@niroai/niro 0.8.0 → 0.10.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.
package/src/lib/git.js CHANGED
@@ -7,6 +7,25 @@ const fs = require("fs");
7
7
  const path = require("path");
8
8
  const { execSync } = require("child_process");
9
9
 
10
+ /**
11
+ * The branch name from `git rev-parse --abbrev-ref HEAD`, or null when there is no branch.
12
+ *
13
+ * On a detached HEAD (a bisect, a tag checkout, a plain commit checkout) that command does not
14
+ * fail — it prints the literal string "HEAD". A caller that trusts it verbatim therefore reports a
15
+ * branch named "HEAD", which no repository has, and everything downstream reads the checkout as
16
+ * being on an unknown branch rather than on no branch at all.
17
+ *
18
+ * `inspect` below has always collapsed that to null, matching what the `.git/HEAD` file read
19
+ * returns. This is that one rule, shared, so a second caller cannot drift from it. One already had:
20
+ * the MCP bridge sent "HEAD" straight through, so the server's routing ladder took every detached
21
+ * checkout for a diverged branch and refused to answer it, even though the ladder has an explicit
22
+ * guard meant to route exactly that case to the indexed project.
23
+ */
24
+ function normalizeBranch(raw) {
25
+ const b = typeof raw === "string" ? raw.trim() : "";
26
+ return b && b !== "HEAD" ? b : null;
27
+ }
28
+
10
29
  function findGitDir(startDir) {
11
30
  let dir = path.resolve(startDir);
12
31
  const root = path.parse(dir).root;
@@ -93,8 +112,7 @@ function inspect(startDir) {
93
112
  // unavailable the calls return null and behaviour is identical to before.
94
113
  if (originUrl === null) originUrl = redactRemoteCreds(gitOut("git remote get-url origin", found.repoRoot));
95
114
  if (branch === null) {
96
- const b = gitOut("git rev-parse --abbrev-ref HEAD", found.repoRoot);
97
- branch = b && b !== "HEAD" ? b : null; // "HEAD" = detached; keep null like the file read
115
+ branch = normalizeBranch(gitOut("git rev-parse --abbrev-ref HEAD", found.repoRoot));
98
116
  }
99
117
  return { repoRoot: found.repoRoot, originUrl, branch };
100
118
  }
@@ -210,4 +228,4 @@ function isTracked(repoRoot, relativePath) {
210
228
  }
211
229
 
212
230
  module.exports = { inspect, mainWorktreeRoot, deriveRepoName, checkIgnored, checkIgnoredPaths,
213
- redactRemoteCreds, isTracked };
231
+ redactRemoteCreds, isTracked, normalizeBranch };
@@ -23,7 +23,7 @@ async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied =
23
23
  }
24
24
  if (!Array.isArray(suggestions) || suggestions.length === 0) return;
25
25
 
26
- console.log(`\n ${color.cyan("Suggested module exclusions")} (modules that are usually noise):`);
26
+ console.log(`\n ${color.cyan("Suggested module exclusions")} (modules that are usually noise):`);
27
27
  const accepted = [];
28
28
  for (const s of suggestions) {
29
29
  const count = s.module_count || s.moduleCount || 0;
@@ -33,24 +33,24 @@ async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied =
33
33
  ` ${color.dim(s.pattern.padEnd(28))} ${String(count).padStart(3)} module${count === 1 ? "" : "s"} ${color.dim(label)}`
34
34
  );
35
35
  if (samples.length > 0) {
36
- console.log(` ${color.dim("e.g. " + samples.join(", "))}`);
36
+ console.log(` ${color.dim("e.g. " + samples.join(", "))}`);
37
37
  }
38
38
  const yes = await prompt.confirm(` Apply ${color.cyan(s.pattern)}?`, true);
39
39
  if (yes) accepted.push(s.pattern);
40
40
  }
41
41
 
42
42
  if (accepted.length === 0) {
43
- console.log(` ${color.dim("No exclusions applied.")}`);
43
+ console.log(` ${color.dim("No exclusions applied.")}`);
44
44
  return;
45
45
  }
46
46
 
47
47
  try {
48
48
  await agentApi.updateModuleExcludePatterns(gitId, accepted);
49
49
  console.log(
50
- ` ${color.green("✓")} Applied ${accepted.length} exclusion${accepted.length === 1 ? "" : "s"}.`
50
+ ` ${color.green("✓")} Applied ${accepted.length} exclusion${accepted.length === 1 ? "" : "s"}.`
51
51
  );
52
52
  } catch (err) {
53
- console.log(` ${color.yellow("Warning:")} failed to save exclusions: ${err.message || err}`);
53
+ console.log(` ${color.yellow("Warning:")} failed to save exclusions: ${err.message || err}`);
54
54
  }
55
55
  }
56
56
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Run something and shift everything it prints one rung deeper.
3
+ *
4
+ * `niro start` is a shell around commands that also run on their own. `niro init` printing at
5
+ * the top level is right when you typed `niro init`; inside the wizard the same lines belong
6
+ * UNDER "4/5 Onboarding and building the index", and printing them flush with the step markers
7
+ * is what made step 4 look like a different program had taken over.
8
+ *
9
+ * Wrapping the call is the only approach that does not require every helper it reaches
10
+ * (folderSync, onboardTarget, moduleExcludeSuggestions, watchDaemon, service) to know how deep
11
+ * it happens to be running. They keep printing at their own level; this adds the offset.
12
+ *
13
+ * Blank lines are left blank rather than padded with spaces, so nothing trails whitespace.
14
+ * process.stdout.write is untouched: the upload progress bar repaints one line with \r, and
15
+ * prefixing every repaint would smear it across the terminal.
16
+ */
17
+ let current = 0;
18
+
19
+ /** How deep the output is nested right now. prompt.js prefixes its questions with this. */
20
+ function currentIndent() {
21
+ return current;
22
+ }
23
+
24
+ function withIndent(spaces, fn) {
25
+ const pad = " ".repeat(spaces);
26
+ const shift = (orig) => (...args) => {
27
+ const text = args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
28
+ // A line with nothing but spaces is emitted empty. Some callers print a whitespace-only
29
+ // string as a spacer, and padding it left a visible run of trailing whitespace.
30
+ orig(text.split("\n").map((l) => (l.trim() ? pad + l : "")).join("\n"));
31
+ };
32
+ const log = console.log;
33
+ const error = console.error;
34
+ const warn = console.warn;
35
+ current += spaces;
36
+ console.log = shift(log);
37
+ console.error = shift(error);
38
+ console.warn = shift(warn);
39
+ return Promise.resolve()
40
+ .then(fn)
41
+ .finally(() => {
42
+ current -= spaces;
43
+ console.log = log;
44
+ console.error = error;
45
+ console.warn = warn;
46
+ });
47
+ }
48
+
49
+ module.exports = { withIndent, currentIndent };
@@ -208,7 +208,7 @@ function ensureOnboardable(targetDir, opts = {}) {
208
208
 
209
209
  const reason = protectedRootReason(resolved);
210
210
  if (reason) {
211
- console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
211
+ console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
212
212
  console.error(" That would upload source from every project underneath it, pin this folder");
213
213
  console.error(" with a .niro-project file, and watch the whole tree for changes.");
214
214
  console.error(` Change into the project folder you meant, then run the command there.`);
@@ -223,17 +223,17 @@ function ensureOnboardable(targetDir, opts = {}) {
223
223
  // The protected-root rule above still applied, and still refuses.
224
224
  if (opts.alreadyRegistered) {
225
225
  console.log(color.dim(
226
- ` No build file in ${resolved}, but it is already registered here — continuing.`
226
+ ` No build file in ${resolved}, but it is already registered here — continuing.`
227
227
  ));
228
228
  return true;
229
229
  }
230
230
  if (opts.force) {
231
231
  console.log(color.yellow(
232
- `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
232
+ `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
233
  ));
234
234
  return true;
235
235
  }
236
- console.error(color.red(`\n ✗ No project found in ${resolved}.`));
236
+ console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
237
  console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
238
  console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
239
  console.error(" Pass --force if you really mean to upload this folder.");
@@ -241,7 +241,7 @@ function ensureOnboardable(targetDir, opts = {}) {
241
241
  }
242
242
 
243
243
  console.log(
244
- ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
244
+ ` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
245
245
  `(${describeIndicators(indicators)}).`
246
246
  );
247
247
  return true;
package/src/lib/prompt.js CHANGED
@@ -18,6 +18,9 @@ function rl() {
18
18
  * Also shared by the MCP setup/teardown wizards' own ask() helpers.
19
19
  */
20
20
  function formatPrompt(question, defaultValue) {
21
+ // Questions asked from inside a nested step sit at that step's depth. readline writes
22
+ // straight to stdout, so withIndent's console wrappers never see them.
23
+ question = " ".repeat(require("./nestedOutput").currentIndent()) + question;
21
24
  return defaultValue != null && defaultValue !== ""
22
25
  ? `${question} ${color.dim(`(default: ${defaultValue})`)}: `
23
26
  : `${question}: `;
@@ -68,26 +68,30 @@ function install() {
68
68
  // Stop any existing instance so we can re-register cleanly
69
69
  spawnSync("pm2", ["delete", PROCESS_NAME], { shell: true, stdio: "pipe" });
70
70
 
71
- // Start niro-watch under PM2
71
+ // PM2's own output is captured, not inherited. Inheriting it put ~55 lines into the middle
72
+ // of setup: a box-drawing process table, the entire generated systemd unit file, and the
73
+ // user's PATH twice. None of it is ours and none of it is actionable while it is working.
74
+ // It IS printed on failure, where it is the only diagnostic there is.
72
75
  const startResult = spawnSync(
73
76
  "pm2",
74
77
  ["start", niroBin, "--name", PROCESS_NAME, "--", "watch"],
75
- { shell: true, stdio: "inherit" }
78
+ { shell: true, encoding: "utf8" }
76
79
  );
77
80
  if (startResult.status !== 0) {
81
+ console.error(color.dim(`${startResult.stdout || ""}${startResult.stderr || ""}`));
78
82
  throw new Error("pm2 start failed");
79
83
  }
80
84
 
81
85
  // Persist the process list so PM2 restores it after a manual `pm2 resurrect`
82
- spawnSync("pm2", ["save"], { shell: true, stdio: "inherit" });
86
+ spawnSync("pm2", ["save"], { shell: true, encoding: "utf8" });
83
87
 
84
88
  // Wire into OS startup — pm2 startup detects systemd / launchd / etc.
85
89
  // It prints the exact privileged command we need to run.
86
90
  console.log(
87
- `\n ${color.bold("Setting up auto-start on login")} — PM2 will ask for your password to register`
91
+ `\n ${color.bold("Setting up auto-start on login")} — PM2 will ask for your password to register`
88
92
  );
89
93
  console.log(
90
- ` with the system startup manager (${getInitSystem()}). This only needs to happen once.\n`
94
+ ` with the system startup manager (${getInitSystem()}). This only needs to happen once.\n`
91
95
  );
92
96
 
93
97
  const startupProbe = spawnSync("pm2", ["startup"], {
@@ -97,21 +101,25 @@ function install() {
97
101
 
98
102
  const setupCmd = extractSetupCommand(startupProbe.stdout + startupProbe.stderr);
99
103
  if (setupCmd) {
100
- console.log(` Running: ${color.dim(setupCmd)}\n`);
104
+ console.log(` Running: ${color.dim(setupCmd)}\n`);
105
+ // stdio is split: stdin stays connected so sudo can still prompt for the password, but
106
+ // PM2's forty lines of generated unit file are captured and only shown if it fails.
101
107
  const r = spawnSync(setupCmd, {
102
108
  shell: true,
103
- stdio: "inherit",
109
+ stdio: ["inherit", "pipe", "pipe"],
110
+ encoding: "utf8",
104
111
  });
105
112
  if (r.status !== 0) {
113
+ console.error(color.dim(`${r.stdout || ""}${r.stderr || ""}`));
106
114
  console.log(
107
115
  color.yellow(
108
- ` ⚠ Startup hook failed (exit ${r.status}). You can run it manually:\n ${setupCmd}`
116
+ ` ⚠ Startup hook failed (exit ${r.status}). You can run it manually:\n ${setupCmd}`
109
117
  )
110
118
  );
111
119
  }
112
120
  } else {
113
121
  // PM2 may print nothing if startup is already configured
114
- console.log(` ${color.green("✓")} Startup already configured.`);
122
+ console.log(` ${color.green("✓")} Startup already configured.`);
115
123
  }
116
124
 
117
125
  return true;
@@ -127,7 +135,7 @@ function uninstall() {
127
135
  spawnSync("pm2", ["save"], { shell: true, stdio: "inherit" });
128
136
 
129
137
  console.log(
130
- `\n ${color.bold("Removing auto-start hook")} — PM2 may ask for your password.\n`
138
+ `\n ${color.bold("Removing auto-start hook")} — PM2 may ask for your password.\n`
131
139
  );
132
140
  const unstartupResult = spawnSync("pm2", ["unstartup"], {
133
141
  shell: true,
@@ -137,7 +145,7 @@ function uninstall() {
137
145
  unstartupResult.stdout + unstartupResult.stderr
138
146
  );
139
147
  if (setupCmd) {
140
- console.log(` Running: ${color.dim(setupCmd)}\n`);
148
+ console.log(` Running: ${color.dim(setupCmd)}\n`);
141
149
  spawnSync(setupCmd, { shell: true, stdio: "inherit" });
142
150
  }
143
151
 
@@ -149,7 +157,7 @@ function uninstall() {
149
157
  */
150
158
  function status() {
151
159
  if (!pm2Available()) {
152
- console.log(color.yellow(" PM2 is not installed. Run: npm install -g pm2"));
160
+ console.log(color.yellow(" PM2 is not installed. Run: npm install -g pm2"));
153
161
  return;
154
162
  }
155
163
  spawnSync("pm2", ["show", PROCESS_NAME], { shell: true, stdio: "inherit" });
@@ -22,6 +22,17 @@ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
22
22
  const FRAME_MS = 120;
23
23
  const HEARTBEAT_MS = 30_000;
24
24
  const ERASE_LINE = "\r\x1b[2K";
25
+
26
+ /**
27
+ * Current nesting depth as spaces.
28
+ *
29
+ * The spinner writes straight to the stream, so withIndent's console wrappers never see it.
30
+ * Left alone, the build status and its "Build completed" line print flush left in the middle
31
+ * of an indented step, which is where they were.
32
+ */
33
+ function pad() {
34
+ return " ".repeat(require("./nestedOutput").currentIndent());
35
+ }
25
36
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
26
37
 
27
38
  /**
@@ -40,8 +51,18 @@ function fit(line, width) {
40
51
  * Labels can carry backend text (e.g. build log messages); an embedded
41
52
  * newline would defeat the single-line overwrite, so flatten whitespace.
42
53
  */
54
+ /**
55
+ * Flatten a label to one line, but KEEP its leading indent.
56
+ *
57
+ * The old version trimmed both ends, which silently ate the indent callers use to place the
58
+ * label in the output tree: " Waiting for build..." came out flush left, four columns
59
+ * off from every line around it. Interior whitespace still collapses, because a newline would
60
+ * defeat the single-line overwrite.
61
+ */
43
62
  function sanitize(label) {
44
- return String(label).replace(/\s+/g, " ").trim();
63
+ const text = String(label);
64
+ const indent = /^[ \t]*/.exec(text)[0].replace(/\t/g, " ");
65
+ return indent + text.replace(/\s+/g, " ").trim();
45
66
  }
46
67
 
47
68
  function start(label, opts = {}) {
@@ -58,9 +79,9 @@ function start(label, opts = {}) {
58
79
  let stopped = false;
59
80
 
60
81
  if (!isTty) {
61
- stream.write(`${label}\n`);
82
+ stream.write(`${pad()}${label}\n`);
62
83
  const timer = setInterval(() => {
63
- stream.write(`${label} ${color.dim(`(still waiting — ${elapsed()}s)`)}\n`);
84
+ stream.write(`${pad()}${label} ${color.dim(`(still waiting — ${elapsed()}s)`)}\n`);
64
85
  }, heartbeatMs);
65
86
  if (timer.unref) timer.unref();
66
87
  return {
@@ -68,13 +89,13 @@ function start(label, opts = {}) {
68
89
  newLabel = sanitize(newLabel);
69
90
  if (stopped || newLabel === label) return;
70
91
  label = newLabel;
71
- stream.write(`${label}\n`);
92
+ stream.write(`${pad()}${label}\n`);
72
93
  },
73
94
  stop(finalLine) {
74
95
  clearInterval(timer);
75
96
  if (stopped) return;
76
97
  stopped = true;
77
- if (finalLine) stream.write(finalLine + "\n");
98
+ if (finalLine) stream.write(pad() + finalLine + "\n");
78
99
  },
79
100
  };
80
101
  }
@@ -83,7 +104,7 @@ function start(label, opts = {}) {
83
104
  const render = () => {
84
105
  const frame = FRAMES[i = (i + 1) % FRAMES.length];
85
106
  const line = `${color.cyan(frame)} ${label} ${color.dim("(" + elapsed() + "s)")}`;
86
- stream.write(ERASE_LINE + fit(line, (stream.columns || 80) - 1));
107
+ stream.write(ERASE_LINE + pad() + fit(line, (stream.columns || 80) - 1 - pad().length));
87
108
  };
88
109
 
89
110
  render();
@@ -101,7 +122,7 @@ function start(label, opts = {}) {
101
122
  if (stopped) return;
102
123
  stopped = true;
103
124
  stream.write(ERASE_LINE);
104
- if (finalLine) stream.write(finalLine + "\n");
125
+ if (finalLine) stream.write(pad() + finalLine + "\n");
105
126
  },
106
127
  };
107
128
  }
@@ -34,4 +34,28 @@ async function currentBranch(tempProjectId, gitId, opts = {}) {
34
34
  return match && match.branch ? match.branch : null;
35
35
  }
36
36
 
37
- module.exports = { currentBranch };
37
+ /**
38
+ * True when an error from a project read means "this project does not exist on this backend".
39
+ *
40
+ * Status alone cannot answer it. A deleted project fails its access check before anything looks for
41
+ * the row, and that check raises AccessDeniedException, which the backend maps to 403 — the SAME
42
+ * status it returns when the project is alive but belongs to someone else
43
+ * (AccessControlService.validateProjectAccess, GlobalExceptionHandler). Treating every 403 as "gone"
44
+ * would let a permissions problem wipe a healthy local registration, and treating none of them as
45
+ * gone is what left dead registrations behind in the first place. So the message decides: only a
46
+ * missing row produces "Project not found", while the other two say "User does not have access to
47
+ * project" and "User not found".
48
+ *
49
+ * A bare status is deliberately NOT enough, not even a 404. This route does not produce one today,
50
+ * so a 404 here means something other than the API answered: a proxy, or an HTML error page. Its
51
+ * sibling agentApi.isRepoNotFound does accept a bare 404, and can afford to — being wrong there
52
+ * costs a re-registration. Being wrong here destroys the record of how to undo a takeover, so this
53
+ * one fails toward keeping it: an unrecognised failure throws and changes nothing.
54
+ */
55
+ function isProjectNotFound(err) {
56
+ if (!err) return false;
57
+ const text = `${err.message || ""} ${typeof err.body === "string" ? err.body : ""}`;
58
+ return /project not found/i.test(text);
59
+ }
60
+
61
+ module.exports = { currentBranch, isProjectNotFound };
@@ -33,7 +33,7 @@ const color = require("./color");
33
33
  */
34
34
  async function ensureWatchDaemon({ skipPrompts = false } = {}) {
35
35
  if (!service.pm2Available()) {
36
- console.log(`\n ${color.yellow("⚠")} PM2 is not installed. Niro uses PM2 to keep your synced folders`);
36
+ console.log(`\n ${color.yellow("⚠")} PM2 is not installed. Niro uses PM2 to keep your synced folders`);
37
37
  console.log(` up to date in the background, including after a reboot.`);
38
38
 
39
39
  if (skipPrompts || prompt.isNonInteractive()) {
@@ -49,29 +49,29 @@ async function ensureWatchDaemon({ skipPrompts = false } = {}) {
49
49
 
50
50
  console.log("");
51
51
  if (!service.installPm2()) {
52
- console.log(`\n ${color.yellow("⚠")} PM2 install failed (a permission error is the usual cause).`);
52
+ console.log(`\n ${color.yellow("⚠")} PM2 install failed (a permission error is the usual cause).`);
53
53
  console.log(` Try manually: ${color.cyan("sudo npm install -g pm2")} then ${color.cyan("niro service install")}`);
54
54
  return { ok: false, reason: "pm2_install_failed" };
55
55
  }
56
- console.log(` ${color.green("✓")} PM2 installed.`);
56
+ console.log(` ${color.green("✓")} PM2 installed.`);
57
57
  }
58
58
 
59
- console.log(`\n ${color.cyan("●")} Installing niro-watch daemon (keeps your synced folders up to date, auto-starts on login)...`);
59
+ console.log(`\n ${color.cyan("●")} Installing niro-watch daemon (keeps your synced folders up to date, auto-starts on login)...`);
60
60
  try {
61
61
  // install() returns false when pm2 turns out not to be runnable after
62
62
  // all — e.g. npm installed it into a global bin dir that isn't on PATH
63
63
  // (npm exits 0 for that). Treat it as the failure it is; the old code's
64
64
  // unchecked call here is how "✓ Done." lied to customers.
65
65
  if (!service.install()) {
66
- console.log(` ${color.yellow("⚠")} PM2 was installed but isn't runnable from this shell (its global bin`);
67
- console.log(` directory may not be on your PATH). Open a NEW terminal and run ${color.cyan("niro service install")}.`);
66
+ console.log(` ${color.yellow("⚠")} PM2 was installed but isn't runnable from this shell (its global bin`);
67
+ console.log(` directory may not be on your PATH). Open a NEW terminal and run ${color.cyan("niro service install")}.`);
68
68
  return { ok: false, reason: "daemon_install_failed" };
69
69
  }
70
- console.log(` ${color.green("✓")} niro-watch is running and will start automatically on login.`);
70
+ console.log(` ${color.green("✓")} niro-watch is running and will start automatically on login.`);
71
71
  return { ok: true };
72
72
  } catch (err) {
73
- console.log(` ${color.yellow("⚠")} Could not install the watch daemon: ${err.message}`);
74
- console.log(` Run ${color.cyan("niro service install")} to try again, or ${color.cyan("niro watch")} to sync manually.`);
73
+ console.log(` ${color.yellow("⚠")} Could not install the watch daemon: ${err.message}`);
74
+ console.log(` Run ${color.cyan("niro service install")} to try again, or ${color.cyan("niro watch")} to sync manually.`);
75
75
  return { ok: false, reason: "daemon_install_failed" };
76
76
  }
77
77
  }
package/src/mcp/server.js CHANGED
@@ -41,7 +41,7 @@ const path = require("path");
41
41
  const readline = require("readline");
42
42
  const { execSync } = require("child_process");
43
43
  const niroProjectFile = require("../lib/niroProjectFile");
44
- const { redactRemoteCreds } = require("../lib/git");
44
+ const { redactRemoteCreds, normalizeBranch } = require("../lib/git");
45
45
  const receiptStore = require("../lib/receiptStore");
46
46
 
47
47
  /** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
@@ -161,8 +161,13 @@ function detectGitContext(dir) {
161
161
  if (dir) opts.cwd = dir;
162
162
  const remote = execSync("git remote get-url origin 2>/dev/null", opts).trim();
163
163
  if (!remote) return null;
164
+ // normalizeBranch, not a bare trim: on a detached HEAD this command PRINTS "HEAD" instead of
165
+ // failing, and sending that verbatim told the server the checkout was on a branch called "HEAD".
166
+ // Nothing indexes such a branch, so the routing ladder read every bisect / tag checkout as
167
+ // diverged and refused to answer it — past its own guard for detached HEAD, which only fires on
168
+ // a null branch. `git.inspect` has always collapsed "HEAD" to null; this shares that one rule.
164
169
  let branch = null;
165
- try { branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts).trim(); } catch (_) {}
170
+ try { branch = normalizeBranch(execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)); } catch (_) {}
166
171
  // The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
167
172
  // the bridge must report them — without git_commit the server-side check no-ops entirely.
168
173
  let commit = null;