@niroai/niro 0.17.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 +136 -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 +6 -2
  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
@@ -0,0 +1,751 @@
1
+ /**
2
+ * The bridge's other backend: the engine on this machine.
3
+ *
4
+ * One bridge, two backends, chosen per call (Niraj, 2026-10-14). A folder indexed by the local
5
+ * engine is answered from the graph on this machine; everything else goes to the cloud exactly
6
+ * as before. This file is the local half: it starts the engine's own MCP server as a child,
7
+ * speaks MCP to it, and answers one tool call at a time.
8
+ *
9
+ * **Why speak MCP to it rather than add a one-shot command to the engine.** A process per call
10
+ * would pay the engine's startup and its database connection on every question, and three
11
+ * things that only exist within a session would be lost: the licence, which arrives on a pipe
12
+ * and is renewed while the process lives; the once-per-session notice saying the graph is
13
+ * behind; and the journal, which records a session rather than a pile of unrelated calls.
14
+ *
15
+ * **The lease travels the same way it does for a build.** The child is started with the two
16
+ * extra pipes and `lease.attach` serves it, so a released engine is licensed for as long as the
17
+ * editor is connected. That is the MCP half of the licence work, and it is why this module
18
+ * starts the engine rather than the engine starting itself.
19
+ *
20
+ * **Every tool stays advertised** (Niraj: "agreed that's the best approach"). The host's
21
+ * `tools/list` is still answered by the cloud, so the assistant sees one toolset. A tool the
22
+ * local graph cannot serve answers in one line saying so and what to run, rather than
23
+ * disappearing from the list mid-session or returning an empty result that reads like "there
24
+ * is nothing there".
25
+ */
26
+ const { spawn } = require("child_process");
27
+
28
+ const lease = require("../lib/lease");
29
+ const localEngine = require("../lib/localEngine");
30
+ const receiptStore = require("../lib/receiptStore");
31
+ const lineName = require("./lineName");
32
+
33
+ /**
34
+ * Cloud names that the engine serves under a different one.
35
+ *
36
+ * Kept small and explicit on purpose. The cloud now accepts `resolve_name` as an alias of its
37
+ * own (phase 1 of the local-engine plan), so the vocabulary is converging; this is the other
38
+ * direction, for a host that calls the cloud's name.
39
+ */
40
+ const ALIASES = { resolve_fqn: "resolve_name" };
41
+
42
+ /**
43
+ * The cloud's argument names, and the engine's name for the same thing.
44
+ *
45
+ * **Why this is needed.** The host is given the CLOUD's schema, because `tools/list` is
46
+ * answered by the cloud so that the assistant sees one toolset whichever graph is behind it.
47
+ * The arguments that arrive are therefore the cloud's, and the cloud names the thing being
48
+ * asked about after its kind: `method_fqn` for a method, `class_fqn` for a class, `fqn`,
49
+ * `entity_fqn` and `resource_fqn`. The engine calls it `name` in every tool. Nothing
50
+ * translated between the two, so the engine's own argument validation refused the call before
51
+ * the graph was touched: measured over stdio on 2026-10-16, `get_call_chain` with
52
+ * `method_fqn` answered "1 validation error ... name Field required" and the same question with
53
+ * `name` answered in full. Nine of the engine's eighteen tools failed that way and they are the
54
+ * structural core, which is why a locally indexed folder produced no call chain, no blast radius
55
+ * and no resolve at all.
56
+ *
57
+ * Keyed by the name the host called AND by the engine's own name where the two differ, because
58
+ * an editor connected straight to `niro-local mcp` sends the engine's names instead.
59
+ *
60
+ * **The order of the keys is the order of precedence**, for a host that sends two seeds at
61
+ * once. A method before a class, because the narrower question is the one that was asked: a
62
+ * blast radius for one method is not the blast radius of its whole class.
63
+ *
64
+ * An argument the engine does not declare is ignored by it rather than refused, which is why
65
+ * only a missing required one broke a call and why nothing here has to drop the rest.
66
+ */
67
+ const ARGUMENT_NAMES = {
68
+ resolve_fqn: { entity_fqn: "name" },
69
+ resolve_name: { entity_fqn: "name" },
70
+ // Not transitive_depth: the engine reads it under that name, with upstream's meaning, since
71
+ // niro-local's call-chain rewrite of 2026-10-23. Renamed to `depth` it meant something else
72
+ // on the reverse chain, where upstream's `depth` is not an alias.
73
+ get_call_chain: { method_fqn: "name" },
74
+ get_reverse_call_chain: { method_fqn: "name", class_fqn: "name" },
75
+ get_blast_radius: { method_fqn: "name", class_fqn: "name", event_fqn: "name" },
76
+ get_class_structure: { class_fqn: "name" },
77
+ get_exception_flow: { method_fqn: "name" },
78
+ // Not get_event_producers_and_handlers: the cloud's `type` there is 'producers' | 'handlers'
79
+ // | 'both', the side to return (ai-assistant GetEventProducersAndHandlersMcpTool.java:42-44),
80
+ // not an event name. Renamed to `name`, `type: "producers"` asked the engine for an event
81
+ // called "producers" and was told there was none. The engine reads `type` itself since
82
+ // niro-local 9beca43.
83
+ find_handler: { fqn: "name" },
84
+ get_data_resource_usage: { resource_fqn: "name" },
85
+ };
86
+
87
+ /**
88
+ * The cloud's batch forms. The engine answers one name per call, so these are answered one at
89
+ * a time and the answers joined, rather than refused: a host that asks about every method of a
90
+ * client class in one call is doing the right thing, and the engine is local, so the extra
91
+ * calls cost a few milliseconds each.
92
+ */
93
+ const BATCH_NAMES = ["method_fqns", "class_fqns", "entity_fqns", "fqns", "targets"];
94
+
95
+ /**
96
+ * How many of a batch are answered. A cap because the answers are concatenated and an
97
+ * assistant's context is the scarce thing; said in the answer when it bites, so nobody is left
98
+ * believing they saw the lot.
99
+ */
100
+ const BATCH_LIMIT = 10;
101
+
102
+ /**
103
+ * The tools whose question can be asked by route instead of by name, and what the engine needs
104
+ * instead.
105
+ *
106
+ * The cloud resolves a route to its handler for you: `get_call_chain` says it "accepts
107
+ * method_fqn (partial auto-resolves) or http_path", and `get_blast_radius` says http_path
108
+ * "resolves to handler method automatically". The engine resolves a NAME, so an untranslated
109
+ * route arrived as a missing `name` and the call was refused, which is the same error this file
110
+ * exists to remove (measured by an independent verifier on 2026-10-16: 3 of 3 refused). The
111
+ * engine does hold the answer, under `find_api_endpoints`, so the route is turned into its
112
+ * handler's name first and the real question asked about that.
113
+ */
114
+ const BY_ROUTE = ["get_call_chain", "get_reverse_call_chain", "get_blast_radius"];
115
+
116
+ /**
117
+ * One line of the form the engine's own endpoint list is rendered in
118
+ * (`niro-local/src/niro_local/mcp/server.py` `_render_endpoints`, which writes
119
+ * `1. POST /api/orders/ -> com.shop.OrderController.create`). Read from there rather than from
120
+ * a sample answer, so a change to that renderer is a change to one regular expression here.
121
+ */
122
+ const ENDPOINT_LINE = /^\s*\d+\.\s+(\S+)\s+(\S+)\s+->\s+(.+?)\s*$/;
123
+
124
+ /**
125
+ * Arguments the engine does not have, and what the answer therefore is NOT.
126
+ *
127
+ * Said in the answer rather than dropped in silence. An argument the engine ignores is
128
+ * normally harmless, because the engine's answer is a superset or the argument only shapes the
129
+ * cloud's presentation (`purpose`, `rationale`), and a notice for those would be noise on every
130
+ * call. These are the ones where ignoring the argument changes what the answer MEANS, so a
131
+ * reader who is not told draws a false conclusion: asked for callers, `get_exception_flow`
132
+ * answers the downstream flow and looks like an answer; asked for upstream only,
133
+ * `get_blast_radius` includes the downstream half as well; asked for three of something, a list
134
+ * the engine does not page returns all of it.
135
+ */
136
+
137
+ const NOT_HONOURED = {
138
+ get_exception_flow: {
139
+ direction: (value) => {
140
+ const asked = String(value).toLowerCase();
141
+ return asked === "upstream" || asked === "callers"
142
+ ? "the local graph answers the downstream flow only, so this is where the exceptions " +
143
+ "reach from here, not what reaches this method"
144
+ : null;
145
+ },
146
+ exception_type: () => "the local graph does not filter this flow by exception type",
147
+ },
148
+ get_blast_radius: {
149
+ direction: (value) => {
150
+ const asked = String(value).toLowerCase();
151
+ return asked === "upstream" || asked === "downstream"
152
+ ? "the local graph answers both directions at once, so the other half is in here too"
153
+ : null;
154
+ },
155
+ include_source_code: () =>
156
+ "the local graph holds structure and not source text, so no source is included",
157
+ },
158
+ };
159
+
160
+ /**
161
+ * A notice is only worth saying when the argument would have changed the answer.
162
+ *
163
+ * `purpose` and `rationale` shape the cloud's presentation and nothing else, so they get no
164
+ * notice: one on every call is noise, and noise is read as decoration. Nor does an argument
165
+ * the engine honours anyway, which is why each entry decides from the VALUE: asked for both
166
+ * directions, the engine's blast radius is exactly the answer, and a notice there would be
167
+ * wrong as well as noisy.
168
+ */
169
+ function notHonoured(toolName, args) {
170
+ const table = NOT_HONOURED[toolName];
171
+ if (!table) return null;
172
+ const said = [];
173
+ for (const [argument, decide] of Object.entries(table)) {
174
+ if (!(argument in args) || args[argument] === undefined || args[argument] === null) continue;
175
+ const sentence = decide(args[argument]);
176
+ if (sentence && !said.includes(sentence)) said.push(sentence);
177
+ }
178
+ if (said.length === 0) return null;
179
+ return `NOTE FROM niro: ${said.join("; and ")}.`;
180
+ }
181
+
182
+ /** How long to wait for the engine to answer one call before giving up on it. */
183
+ const CALL_TIMEOUT_MS = Number(process.env.NIRO_LOCAL_CALL_TIMEOUT_MS) || 30_000;
184
+
185
+ /** How long to wait for the engine to start and list its tools. */
186
+ const START_TIMEOUT_MS = Number(process.env.NIRO_LOCAL_START_TIMEOUT_MS) || 30_000;
187
+
188
+ /** Said at the top of every local answer, so nobody has to guess which graph answered. */
189
+ const LOCAL_MARKER = "Answered from the local graph on this machine (niro-local).";
190
+
191
+ /**
192
+ * One line of text that is an end-of-task line: `[NIRO-CLI] ...` from the engine (`[NIRO-LOCAL] ...`
193
+ * from an engine older than 2026-11-09, `lineName.js`), `[NIRO] ...` from the cloud, with spaces or a
194
+ * backtick allowed in front. Tested on each line from its start
195
+ * only, so what follows it (a backtick, a Windows "\r") and the layout around it do not matter.
196
+ */
197
+ const NIRO_LINE = new RegExp(`^[ \\t]*\`?${lineName.PREFIX_SOURCE} `);
198
+ /**
199
+ * How the engine's own notices open (niro-local `freshness.py`: "NOTE FROM niro-local: the graph is
200
+ * behind ...", "... ACTION REQUIRED ..."; `arguments.py`: "⚠ This call also passed ..."). Kept when
201
+ * the line is taken out; "NOTE FROM niro-local:" is also what starts the background rebuild
202
+ * (localBuild.js BEHIND_MARKER).
203
+ */
204
+ const NOTICE_OPENINGS = ["NOTE FROM", "⚠"];
205
+ /** Said in place of the line when `niro mcp receipt --off` is set. */
206
+ const LINE_SWITCHED_OFF =
207
+ "The end-of-task Niro line is switched off on this machine, so there is no line to show. Print nothing.";
208
+
209
+ class LocalBackend {
210
+ constructor({ log = (line) => process.stderr.write(`[niro-mcp] ${line}\n`) } = {}) {
211
+ this.log = log;
212
+ this.child = null;
213
+ this.licence = null;
214
+ this.tools = null;
215
+ this.client = null;
216
+ // The host's own initialize is passed to the engine, and the engine's answers to it and to tools/list are
217
+ // handed back to the host unchanged (`localSend` in server.js: local mode, and every release without the cloud).
218
+ this.hostInitialize = null;
219
+ this.initializeResult = null;
220
+ this.toolList = null;
221
+ this.nextId = 1;
222
+ this.waiting = new Map();
223
+ this.buffered = "";
224
+ this.starting = null;
225
+ }
226
+
227
+ /** Remember the assistant's `clientInfo`, passed on to the engine when it starts. */
228
+ setClient(info) {
229
+ if (info && typeof info.name === "string" && info.name) {
230
+ this.client = { name: info.name.slice(0, 100), version: String(info.version || "").slice(0, 50) };
231
+ }
232
+ }
233
+
234
+ /** Start the engine and learn which tools it serves. Safe to call repeatedly. */
235
+ async start() {
236
+ // `starting` first: the child exists from the first moment of _start, before the engine has
237
+ // answered initialize and tools/list, so a call arriving meanwhile must wait for that, not find
238
+ // `tools` still empty (local mode sends initialize, tools/list and a call back to back).
239
+ if (this.starting) return this.starting;
240
+ if (this.child && !this.child.killed) return this.tools;
241
+ this.starting = this._start().finally(() => {
242
+ this.starting = null;
243
+ });
244
+ return this.starting;
245
+ }
246
+
247
+ async _start() {
248
+ const executable = localEngine.enginePath();
249
+ // Marked in use while it runs, so an upgrade does not delete its folder under it (`engine.startHeld`).
250
+ const child = require("../lib/engine").startHeld(executable, () =>
251
+ spawn(executable, ["mcp"], {
252
+ // Three pipes for MCP itself plus the two the licence travels on, which is why the
253
+ // engine is started here and not by the editor: only the CLI can fetch a licence.
254
+ stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"],
255
+ env: lease.childEnvironment(),
256
+ })
257
+ );
258
+ this.child = child;
259
+ this.licence = lease.attach(child, { log: (line) => this.log(line.trim()) });
260
+
261
+ child.stdout.on("data", (chunk) => this._onData(chunk));
262
+ // The engine's standard error is its own log, not an answer. Passed through to ours so a
263
+ // refusal or a database problem is visible to whoever is reading the bridge's log.
264
+ child.stderr.on("data", (chunk) => {
265
+ const text = chunk.toString().trim();
266
+ if (text) this.log(`engine: ${text}`);
267
+ });
268
+ child.on("error", (problem) => this._fail(`could not start the engine: ${problem.message}`));
269
+ // An engine that exits at once (a refused licence, a broken binary) closes its input before the handshake is
270
+ // written to it, and the write fails with EPIPE. Node reports that as an event on this pipe, and with no listener
271
+ // it is thrown and the whole bridge dies without answering anything. Failing the waiting calls instead lets
272
+ // localSend (server.js) answer the handshake with an empty tool list, as it does for a missing engine.
273
+ child.stdin.on("error", (problem) => {
274
+ if (this.child === child) this._fail(`the engine closed its input (${problem.code || problem.message})`);
275
+ });
276
+ child.on("close", (code) => {
277
+ this._fail(`the engine stopped (exit ${code})`);
278
+ this.child = null;
279
+ this.tools = null;
280
+ });
281
+
282
+ this.initializeResult = await this._send("initialize", this.hostInitialize || {
283
+ protocolVersion: "2024-11-05",
284
+ capabilities: {},
285
+ // The assistant's own name and version, from its handshake with the bridge, so the engine's
286
+ // journal records which assistant asked (agent_name); the bridge's when there was none.
287
+ clientInfo: this.client || { name: "niro-cli-bridge", version: require("../../package.json").version },
288
+ }, START_TIMEOUT_MS);
289
+ // A notification, so there is nothing to wait for.
290
+ this._write({ jsonrpc: "2.0", method: "notifications/initialized" });
291
+ const listed = await this._send("tools/list", {}, START_TIMEOUT_MS);
292
+ // The line's name as the developer sees it, whichever engine answered (`lineName.js`).
293
+ this.initializeResult = lineName.initializeRenamed(this.initializeResult);
294
+ this.toolList = lineName.toolsRenamed(listed.tools || []);
295
+ this.tools = new Set(this.toolList.map((tool) => tool.name));
296
+ this.log(`the local engine serves ${this.tools.size} tool(s)`);
297
+ return this.tools;
298
+ }
299
+
300
+ /**
301
+ * Local mode: the engine's answer to the host's initialize. The engine is started with the host's
302
+ * own parameters, so the protocol version and capabilities are agreed between the two directly.
303
+ */
304
+ async initialize(params) {
305
+ if (!this.child && !this.starting && params) this.hostInitialize = params;
306
+ await this.start();
307
+ return this.initializeResult;
308
+ }
309
+
310
+ /** Local mode: the engine's tool list, exactly as it gave it. */
311
+ async listTools() {
312
+ await this.start();
313
+ return { tools: this.toolList || [] };
314
+ }
315
+
316
+ /** Local mode: any other request (resources/list, prompts/list ...), passed to the engine as it is. */
317
+ async request(method, params) {
318
+ await this.start();
319
+ return this._send(method, params || {}, CALL_TIMEOUT_MS);
320
+ }
321
+
322
+ /** Does the local engine serve this tool, under this name or an alias of it? */
323
+ serves(toolName) {
324
+ if (!this.tools) return false;
325
+ return this.tools.has(toolName) || this.tools.has(ALIASES[toolName]);
326
+ }
327
+
328
+ /**
329
+ * Answer one `tools/call`.
330
+ *
331
+ * Three things happen to the call on the way through, and each has its own reason above:
332
+ * the bridge's own extras are dropped, because the pin and the git snapshot exist for the
333
+ * cloud's routing ladder and the engine answers for the folder it runs in; the cloud's
334
+ * argument names become the engine's; and a question asked about several names is asked
335
+ * once per name.
336
+ */
337
+ async call(toolName, args = {}) {
338
+ await this.start();
339
+ const passed = { ...args };
340
+ for (const extra of ["_niroPin", "_niroGit", "_niroGitRepos", "project_id"]) {
341
+ delete passed[extra];
342
+ }
343
+ // Every call goes to the tool the host named. Until 2026-10-23 a get_call_chain asked for
344
+ // callers was sent to get_reverse_call_chain, because the engine had no `direction`; it
345
+ // answers that itself now, in upstream's wording.
346
+ const wanted = toolName;
347
+ const renamed = LocalBackend.engineArguments(wanted, passed);
348
+ const name = this.tools.has(wanted) ? wanted : ALIASES[wanted];
349
+ const notice = notHonoured(toolName, passed);
350
+
351
+ // A question asked by route: the engine answers by name, so find the name first.
352
+ if (!("name" in renamed) && renamed.http_path && BY_ROUTE.includes(wanted)) {
353
+ const handler = await this._handlerFor(String(renamed.http_path));
354
+ if (!handler) return this._withMarker(LocalBackend.noRoute(String(renamed.http_path)));
355
+ renamed.name = handler;
356
+ }
357
+ delete renamed.http_path;
358
+
359
+ const batch = LocalBackend.batchOf(renamed);
360
+ const answer = batch
361
+ ? await this._callEach(name, renamed, batch)
362
+ : await this._send("tools/call", { name, arguments: renamed }, CALL_TIMEOUT_MS);
363
+ // `niro mcp receipt --off`: the engine has run the tool, so the turn is recorded; the line it
364
+ // returned is taken out here, before the bridge adds anything of its own, so everything in
365
+ // the answer is the engine's. Read on every call, so switching it takes effect at the next
366
+ // mark_task_complete with no restart.
367
+ const kept =
368
+ wanted === "mark_task_complete" && receiptStore.isDisabled() ? LocalBackend.withoutLine(answer) : answer;
369
+ // An engine older than 2026-11-09 writes `[NIRO-LOCAL]`; the developer is shown `[NIRO-CLI]`. Only the
370
+ // line's own answer is renamed throughout; any other answer only in the reminder under it (`lineName.js`).
371
+ const shown =
372
+ wanted === "mark_task_complete"
373
+ ? lineName.answerRenamed(kept)
374
+ : lineName.answerRenamed(kept, lineName.reminderRenamed);
375
+ const marked = this._withMarker(shown);
376
+ return notice ? LocalBackend.prepend(marked, notice) : marked;
377
+ }
378
+
379
+ /**
380
+ * The method that answers a route, asked of the engine's own endpoint list.
381
+ *
382
+ * Exact path first, then a path that only differs by a trailing slash, then the single match
383
+ * when the engine found exactly one. Null when the route is not in the graph, or when the
384
+ * engine does not serve the endpoint list at all, because a guess here would send the real
385
+ * question to the wrong method and the answer would look right.
386
+ */
387
+ async _handlerFor(httpPath) {
388
+ const tool = "find_api_endpoints";
389
+ if (!this.tools || !this.tools.has(tool)) return null;
390
+ let text;
391
+ try {
392
+ const answer = await this._send(
393
+ "tools/call",
394
+ { name: tool, arguments: { path_pattern: httpPath } },
395
+ CALL_TIMEOUT_MS
396
+ );
397
+ text = (answer.content || [])
398
+ .filter((block) => block && block.type === "text")
399
+ .map((block) => block.text)
400
+ .join("\n");
401
+ } catch {
402
+ return null;
403
+ }
404
+ const rows = [];
405
+ for (const line of String(text || "").split("\n")) {
406
+ const found = ENDPOINT_LINE.exec(line);
407
+ if (found) rows.push({ path: found[2], handler: found[3] });
408
+ }
409
+ const bare = (one) => one.replace(/\/+$/, "");
410
+ const exact = rows.find((row) => row.path === httpPath)
411
+ || rows.find((row) => bare(row.path) === bare(httpPath));
412
+ if (exact) return exact.handler;
413
+ return rows.length === 1 ? rows[0].handler : null;
414
+ }
415
+
416
+ /** What to answer when the route is not one this folder serves. */
417
+ static noRoute(httpPath) {
418
+ return {
419
+ content: [
420
+ {
421
+ type: "text",
422
+ text:
423
+ `Nothing in this folder's graph serves ${httpPath}, so the question was not ` +
424
+ "answered.\n\nRoutes are read from the annotations and decorators a method " +
425
+ "carries, so a route registered at run time is not in the graph. " +
426
+ "find_api_endpoints lists the routes this folder does serve; ask again with the " +
427
+ "method's name once you have it.",
428
+ },
429
+ ],
430
+ };
431
+ }
432
+
433
+
434
+ /**
435
+ * The same arguments under the names the engine declares.
436
+ *
437
+ * A name the engine already uses wins over a translated one, so a host that sends both
438
+ * `name` and `method_fqn` is not overwritten by the second. A batch key is dropped when a
439
+ * single name is present, because the single name is the more specific question.
440
+ */
441
+ static engineArguments(toolName, args) {
442
+ const map = ARGUMENT_NAMES[toolName];
443
+ const out = { ...args };
444
+ if (map) {
445
+ for (const [cloud, engine] of Object.entries(map)) {
446
+ if (cloud in out && !(engine in out)) {
447
+ out[engine] = out[cloud];
448
+ }
449
+ delete out[cloud];
450
+ }
451
+ }
452
+ if ("name" in out) {
453
+ for (const key of BATCH_NAMES) delete out[key];
454
+ }
455
+ return out;
456
+ }
457
+
458
+ /** The batch key in use and the names under it, or null when this is an ordinary call. */
459
+ static batchOf(args) {
460
+ for (const key of BATCH_NAMES) {
461
+ const names = args[key];
462
+ if (Array.isArray(names) && names.length > 0) return { key, names };
463
+ }
464
+ return null;
465
+ }
466
+
467
+ /**
468
+ * Ask the same question about each name and join the answers.
469
+ *
470
+ * One text block per name, each under the name it answers for, because a run of answers with
471
+ * nothing between them cannot be read. A name that fails is reported in its own place rather
472
+ * than failing the whole call: nine answers and one failure is a better answer than none.
473
+ */
474
+ async _callEach(name, args, batch) {
475
+ const { key, names } = batch;
476
+ const asked = names.slice(0, BATCH_LIMIT);
477
+ const rest = { ...args };
478
+ delete rest[key];
479
+ const parts = [];
480
+ let failures = 0;
481
+ for (const one of asked) {
482
+ let text;
483
+ try {
484
+ const answer = await this._send(
485
+ "tools/call",
486
+ { name, arguments: { ...rest, name: one } },
487
+ CALL_TIMEOUT_MS
488
+ );
489
+ text = (answer.content || [])
490
+ .filter((block) => block && block.type === "text")
491
+ .map((block) => block.text)
492
+ .join("\n");
493
+ } catch (err) {
494
+ failures += 1;
495
+ text = `could not be answered: ${(err && err.message) || String(err)}`;
496
+ }
497
+ parts.push(`## ${one}\n\n${text}`);
498
+ }
499
+ if (names.length > asked.length) {
500
+ parts.push(
501
+ `The first ${BATCH_LIMIT} of ${names.length} names are answered above. Ask again for ` +
502
+ "the rest, or ask about one name at a time."
503
+ );
504
+ }
505
+ // Every name failed, so this is a failed call and not an answer that happens to contain
506
+ // the word "could not": a host that reads isError retries or says so, and one that reads
507
+ // the text alone is no worse off.
508
+ const answer = { content: [{ type: "text", text: parts.join("\n\n") }] };
509
+ if (failures > 0 && failures === asked.length) answer.isError = true;
510
+ return answer;
511
+ }
512
+
513
+ /**
514
+ * Which graph answered, said in the answer itself.
515
+ *
516
+ * Risk 1 of the plan: the two graphs name some things differently, so an answer that does
517
+ * not say where it came from is the most expensive kind of wrong. One line, prepended to the
518
+ * first text block.
519
+ */
520
+ _withMarker(answer) {
521
+ return LocalBackend.prepend(answer, LOCAL_MARKER);
522
+ }
523
+
524
+ /**
525
+ * Put `text` in front of an answer's first text block, or add one when it has none.
526
+ *
527
+ * Static and shared because two things do this now: the marker saying which graph answered,
528
+ * and the notice about a project whose folders are on different branches. A second copy of
529
+ * this would be a second shape of answer for a host to render.
530
+ */
531
+ static prepend(answer, text) {
532
+ const content = Array.isArray(answer && answer.content) ? answer.content : [];
533
+ const first = content.find((block) => block && block.type === "text");
534
+ if (!first) {
535
+ return { ...answer, content: [{ type: "text", text }, ...content] };
536
+ }
537
+ return {
538
+ ...answer,
539
+ content: content.map((block) =>
540
+ block === first ? { ...block, text: `${text}\n\n${block.text}` } : block
541
+ ),
542
+ };
543
+ }
544
+
545
+ /**
546
+ * The engine's `mark_task_complete` answer with the end-of-task line taken out, for a developer
547
+ * who ran `niro mcp receipt --off`. Given the engine's answer as it came, before the bridge adds
548
+ * its marker.
549
+ *
550
+ * The engine still ran the tool, so the turn is recorded in its journal; only what the assistant
551
+ * would print is removed, the way the switch already works for the cloud's answer
552
+ * (`captureReceipt`). The engine's answer is what to do with the line, then the line, then any
553
+ * notes (niro-local `receipt.with_instruction(line) + note`). So everything before the first line
554
+ * is the instruction and goes, whatever its wording, except a paragraph that is one of the
555
+ * engine's own notices ("NOTE FROM niro-local: ...", "⚠ ..."): the assistant must still read
556
+ * those, and the bridge starts a background rebuild on "the graph is behind" (`catchUpIfBehind`).
557
+ * Every line goes; what follows the last line (a "Not recorded" note) stays. One sentence, said once, replaces them, so the assistant does not
558
+ * go looking for a line. Lines are found one at a time, so a trailing newline, one newline instead
559
+ * of a blank line, Windows line endings or backticks around the line make no difference.
560
+ *
561
+ * `structuredContent.result` is a second copy of the same text (niro-local declares an output
562
+ * schema for this tool, and a client may show that copy instead), so it is given the same text.
563
+ * A `result` that is not a string is left alone: niro-local always sends a string (its tools
564
+ * return text), so there is no other shape to clean, and guessing at one could break a client
565
+ * that checks the copy against the schema. An answer with no line comes back as it was.
566
+ */
567
+ static withoutLine(answer) {
568
+ const content = Array.isArray(answer && answer.content) ? answer.content : null;
569
+ if (!content) return answer;
570
+ const first = content.findIndex((block) => LocalBackend._isText(block) && LocalBackend._lineAt(block.text) >= 0);
571
+ if (first < 0) return answer;
572
+ const blocks = [];
573
+ content.forEach((block, index) => {
574
+ if (!LocalBackend._isText(block)) return blocks.push(block);
575
+ const lines = block.text.split("\n");
576
+ // Before the first line: the instruction, which goes, and any engine notice, which stays.
577
+ const before = index < first ? lines : index === first ? lines.slice(0, LocalBackend._lineAt(block.text)) : [];
578
+ const notices = LocalBackend._notices(before);
579
+ if (index < first) {
580
+ if (notices.length) blocks.push({ ...block, text: notices.join("\n\n") });
581
+ return;
582
+ }
583
+ const after = index === first ? lines.slice(LocalBackend._lineAt(block.text) + 1) : lines;
584
+ const rest = after.filter((line) => !NIRO_LINE.test(line));
585
+ while (rest.length && !rest[0].trim()) rest.shift();
586
+ while (rest.length && !rest[rest.length - 1].trim()) rest.pop();
587
+ const tail = rest.join("\n");
588
+ if (index === first) {
589
+ const parts = [...notices, LINE_SWITCHED_OFF, ...(tail.trim() ? [tail] : [])];
590
+ blocks.push({ ...block, text: parts.join("\n\n") });
591
+ } else if (tail.trim()) {
592
+ blocks.push({ ...block, text: tail });
593
+ }
594
+ });
595
+ const cleaned = { ...answer, content: blocks };
596
+ const structured = answer.structuredContent;
597
+ if (structured && typeof structured === "object" && typeof structured.result === "string") {
598
+ cleaned.structuredContent = {
599
+ ...structured,
600
+ result: blocks.filter(LocalBackend._isText).map((block) => block.text).join("\n\n"),
601
+ };
602
+ }
603
+ return cleaned;
604
+ }
605
+
606
+ /**
607
+ * The paragraphs of `lines` that are engine notices, in order. A paragraph is a run of lines
608
+ * between blank lines; it is a notice when its first line opens with one of NOTICE_OPENINGS.
609
+ */
610
+ static _notices(lines) {
611
+ const paragraphs = [];
612
+ let current = [];
613
+ for (const line of [...lines, ""]) {
614
+ if (line.trim()) {
615
+ current.push(line);
616
+ } else if (current.length) {
617
+ paragraphs.push(current);
618
+ current = [];
619
+ }
620
+ }
621
+ return paragraphs
622
+ .filter((paragraph) => NOTICE_OPENINGS.some((opening) => paragraph[0].trimStart().startsWith(opening)))
623
+ .map((paragraph) => paragraph.join("\n"));
624
+ }
625
+
626
+ /** A content block with text in it. */
627
+ static _isText(block) {
628
+ return Boolean(block && block.type === "text" && typeof block.text === "string");
629
+ }
630
+
631
+ /** The index of the first end-of-task line in `text`, split at "\n", or -1. */
632
+ static _lineAt(text) {
633
+ return text.split("\n").findIndex((line) => NIRO_LINE.test(line));
634
+ }
635
+
636
+ /** What to answer for a tool the local graph cannot serve. */
637
+ static cannotServe(toolName) {
638
+ return {
639
+ content: [
640
+ {
641
+ type: "text",
642
+ text:
643
+ `${toolName} is not answered for this folder: it is indexed locally by niro-local, ` +
644
+ "whose graph holds structure and not source text or documentation.\n\n" +
645
+ "What you can do instead: read the file with your own tools, and use " +
646
+ "list_types, resolve_name, get_call_chain, get_reverse_call_chain, " +
647
+ "get_blast_radius, get_class_structure, find_api_endpoints, find_handler, " +
648
+ "get_exception_flow, list_events, get_event_producers_and_handlers, " +
649
+ "find_outbound_http_calls, list_data_resources, get_data_resource_usage or " +
650
+ "get_service_topology, which this graph does answer.",
651
+ },
652
+ ],
653
+ };
654
+ }
655
+
656
+ /** Stop the engine. The licence stops with it. */
657
+ stop() {
658
+ if (this.licence) this.licence.stop();
659
+ if (this.child && !this.child.killed) {
660
+ try {
661
+ this.child.kill();
662
+ } catch {
663
+ // Already gone.
664
+ }
665
+ }
666
+ this.child = null;
667
+ this.tools = null;
668
+ }
669
+
670
+ // --- the JSON-RPC plumbing ---------------------------------------------------------
671
+
672
+ _write(message) {
673
+ if (!this.child || !this.child.stdin.writable) {
674
+ throw new Error("the local engine is not running");
675
+ }
676
+ this.child.stdin.write(JSON.stringify(message) + "\n");
677
+ }
678
+
679
+ _send(method, params, timeoutMs) {
680
+ const id = this.nextId++;
681
+ return new Promise((resolve, reject) => {
682
+ const timer = setTimeout(() => {
683
+ this.waiting.delete(id);
684
+ reject(new Error(`the local engine did not answer ${method} within ${timeoutMs}ms`));
685
+ }, timeoutMs);
686
+ this.waiting.set(id, { resolve, reject, timer });
687
+ try {
688
+ this._write({ jsonrpc: "2.0", id, method, params });
689
+ } catch (problem) {
690
+ clearTimeout(timer);
691
+ this.waiting.delete(id);
692
+ reject(problem);
693
+ }
694
+ });
695
+ }
696
+
697
+ _onData(chunk) {
698
+ this.buffered += chunk.toString();
699
+ let newline = this.buffered.indexOf("\n");
700
+ while (newline !== -1) {
701
+ const line = this.buffered.slice(0, newline).trim();
702
+ this.buffered = this.buffered.slice(newline + 1);
703
+ if (line) this._onLine(line);
704
+ newline = this.buffered.indexOf("\n");
705
+ }
706
+ }
707
+
708
+ _onLine(line) {
709
+ let message;
710
+ try {
711
+ message = JSON.parse(line);
712
+ } catch {
713
+ // The engine writes JSON-RPC on this pipe and nothing else, so anything unparseable is
714
+ // worth saying rather than ignoring: it is usually a library printing to the wrong place.
715
+ this.log(`the local engine wrote something that is not JSON-RPC: ${line.slice(0, 120)}`);
716
+ return;
717
+ }
718
+ const pending = this.waiting.get(message.id);
719
+ if (!pending) return; // a notification, or an answer to a call that already timed out
720
+ this.waiting.delete(message.id);
721
+ clearTimeout(pending.timer);
722
+ if (message.error) {
723
+ const refused = new Error(message.error.message || "the local engine refused the call");
724
+ refused.code = message.error.code;
725
+ pending.reject(refused);
726
+ } else {
727
+ pending.resolve(message.result || {});
728
+ }
729
+ }
730
+
731
+ /** Every waiting call fails when the engine goes away, rather than hanging for ever. */
732
+ _fail(reason) {
733
+ for (const [, pending] of this.waiting) {
734
+ clearTimeout(pending.timer);
735
+ pending.reject(new Error(reason));
736
+ }
737
+ this.waiting.clear();
738
+ }
739
+ }
740
+
741
+ module.exports = {
742
+ LocalBackend,
743
+ ALIASES,
744
+ notHonoured,
745
+ ARGUMENT_NAMES,
746
+ BATCH_NAMES,
747
+ BATCH_LIMIT,
748
+ LOCAL_MARKER,
749
+ LINE_SWITCHED_OFF,
750
+ CALL_TIMEOUT_MS,
751
+ };