@niroai/niro 0.16.0 → 0.18.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 (177) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +123 -302
  3. package/bin/niro.js +12 -1
  4. package/docs/agent-antigravity.md +37 -0
  5. package/docs/agent-claude-code.md +48 -0
  6. package/docs/agent-claude-desktop.md +47 -0
  7. package/docs/agent-cline.md +42 -0
  8. package/docs/agent-codex.md +45 -0
  9. package/docs/agent-copilot-cli.md +42 -0
  10. package/docs/agent-cursor.md +44 -0
  11. package/docs/agent-factory.md +42 -0
  12. package/docs/agent-gemini-cli.md +42 -0
  13. package/docs/agent-goose.md +42 -0
  14. package/docs/agent-kiro.md +37 -0
  15. package/docs/agent-opencode.md +37 -0
  16. package/docs/agent-vscode.md +45 -0
  17. package/docs/agent-windsurf.md +38 -0
  18. package/docs/answer-shapes.md +69 -0
  19. package/docs/builds-and-updates.md +68 -0
  20. package/docs/cloud-README.md +372 -0
  21. package/docs/configuring-a-folder.md +51 -0
  22. package/docs/connect-ci.md +24 -0
  23. package/docs/connect-overview.md +20 -0
  24. package/docs/connect-review-bots.md +17 -0
  25. package/docs/connect-team-server.md +12 -0
  26. package/docs/connect-terminal.md +49 -0
  27. package/docs/connect-web-agents.md +9 -0
  28. package/docs/connect-your-ai-assistant.md +59 -175
  29. package/docs/coverage-audit-and-consent.md +51 -0
  30. package/docs/evaluate-what-niro-did.md +271 -0
  31. package/docs/faq-assistants-and-mcp.md +122 -0
  32. package/docs/faq-feedback-and-receipts.md +33 -0
  33. package/docs/faq-indexing.md +127 -0
  34. package/docs/faq-install-and-setup.md +103 -0
  35. package/docs/faq-licence-and-plans.md +86 -0
  36. package/docs/faq-login-and-account.md +69 -0
  37. package/docs/faq-offline.md +34 -0
  38. package/docs/faq-performance.md +33 -0
  39. package/docs/faq-privacy-and-security.md +113 -0
  40. package/docs/faq-uninstall.md +47 -0
  41. package/docs/faq-updates.md +59 -0
  42. package/docs/faq-worktrees-and-branches.md +59 -0
  43. package/docs/faq-wrong-or-missing-answers.md +275 -0
  44. package/docs/hosted-only.md +33 -0
  45. package/docs/how-the-graph-works.md +47 -0
  46. package/docs/index.md +157 -0
  47. package/docs/licence-and-plans.md +72 -0
  48. package/docs/lifecycle-debug.md +48 -0
  49. package/docs/lifecycle-onboarding.md +32 -0
  50. package/docs/lifecycle-overview.md +77 -0
  51. package/docs/lifecycle-plan-and-design.md +44 -0
  52. package/docs/lifecycle-review.md +30 -0
  53. package/docs/lifecycle-security-and-privacy.md +35 -0
  54. package/docs/lifecycle-test.md +28 -0
  55. package/docs/lifecycle-write-code.md +35 -0
  56. package/docs/llms-full.txt +8015 -0
  57. package/docs/llms.txt +126 -0
  58. package/docs/playbook-add-a-feature.md +108 -0
  59. package/docs/playbook-change-safely.md +93 -0
  60. package/docs/playbook-data-and-events.md +107 -0
  61. package/docs/playbook-debug-after-a-change.md +96 -0
  62. package/docs/playbook-debug-config.md +95 -0
  63. package/docs/playbook-debug-exception.md +124 -0
  64. package/docs/playbook-debug-failing-service-call.md +119 -0
  65. package/docs/playbook-debug-slow-path.md +80 -0
  66. package/docs/playbook-debug-wrong-value.md +137 -0
  67. package/docs/playbook-onboard.md +95 -0
  68. package/docs/playbook-review-a-change.md +72 -0
  69. package/docs/playbook-test-a-change.md +73 -0
  70. package/docs/reference-cli.md +113 -0
  71. package/docs/reference-config-files.md +42 -0
  72. package/docs/reference-engine-commands.md +56 -0
  73. package/docs/reference-environment-variables.md +89 -0
  74. package/docs/reference-exit-codes.md +67 -0
  75. package/docs/reference-limits.md +75 -0
  76. package/docs/reference-mcp-tools.md +326 -0
  77. package/docs/reference-messages.md +180 -0
  78. package/docs/secrets-and-masking.md +48 -0
  79. package/docs/supported-c.md +130 -0
  80. package/docs/supported-cpp.md +157 -0
  81. package/docs/supported-csharp.md +211 -0
  82. package/docs/supported-go.md +258 -0
  83. package/docs/supported-java.md +226 -0
  84. package/docs/supported-javascript.md +223 -0
  85. package/docs/supported-kotlin.md +124 -0
  86. package/docs/supported-overview.md +89 -0
  87. package/docs/supported-php.md +157 -0
  88. package/docs/supported-python.md +224 -0
  89. package/docs/supported-ruby.md +162 -0
  90. package/docs/supported-rust.md +150 -0
  91. package/docs/supported-swift.md +133 -0
  92. package/docs/supported-typescript.md +220 -0
  93. package/docs/tokens-speed-and-accuracy.md +99 -0
  94. package/docs/troubleshooting.md +42 -0
  95. package/docs/uninstall.md +74 -0
  96. package/docs/what-leaves-your-machine.md +71 -0
  97. package/docs/where-things-are-stored.md +59 -0
  98. package/docs/worktrees-and-branches.md +55 -0
  99. package/package.json +5 -3
  100. package/src/commands/add-repo.js +5 -0
  101. package/src/commands/auditSend.js +67 -0
  102. package/src/commands/build.js +6 -0
  103. package/src/commands/docsSend.js +73 -0
  104. package/src/commands/engine.js +120 -0
  105. package/src/commands/engineRoutes.js +105 -0
  106. package/src/commands/guide.js +254 -0
  107. package/src/commands/init.js +16 -7
  108. package/src/commands/initLocal.js +96 -0
  109. package/src/commands/journalSend.js +47 -0
  110. package/src/commands/login.js +89 -12
  111. package/src/commands/mcp.js +22 -6
  112. package/src/commands/plan.js +99 -0
  113. package/src/commands/repoPush.js +33 -0
  114. package/src/commands/requestIntegration.js +38 -0
  115. package/src/commands/share.js +56 -0
  116. package/src/commands/start.js +451 -371
  117. package/src/commands/status.js +57 -0
  118. package/src/commands/takeover.js +6 -0
  119. package/src/commands/uninstall.js +180 -0
  120. package/src/commands/upgrade.js +38 -8
  121. package/src/commands/watch.js +420 -6
  122. package/src/index.js +174 -3
  123. package/src/lib/agentApi.js +32 -1
  124. package/src/lib/api.js +20 -6
  125. package/src/lib/auditUpload.js +293 -0
  126. package/src/lib/branchDrift.js +149 -0
  127. package/src/lib/choose.js +116 -0
  128. package/src/lib/cloudGuard.js +74 -0
  129. package/src/lib/credentials.js +2 -2
  130. package/src/lib/docsMarked.js +55 -0
  131. package/src/lib/docsState.js +213 -0
  132. package/src/lib/docsUpload.js +481 -0
  133. package/src/lib/engine.js +619 -0
  134. package/src/lib/folderSync.js +2 -1
  135. package/src/lib/graphCheck.js +40 -0
  136. package/src/lib/guide.js +293 -0
  137. package/src/lib/guideSearch.js +252 -0
  138. package/src/lib/integrationRequest.js +89 -0
  139. package/src/lib/journalUpload.js +147 -0
  140. package/src/lib/languages.js +129 -0
  141. package/src/lib/lease.js +375 -0
  142. package/src/lib/localBuild.js +285 -0
  143. package/src/lib/localEngine.js +210 -0
  144. package/src/lib/localOnboard.js +124 -0
  145. package/src/lib/localWorktrees.js +243 -0
  146. package/src/lib/machineCheck.js +58 -0
  147. package/src/lib/mcpClients.js +10 -1
  148. package/src/lib/migrationNotice.js +118 -0
  149. package/src/lib/modelFetch.js +153 -0
  150. package/src/lib/nestedOutput.js +23 -9
  151. package/src/lib/onboardingLog.js +34 -0
  152. package/src/lib/planUsage.js +165 -0
  153. package/src/lib/progressBar.js +46 -0
  154. package/src/lib/prompt.js +1 -1
  155. package/src/lib/redact.js +155 -0
  156. package/src/lib/release.js +33 -0
  157. package/src/lib/repoPush.js +495 -0
  158. package/src/lib/secret.js +1 -1
  159. package/src/lib/service.js +372 -63
  160. package/src/lib/shareCode.js +367 -0
  161. package/src/lib/shareExclusions.js +114 -0
  162. package/src/lib/shareFiles.js +129 -0
  163. package/src/lib/spinner.js +9 -5
  164. package/src/lib/watchDaemon.js +33 -10
  165. package/src/lib/watchState.js +12 -1
  166. package/src/lib/windowsPaths.js +67 -0
  167. package/src/mcp/lineCheck.js +382 -0
  168. package/src/mcp/lineName.js +92 -0
  169. package/src/mcp/localBackend.js +751 -0
  170. package/src/mcp/localMode.js +383 -0
  171. package/src/mcp/localSkill.js +189 -0
  172. package/src/mcp/server.js +610 -79
  173. package/src/mcp/setup.js +519 -131
  174. package/src/mcp/shellSearchHook.js +509 -0
  175. package/src/mcp/teardown.js +173 -91
  176. package/src/mcp/usingNiro.js +39 -0
  177. package/docs/working-on-branches.md +0 -162
@@ -10,6 +10,7 @@
10
10
  * Also exported as `startWatch(dir)` so `niro add repo` can transition into
11
11
  * watch mode automatically after initial sync.
12
12
  */
13
+ const crypto = require("crypto");
13
14
  const path = require("path");
14
15
  const fs = require("fs");
15
16
  const api = require("../lib/api");
@@ -22,6 +23,9 @@ const folderSync = require("../lib/folderSync");
22
23
  const git = require("../lib/git");
23
24
  const tempProject = require("../lib/tempProject");
24
25
  const periodicClaim = require("../lib/periodicClaim");
26
+ const localBuild = require("../lib/localBuild");
27
+ const localEngine = require("../lib/localEngine");
28
+ const { startLocalDocs } = require("../lib/docsUpload");
25
29
  const {
26
30
  SOURCE_EXTENSIONS, STRUCTURAL_EXTENSIONS, STRUCTURAL_FILENAMES,
27
31
  IGNORED_DIRS, isConfigName, chunk,
@@ -124,13 +128,22 @@ function recordBackstopSuccess(key) {
124
128
 
125
129
  async function watch(pathArg, opts = {}) {
126
130
  const creds = credentials.requireCreds();
131
+ if (pathArg && !require("../lib/release").cloud) {
132
+ // `niro watch <path>` syncs one folder to the cloud, which this release does not have.
133
+ console.error("`niro watch <path>` syncs a folder to Niro's cloud, which this release does not include. The background watcher (`niro service install`) keeps your local folders current.");
134
+ process.exitCode = 1;
135
+ return;
136
+ }
127
137
 
128
138
  if (!pathArg) {
129
139
  // Watch all registered local repos for the CURRENT backend (entries from
130
140
  // another stack are skipped — their gitIds are invalid here).
131
- const all = watchState.allLocal(api.currentBaseUrl());
132
- if (all.length === 0) {
133
- console.error("No registered local repos found. Run `niro add repo` first.");
141
+ const { cloud: all, local, engineAnswered } = await daemonTargets({ run: opts.run });
142
+ // An engine that answered with no folder yet still gets a daemon: the folder list is read
143
+ // again every round (`followLocalFolders`), so the first `niro init` is picked up without a
144
+ // restart. Only a machine with neither an engine nor a cloud folder has nothing to do.
145
+ if (all.length === 0 && local.length === 0 && !engineAnswered) {
146
+ console.error("No folders to watch. Run `niro start` in a project folder first.");
134
147
  process.exitCode = 1;
135
148
  return;
136
149
  }
@@ -146,6 +159,10 @@ async function watch(pathArg, opts = {}) {
146
159
  watched.add(path.resolve(entry.folderPath));
147
160
  startWatch(entry.folderPath, opts).catch(err => handleEntryFailure(entry, err, watched));
148
161
  }
162
+ // Folders the local engine indexes and nothing registered for the cloud: a Niro Local
163
+ // machine has only these. Followed for as long as the daemon runs, not listed once: see
164
+ // followLocalFolders.
165
+ followLocalFolders(local, { ...opts, cloudFolders: watched });
149
166
  // Pick up folders added by `niro takeover` / `niro add repo` WITHOUT a daemon restart. Those commands
150
167
  // rewrite the state file, so watch it and start watching any NEW entry live. (A takeover that only
151
168
  // re-points an ALREADY-watched folder's gitId is handled per-event by handleFileEvent re-reading
@@ -154,7 +171,9 @@ async function watch(pathArg, opts = {}) {
154
171
  const reconcile = () => {
155
172
  if (reconcileTimer) clearTimeout(reconcileTimer);
156
173
  reconcileTimer = setTimeout(() => {
157
- for (const entry of watchState.allLocal(api.currentBaseUrl())) {
174
+ // The same list as start-up (`hostedFolders`): without the cloud it is empty, so a hosted
175
+ // folder left in projects.json is not started here either when the file is rewritten.
176
+ for (const entry of hostedFolders()) {
158
177
  const resolved = path.resolve(entry.folderPath);
159
178
  if (!watched.has(resolved)) {
160
179
  watched.add(resolved);
@@ -178,7 +197,13 @@ async function watch(pathArg, opts = {}) {
178
197
  } catch (err) {
179
198
  console.error(color.yellow(` Could not watch the state file for new folders: ${err.message}`));
180
199
  }
181
- startPeriodicSweep();
200
+ startPeriodicSweep({ startupDelayMs: sweepStartupDelayMs() });
201
+ startLocalUploads();
202
+ // Documentation requests for folders built with `niro-local build --llm`. Its own timer and
203
+ // claim, kept in docsUpload.js because this file is already over 1,000 lines. Not started while
204
+ // LLM documentation is off (src/lib/release.js `llmDocs`): against an engine with it off every
205
+ // round would end refused, after a token refresh and a log line, every ten minutes.
206
+ if (require("../lib/release").llmDocs) startLocalDocs();
182
207
 
183
208
  // Keep process alive
184
209
  await new Promise(() => {});
@@ -189,6 +214,163 @@ async function watch(pathArg, opts = {}) {
189
214
  await startWatch(targetDir, opts);
190
215
  }
191
216
 
217
+ /**
218
+ * What the no-path daemon watches: `cloud`, the registered folders for this backend, and
219
+ * `local`, every folder the local engine knows (`niro-local projects --json`) that is still
220
+ * on disk and not already among the cloud ones. A machine with no engine has no local folders.
221
+ *
222
+ * Before this the daemon exited when ~/.niro/projects.json was empty, so a Niro Local machine,
223
+ * which registers nothing there, had no daemon at all and "updates automatically" was false.
224
+ */
225
+ /**
226
+ * How long `niro-local projects --json` may take before it is stopped. It answers in well under a
227
+ * second; a call that hangs would otherwise hold the daemon's round for ever, and no later round
228
+ * would run (VP-rel-watch N4). `NIRO_LOCAL_LIST_TIMEOUT_MS` changes it.
229
+ */
230
+ const LIST_TIMEOUT_MS = 20_000;
231
+
232
+ /**
233
+ * The folders registered for hosted Niro that the daemon syncs: the entries of ~/.niro/projects.json
234
+ * for this backend. None without the cloud (src/lib/release.js): a machine that once registered folders
235
+ * for the cloud keeps them in that file, and watching those would keep uploading their source to Niro.
236
+ * Start-up and the re-read after projects.json changes both take their list from here.
237
+ */
238
+ function hostedFolders() {
239
+ return require("../lib/release").cloud ? watchState.allLocal(api.currentBaseUrl()) : [];
240
+ }
241
+
242
+ async function daemonTargets({ run = localEngine.run } = {}) {
243
+ const cloud = hostedFolders();
244
+ const taken = new Set(cloud.map((e) => path.resolve(e.folderPath)));
245
+ const local = [];
246
+ // Whether the engine answered at all, so the daemon can tell "no engine" from "an engine that
247
+ // holds no folder yet" (the second keeps the daemon up, waiting for the first folder).
248
+ let engineAnswered = false;
249
+ let problem = null;
250
+ try {
251
+ const timeoutMs = Number(process.env.NIRO_LOCAL_LIST_TIMEOUT_MS) > 0
252
+ ? Number(process.env.NIRO_LOCAL_LIST_TIMEOUT_MS) : LIST_TIMEOUT_MS;
253
+ const parsed = JSON.parse(await run(["projects", "--json"], undefined, { timeoutMs }));
254
+ engineAnswered = true;
255
+ for (const project of parsed.projects || []) {
256
+ for (const w of project.worktrees || []) {
257
+ const folder = w && typeof w.path === "string" ? path.resolve(w.path) : null;
258
+ if (!folder || taken.has(folder) || !fs.existsSync(folder)) continue;
259
+ taken.add(folder);
260
+ local.push(folder);
261
+ }
262
+ }
263
+ } catch (err) {
264
+ // No engine, or one that cannot answer: no local folders. The reason is passed back for the
265
+ // daemon's rounds, which say it once; the start-up caller only needs the yes or no.
266
+ problem = (err && err.message) || String(err);
267
+ }
268
+ return { cloud, local, engineAnswered, problem };
269
+ }
270
+
271
+ /**
272
+ * How often the daemon asks niro-local again which folders it indexes. Thirty seconds: one
273
+ * `niro-local projects --json` and two git calls per held repository, so a folder indexed by
274
+ * `niro init` or a `git worktree add` is followed within half a minute without a restart.
275
+ * `NIRO_LOCAL_RECONCILE_MS` changes it (read when the daemon starts; under one second is ignored).
276
+ */
277
+ const LOCAL_RECONCILE_MS = 30_000;
278
+
279
+ /** Said under a failed new-worktree build: that folder is not watched yet, so saving does nothing. */
280
+ const WORKTREE_RETRY_HINT = () => `It is tried again in a few minutes. To see why now, run \`${require("../lib/release").rebuildCommand()}\` in that folder.`;
281
+
282
+ /** The local folders being watched now: resolved folder -> handle with `stop()`. */
283
+ const localWatches = new Map();
284
+
285
+ /**
286
+ * Keep the daemon's local folders the same as niro-local's list, for as long as it runs.
287
+ *
288
+ * Until release finding 6 the list was read once, when the daemon started, so a folder indexed
289
+ * afterwards was never watched until the next reboot, a folder removed with `niro-local remove` was
290
+ * still built on every save (and each build failed in red), and a new git worktree of a held
291
+ * repository was never indexed. Now each round:
292
+ * - a folder niro-local lists and nobody watches starts being watched;
293
+ * - a watched folder it no longer lists, or that is gone from disk, stops being watched;
294
+ * - a worktree of a held repository that is new since the repository was first seen is built
295
+ * with `niro-local build` (`localWorktrees.js`), which makes its temporary copy; the next
296
+ * round finds the copy in the list and watches it. A build that fails is tried twice more,
297
+ * minutes apart, then given up with one line.
298
+ * A round where the engine cannot answer changes nothing, so a passing failure never stops a watch.
299
+ *
300
+ * `cloudFolders` are left to the cloud half. Returns `{ stop, round }`; `round` is for tests.
301
+ */
302
+ function followLocalFolders(initial, {
303
+ run, intervalMs, log = console.log, cloudFolders = new Set(),
304
+ newWorktreeFolders = require("../lib/localWorktrees").newWorktreeFolders,
305
+ } = {}) {
306
+ const every = usableReconcileMs(intervalMs) || usableReconcileMs(Number(process.env.NIRO_LOCAL_RECONCILE_MS))
307
+ || LOCAL_RECONCILE_MS;
308
+ let busy = false;
309
+ let failing = false;
310
+
311
+ const apply = async (folders, { first = false } = {}) => {
312
+ const wanted = new Set(folders.map((f) => path.resolve(f)).filter((f) => !cloudFolders.has(f)));
313
+ for (const folder of wanted) {
314
+ if (localWatches.has(folder)) continue;
315
+ if (!first) log(` ${color.green("\u21bb")} Now watching new folder: ${folder}`);
316
+ await startLocalWatch(folder, { once: true, log });
317
+ }
318
+ for (const [folder, handle] of [...localWatches]) {
319
+ // A cloud folder that niro-local also indexes is watched through the cloud half
320
+ // (`startWatch` hands it to `startLocalWatch`), so it is in this map but never in `wanted`.
321
+ // It is not this round's to stop (VP-rel-watch N2: it lost its watch at the first round).
322
+ if (wanted.has(folder) || cloudFolders.has(folder)) continue;
323
+ handle.stop();
324
+ const why = fs.existsSync(folder) ? "niro-local no longer indexes it" : "it is gone from disk";
325
+ log(` Stopped watching ${folder}: ${why}.`);
326
+ }
327
+ for (const folder of await newWorktreeFolders([...wanted], { log })) {
328
+ log(` ${color.green("\u21bb")} New worktree of a repository niro-local indexes: building ${folder}`);
329
+ localBuild.noteChange(folder, { settleMs: 0, log, hint: WORKTREE_RETRY_HINT() });
330
+ }
331
+ };
332
+
333
+ const round = async () => {
334
+ if (busy) return;
335
+ busy = true;
336
+ try {
337
+ const { local, engineAnswered, problem } = await daemonTargets({ run });
338
+ if (engineAnswered) {
339
+ if (failing) log(` niro-local answers again; following its folders.`);
340
+ failing = false;
341
+ await apply(local);
342
+ } else if (!failing) {
343
+ // Said once per run of failures, not every 30 s. Nothing is stopped meanwhile.
344
+ failing = true;
345
+ log(color.yellow(` Could not ask niro-local for its folders (${problem}); watching the same folders and asking again every ${Math.round(every / 1000)} s.`));
346
+ }
347
+ } catch (err) {
348
+ log(color.yellow(` Could not check niro-local's folders: ${err && err.message ? err.message : err}`));
349
+ } finally {
350
+ busy = false;
351
+ }
352
+ };
353
+
354
+ busy = true;
355
+ const started = apply(initial, { first: true })
356
+ .catch((err) => log(color.red(` Could not watch niro-local's folders: ${err && err.message ? err.message : err}`)))
357
+ .finally(() => { busy = false; });
358
+ // Not unref'd: with no folder yet this timer is what keeps the daemon up, waiting for the first.
359
+ const timer = setInterval(round, every);
360
+ return {
361
+ started,
362
+ round,
363
+ stop: () => {
364
+ clearInterval(timer);
365
+ for (const handle of [...localWatches.values()]) handle.stop();
366
+ },
367
+ };
368
+ }
369
+
370
+ function usableReconcileMs(ms) {
371
+ return Number.isFinite(ms) && ms >= 1000 ? ms : undefined;
372
+ }
373
+
192
374
  /**
193
375
  * How often the daemon sweeps for temporary projects that have gone from the server. Six hours, not
194
376
  * six seconds: its job is repairing pointers that go stale over days, not reacting to keystrokes.
@@ -201,6 +383,16 @@ const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
201
383
  */
202
384
  const SWEEP_STARTUP_DELAY_MS = 60 * 1000;
203
385
 
386
+ /**
387
+ * The first sweep's delay. `NIRO_SWEEP_STARTUP_DELAY_MS` (at least 1000) shortens it, so the end-to-end
388
+ * test of the daemon (test/watchHostedGuard.test.js) does not wait a minute; it changes only when the
389
+ * first sweep runs, since every run is still gated by the six-hour claim.
390
+ */
391
+ function sweepStartupDelayMs() {
392
+ const ms = Number(process.env.NIRO_SWEEP_STARTUP_DELAY_MS);
393
+ return Number.isFinite(ms) && ms >= 1000 ? ms : SWEEP_STARTUP_DELAY_MS;
394
+ }
395
+
204
396
  /**
205
397
  * Periodically run `niro cleanup-temp-projects`. Machine-wide, so it belongs HERE, in the no-path
206
398
  * branch, and not in startWatch — startWatch runs once per folder, and this must run once per machine.
@@ -239,6 +431,96 @@ function startPeriodicSweep({ intervalMs = SWEEP_INTERVAL_MS, startupDelayMs = S
239
431
  return { stop: () => { clearTimeout(first); clearInterval(timer); } };
240
432
  }
241
433
 
434
+ /**
435
+ * How often the local engine's queues are emptied: its coverage-audit findings and its record
436
+ * of which MCP tools were asked for.
437
+ *
438
+ * Five minutes (Divya, 2026-11-05; it was half an hour). The engine holds back a session that is
439
+ * still in use and the audit queue dedups by content hash, so a run that finds nothing costs one
440
+ * licence request and nothing is sent twice.
441
+ */
442
+ const LOCAL_UPLOAD_INTERVAL_MS = 5 * 60 * 1000;
443
+
444
+ /** The same reasoning as the sweep's delay: let the daemon's startup burst settle first. */
445
+ const LOCAL_UPLOAD_STARTUP_DELAY_MS = 2 * 60 * 1000;
446
+
447
+ /**
448
+ * Periodically send what the local engine is holding.
449
+ *
450
+ * Until now both of these needed a person to type `niro audit-send` or `niro journal-send`,
451
+ * which means in practice they were never sent: a developer has no reason to run either and
452
+ * no way to know there is anything waiting.
453
+ *
454
+ * **Why on a timer rather than after each build.** The audit's findings come out of a build,
455
+ * so after-each-build looks like the obvious hook. But the watcher builds after every quiet
456
+ * period, which during a working session is every few minutes, and an upload per build is a
457
+ * request per save for a queue that is usually empty. The journal has no build to hang off at
458
+ * all. One timer, three jobs.
459
+ *
460
+ * **The third job, shared code (2026-11-11).** While the lease says `share_code: true`, the code
461
+ * of every indexed folder that is not excluded is sent, redacted here: the whole indexed set the
462
+ * first time, then only changed files (src/lib/shareCode.js, decision 01M75JHE64PYYNKR2G4JJ4JCZ3).
463
+ * The audit and the journal do not depend on that switch; this job does nothing without it.
464
+ *
465
+ * **Machine-wide, so it belongs in the no-path branch** beside the temporary-project sweep,
466
+ * and gated by periodicClaim for the same two reasons: PM2 restarting the daemon must not turn
467
+ * a half-hourly job into one that runs on every restart, and a `niro watch` left in a terminal
468
+ * must not upload at the same moment as the daemon.
469
+ *
470
+ * **It stays quiet on a machine with no engine.** A cloud-only developer has no niro-local, so
471
+ * each attempt fails to run it; that is never logged, and the next round tries again, so an
472
+ * engine installed later is sent from without restarting the daemon.
473
+ *
474
+ * Never throws: an upload failure must not touch the daemon's actual job.
475
+ */
476
+ function startLocalUploads({
477
+ intervalMs = LOCAL_UPLOAD_INTERVAL_MS,
478
+ startupDelayMs = LOCAL_UPLOAD_STARTUP_DELAY_MS,
479
+ claimName = "localEngineUploads",
480
+ log = console.log,
481
+ } = {}) {
482
+ let stopped = false;
483
+ const handle = { stop: () => {} };
484
+
485
+ const run = async () => {
486
+ if (stopped || !periodicClaim.claim(claimName, intervalMs)) return;
487
+ for (const job of [
488
+ { what: "audit findings", send: () => require("../lib/auditUpload").uploadPending() },
489
+ { what: "MCP journal", send: () => require("../lib/journalUpload").uploadPending() },
490
+ // Only while "Help improve Niro" is on; the rest of its rules are in shareCode.js.
491
+ { what: "shared code files", send: () => require("../lib/shareCode").shareRound() },
492
+ ]) {
493
+ try {
494
+ const result = await job.send();
495
+ const sent = result.submitted || result.sent || 0;
496
+ if (sent > 0) log(` Sent ${sent} ${job.what} to Niro.`);
497
+ } catch (err) {
498
+ const message = (err && err.message) || String(err);
499
+ if (/cannot run /.test(message)) {
500
+ // No engine on this machine yet: nothing to send this round, and nothing worth saying.
501
+ // Tried again next round, so an engine installed after the daemon started is picked up
502
+ // without a restart (until 2026-11-06 the timer stopped here for good).
503
+ return;
504
+ }
505
+ // Said rather than swallowed: a queue that silently never drains is the failure this
506
+ // whole path exists to prevent. Once per interval, not once per item.
507
+ log(color.yellow(` Could not send the ${job.what}: ${message}`));
508
+ }
509
+ }
510
+ };
511
+
512
+ const first = setTimeout(run, startupDelayMs);
513
+ const timer = setInterval(run, intervalMs);
514
+ if (first.unref) first.unref();
515
+ if (timer.unref) timer.unref();
516
+ handle.stop = () => {
517
+ stopped = true;
518
+ clearTimeout(first);
519
+ clearInterval(timer);
520
+ };
521
+ return handle;
522
+ }
523
+
242
524
  /**
243
525
  * One watch entry failed to start (its startup delta sync threw). Contain the failure to that
244
526
  * entry — and when the cause is "the repo no longer exists on this backend" (a poison entry left
@@ -277,7 +559,139 @@ async function handleEntryFailure(entry, err, watched) {
277
559
  `Watch error for ${entry.folderPath}: ${err.message} — entry kept, will retry on next daemon start.`));
278
560
  }
279
561
 
562
+ /**
563
+ * Watch a folder the local engine indexes: rebuild it when it goes quiet.
564
+ *
565
+ * Self-contained rather than branching through startWatch below, and the ~8 lines of
566
+ * fs.watch setup are duplicated on purpose. Two reasons, written down because a silent
567
+ * copy-paste is not allowed. First, the cloud half of this file carries protections that
568
+ * were each paid for by a real failure, and its fs.watch wiring is NOT covered by any test
569
+ * ("the fs.watch wiring is not driven here", test/watchDeleteSettle.test.js), so
570
+ * restructuring it to share with a new path would be an unguarded change to the riskiest
571
+ * code here. Second, phase 12 of the plan deletes the cloud half outright, so the
572
+ * duplication ends by deletion rather than by an abstraction that would then have one
573
+ * caller.
574
+ *
575
+ * What is NOT duplicated, because it is not needed: the per-file hashes, the gitignore
576
+ * check at upload time, the manifest scope and the heartbeat. The engine compares the whole
577
+ * folder against its own content hashes on every build, so it finds a change this watcher
578
+ * missed without being told. The one thing it cannot do is know that something happened,
579
+ * which is what this provides: a file event, or, for an event fs.watch dropped, a re-check of
580
+ * the folder's file sizes and times every few minutes (`localBuild.startRecheck`, rank 176; the
581
+ * cloud half's backstop re-hashes every file each minute, which a local build does not need).
582
+ */
583
+ /** How often a folder is checked for changes when this Node cannot watch a folder tree (startLocalWatch). */
584
+ const WATCHLESS_POLL_MS = 10_000;
585
+
586
+ async function startLocalWatch(targetDir, opts = {}) {
587
+ const log = opts.log || console.log;
588
+ // One watch per folder, whichever path asks for it (the daemon's list and a cloud entry that
589
+ // turns out to be local can both name the same folder).
590
+ const existing = localWatches.get(path.resolve(targetDir));
591
+ if (existing) return existing;
592
+ let watcher = null;
593
+ const handle = {
594
+ stop: () => {
595
+ if (localWatches.get(path.resolve(targetDir)) === handle) localWatches.delete(path.resolve(targetDir));
596
+ if (watcher) watcher.close();
597
+ watcher = null;
598
+ localBuild.forget(targetDir);
599
+ },
600
+ };
601
+ localWatches.set(path.resolve(targetDir), handle);
602
+ log(`\n ${color.cyan("Watching")} ${targetDir}`);
603
+ log(` ${color.dim("indexed locally by niro-local")}`);
604
+
605
+ // Build once at startup. The folder may have changed while nothing was watching it, and
606
+ // that is the stale-graph case this exists to prevent; waiting for the next edit would
607
+ // leave it stale until one happened to arrive.
608
+ localBuild.noteChange(targetDir, { settleMs: 0, log });
609
+
610
+ // A Node that cannot watch a folder tree (Node 18 on Linux, Ubuntu 24.04's own package) throws here. It used to
611
+ // end the watch before the re-check below was started, with the folder still marked as watched, so nothing ever
612
+ // synced while PM2 showed the watcher online (2026-11-11). Then the re-check is the watcher, every 10 s.
613
+ let pollMs;
614
+ try {
615
+ watcher = fs.watch(targetDir, { recursive: true }, (eventType, filename) => {
616
+ if (!filename) return;
617
+ const relativePath = filename.replace(/\\/g, "/");
618
+ if (shouldIgnore(relativePath)) return;
619
+ // Straight to the folder's settle timer, with no per-file debounce in between: the
620
+ // settle timer already collapses a burst, and adding the per-file 2s on top would only
621
+ // delay every rebuild by two seconds for nothing.
622
+ localBuild.noteChange(targetDir, { log });
623
+ });
624
+ } catch (err) {
625
+ pollMs = WATCHLESS_POLL_MS;
626
+ log(color.yellow(` Node ${process.version} cannot watch a folder tree here (${err.code || err.message}); checking ${targetDir} for changes every ${pollMs / 1000} s instead. Node 20 or later sees changes as they happen.`));
627
+ }
628
+
629
+ // A watch that errors (its folder deleted, too many open files) is stopped rather than left
630
+ // half alive; the daemon starts it again next round if niro-local still lists the folder.
631
+ if (watcher) watcher.on("error", (err) => {
632
+ // A folder deleted on purpose (`git worktree remove`) is not a fault: said plainly, not in red.
633
+ if (!fs.existsSync(targetDir)) log(` Stopped watching ${targetDir}: it is gone from disk.`);
634
+ else log(color.red(` Watcher error for ${targetDir}: ${err.message}`));
635
+ handle.stop();
636
+ });
637
+
638
+ // fs.watch drops events, so a few minutes apart the folder is listed again and rebuilt when it
639
+ // changed unseen (rank 176). Stopped by `forget` below. Without fs.watch it is the watcher (pollMs).
640
+ localBuild.startRecheck(targetDir, { intervalMs: pollMs, list: () => folderDigest(targetDir), log });
641
+
642
+ log(` ${color.green("\u2713")} Watching for changes. Press Ctrl+C to stop.\n`);
643
+
644
+ // `once` is the daemon (and tests): it returns the handle and the daemon stops it. A Ctrl+C
645
+ // handler per folder there would add one listener per folder for nothing.
646
+ if (opts.once) return handle;
647
+
648
+ process.on("SIGINT", () => {
649
+ handle.stop();
650
+ log("\n Watch stopped.");
651
+ process.exit(0);
652
+ });
653
+ await new Promise(() => {});
654
+ }
655
+
656
+ /**
657
+ * A digest of a locally indexed folder for the periodic re-check: every file the walk keeps (the same
658
+ * walk and filters as the cloud manifest, `buildMinimalManifest`), with its size and modification time.
659
+ * No file is read. Null when a folder under it cannot be listed: unknown is not "changed".
660
+ */
661
+ function folderDigest(rootDir) {
662
+ const { files, failedDirs } = buildMinimalManifest(rootDir);
663
+ if (failedDirs.length > 0) return null;
664
+ const hash = crypto.createHash("sha1");
665
+ for (const relativePath of files.sort()) {
666
+ let stamp = "gone";
667
+ try {
668
+ const stat = fs.statSync(path.join(rootDir, relativePath));
669
+ stamp = `${stat.mtimeMs}:${stat.size}`;
670
+ } catch {
671
+ // Removed between the walk and the stat: a change like any other.
672
+ }
673
+ hash.update(`${relativePath}\t${stamp}\n`);
674
+ }
675
+ return hash.digest("hex");
676
+ }
677
+
280
678
  async function startWatch(targetDir, opts = {}) {
679
+ // A locally indexed folder has no gitId and nothing to upload, so it is answered before
680
+ // the registration guard below, which would otherwise refuse it.
681
+ if (await localBuild.isLocallyIndexed(targetDir)) {
682
+ return startLocalWatch(targetDir, opts);
683
+ }
684
+
685
+ // Without the cloud (src/lib/release.js) nothing is synced to hosted Niro. Every sync of a folder
686
+ // starts here (daemon start-up, the re-read when projects.json changes, `niro watch <path>`), so this
687
+ // one check stops all of them: the delta sync below uploads whole files, unmasked. Before it, the
688
+ // re-read started syncing old hosted folders whenever projects.json was rewritten, including by the
689
+ // daemon's own sweep (2026-11-10).
690
+ if (!require("../lib/release").cloud) {
691
+ console.log(` Not syncing ${targetDir}: the local engine does not index it, and this release does not sync folders to hosted Niro.`);
692
+ return;
693
+ }
694
+
281
695
  // Scope to the current backend: a folder registered against a different stack
282
696
  // has a gitId that is invalid here, so resume must refuse rather than 404 on
283
697
  // every sync. (The daemon's allLocal() already filters; this also guards the
@@ -976,7 +1390,7 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
976
1390
  // gitignore/fail-closed guards without a live server or fs.watch loop. matchesPatterns is
977
1391
  // exported for the same reason: it is the gate all three sync paths share, and a pattern that
978
1392
  // cannot be compiled stops every one of them, so it is worth testing on its own.
979
- module.exports = { watch, startWatch, startPeriodicSweep, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
1393
+ module.exports = { watch, daemonTargets, followLocalFolders, localWatches, LOCAL_RECONCILE_MS, startWatch, startLocalWatch, folderDigest, startPeriodicSweep, startLocalUploads, startLocalDocs, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
980
1394
  matchesPatterns,
981
1395
  queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
982
1396
  unreadableRoots,