@niroai/niro 0.7.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -2,6 +2,93 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
+ ## 0.9.0 — 2026-08-26
6
+
7
+ One change to how the client waits, and two fixes for cases where Niro and your machine
8
+ disagreed about what still exists.
9
+
10
+ ### Upgrading
11
+
12
+ ```bash
13
+ npm install -g @niroai/niro@latest
14
+ ```
15
+
16
+ Nothing extra is needed from 0.8.0. Against an older server the waiting change simply does not
17
+ engage, because the client decides from the server's answer rather than from a version.
18
+
19
+ **Creating a working copy no longer times out.** It takes 44 to 60 seconds, and it used to run
20
+ inside a single request, against a gateway that gives up at 60. Three at once on 26 August all
21
+ came back as gateway error pages, and because such an error says nothing about whether the work
22
+ finished, the real cause underneath went unread for an afternoon. The client now asks the server
23
+ to do it in the background and waits for it to finish, showing progress. A dropped connection
24
+ while waiting is not treated as a failure, because the copy is still being made on the server; an
25
+ expired login is, because waiting cannot fix it.
26
+
27
+ **Discarding a working copy the server had already deleted.** A working copy can go away
28
+ without your machine being told: someone deletes it in the web UI, or the server reaps it
29
+ after it has sat idle for two weeks. The folder on your machine kept a registration pointing
30
+ at it, and `niro discard-temp-project`, which is what clears that registration, failed before
31
+ it got that far. The registration is also what makes `niro new-temp-project` in a second
32
+ worktree refuse, advising you to run discard in the other folder, so the advice named the one
33
+ command that could not work. Discard now treats a project the server says is missing as
34
+ nothing left to delete: it skips the server call and does the local half, pointing the folder
35
+ back at the original project. A server it could not reach, or one that refuses because the
36
+ copy belongs to another developer, still fails loudly and changes nothing, which matters
37
+ because the server answers both of those with the same status code as a deleted project.
38
+
39
+ **A detached checkout stopped Niro answering.** During `git bisect`, or after checking out a
40
+ tag, your repo is on no branch. `git rev-parse --abbrev-ref HEAD` does not fail there, it
41
+ prints the word `HEAD`, and Niro passed that on as though it were a branch name. Nothing
42
+ indexes a branch called `HEAD`, so the server treated every such question as coming from a
43
+ diverged branch and declined to answer it, for the whole bisect. Niro now reports no branch,
44
+ which is what the server already had a rule for, and answers from the indexed project.
45
+
46
+ ## 0.8.0 — 2026-08-26
47
+
48
+ Needed before a server has the layered store turned on. On an older client the new
49
+ server-side setting is not just unusable, it is misleading.
50
+
51
+ ### Upgrading
52
+
53
+ ```bash
54
+ npm install -g @niroai/niro@latest
55
+ ```
56
+
57
+ Nothing extra is needed from 0.7.0.
58
+
59
+ ### Why this release exists
60
+
61
+ A working copy is a private index of the branch in your folder, including edits you have not
62
+ committed. The server can now keep several of them per developer, and can store one as a
63
+ layer over the shared project rather than a full copy. Three things in 0.7.0 get in the way.
64
+
65
+ **A second working copy was refused on your own machine.** 0.7.0 decided by itself that one
66
+ folder already held the project, and never asked the server. Once the server allows several,
67
+ that refusal is simply wrong. Worse, run unattended, by an agent or a script, it threw an
68
+ error advising `niro discard-temp-project` in the other folder. Following that advice
69
+ destroys a working copy that did not need destroying. The client now asks the server and
70
+ does what it says.
71
+
72
+ **A git worktree of a repo with no remote could not be onboarded at all.** Niro records
73
+ onboarding against the folder that was onboarded, which is the main checkout. A linked
74
+ worktree has no entry of its own, so the repo could not be named and `niro new-temp-project`
75
+ failed. It now resolves the main working tree through `git rev-parse --git-common-dir`.
76
+ That combination, a worktree of a remote-less repo, is the layered store's main use case.
77
+
78
+ **Large files were dropped from a working copy without telling you.** The size warning
79
+ existed only in `niro init`. A developer ran `niro new-temp-project` and lost their three
80
+ largest source files, the ones the whole branch was about. Niro then answered questions
81
+ about those files from the shared project's older copy, confidently, with nothing on screen
82
+ to say the working copy did not contain them. The warning now runs on both commands, and it
83
+ quotes the limit the server reports rather than a number compiled into the client, which
84
+ said 256 KiB while a server raised to 2 MiB was accepting the file happily.
85
+
86
+ ### Compatibility
87
+
88
+ Against an older server, the per-file limit is not reported and the client falls back to its
89
+ built-in figure, so the warning quotes an estimate rather than the server's real cap. Nothing
90
+ else changes.
91
+
5
92
  ## 0.7.0 — 2026-08-17
6
93
 
7
94
  A data-loss release. If you use `niro watch`, upgrade.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
@@ -174,6 +174,7 @@ async function addRepo(pathArg, opts = {}) {
174
174
  // Get filter recommendation
175
175
  console.log(` Analyzing project structure...`);
176
176
  const filterResult = await agentApi.filterManifest(gitId, manifest);
177
+ folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(color.yellow(m)));
177
178
  const includedCount = filterResult.included_count || filterResult.includedCount || 0;
178
179
  const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
179
180
 
@@ -422,16 +422,9 @@ async function init(pathArg, opts = {}) {
422
422
  process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
423
423
  const manifest = folderSync.buildManifest(targetDir);
424
424
  process.stdout.write(` ${manifest.length} files found.\n`);
425
- for (const m of manifest) {
426
- if (m.size_bytes > folderSync.PER_FILE_CAP_BYTES) {
427
- console.log(warn(
428
- ` ⚠ ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) exceeds the ` +
429
- `256 KiB per-file cap — it will be skipped and not indexed.`
430
- ));
431
- }
432
- }
433
-
434
425
  const filterResult = await agentApi.filterManifest(gitId, manifest);
426
+ // After the filter, not before: the limit is the SERVER's, and it reports it back to us.
427
+ folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
435
428
  const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
436
429
  const includedCount = filterResult.included_count || filterResult.includedCount || includedPaths.length;
437
430
  const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
@@ -22,6 +22,7 @@ const git = require("../lib/git");
22
22
  const niroProjectFile = require("../lib/niroProjectFile");
23
23
  const prompt = require("../lib/prompt");
24
24
  const watchState = require("../lib/watchState");
25
+ const tempProject = require("../lib/tempProject");
25
26
 
26
27
  const ok = (s) => color.green(s);
27
28
  const warn = (s) => color.yellow(s);
@@ -56,26 +57,80 @@ async function release(pathArg, opts = {}) {
56
57
  "`niro discard-temp-project --project <temp-project-id>`. The server refuses to delete anything " +
57
58
  "that is not a temporary project, so this is always safe.");
58
59
  }
59
- const project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`);
60
- const cloneOf = project.clone_of_project_id || project.cloneOfProjectId;
61
- if (!cloneOf) {
62
- throw new Error(`Project ${tempProjectId} is not a temporary project — refusing. ` +
63
- "(discard only deletes projects created by `niro new-temp-project`)");
60
+ // The temporary project may ALREADY be gone from the server, and nothing tells this machine when
61
+ // that happens: the idle sweeper reaps it after N days, and deleting it in the UI goes down the
62
+ // ordinary project-delete path. This fetch used to throw on that, BEFORE any local cleanup ran, so
63
+ // the folder kept a registration pointing at a project that no longer exists — and the only advice
64
+ // anything offered ("run `niro discard-temp-project` in that folder first") named this very command,
65
+ // which then failed the same way. A definitive not-found means there is nothing left to delete, so
66
+ // skip the server half and do the local half, which is the half that was still needed.
67
+ let project = null;
68
+ let cloneOf = null;
69
+ let alreadyGone = false;
70
+ try {
71
+ project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`);
72
+ } catch (err) {
73
+ // Only a definitive "no such project" counts. A network blip, a 401, a 500 or a genuine
74
+ // permissions refusal must still fail loudly — rewriting local registrations because the server
75
+ // could not be reached is exactly the mistake this command exists to undo.
76
+ if (!tempProject.isProjectNotFound(err)) throw err;
77
+ alreadyGone = true;
78
+ }
79
+ if (alreadyGone && !opts.project && !(entry && entry.takeover)) {
80
+ // The id did not come from a takeover backup or from the user — it came from a `.niro-project`
81
+ // pin that happens to name a missing project. Nothing here says this folder was ever taken over,
82
+ // so there is no warrant to touch its registration.
83
+ throw new Error(`Project ${tempProjectId} does not exist on this backend, and nothing on this ` +
84
+ "machine records a temporary project for this folder — refusing to change anything. " +
85
+ "If you know the temporary project's id, pass it explicitly: " +
86
+ "`niro discard-temp-project --project <temp-project-id>`.");
87
+ }
88
+ if (!alreadyGone) {
89
+ cloneOf = project.clone_of_project_id || project.cloneOfProjectId;
90
+ if (!cloneOf) {
91
+ throw new Error(`Project ${tempProjectId} is not a temporary project — refusing. ` +
92
+ "(discard only deletes projects created by `niro new-temp-project`)");
93
+ }
64
94
  }
65
95
 
66
96
  // Every folder registered against this temp project (multi-repo takeover, ADR-016).
67
97
  const currentDir = path.resolve(targetDir);
68
- const folders = watchState.allLocal(baseUrl).filter((e) => e.projectId === tempProjectId);
98
+ let folders = watchState.allLocal(baseUrl).filter((e) => e.projectId === tempProjectId);
69
99
  if (!folders.some((e) => path.resolve(e.folderPath) === currentDir)) {
70
100
  folders.push({ folderPath: targetDir, ...(entry || {}) });
71
101
  }
102
+ if (alreadyGone) {
103
+ // Two corrections that only matter once the server is no longer there to check the id against.
104
+ //
105
+ // Drop any folder not actually registered against THIS project. The line above adds the current
106
+ // folder unconditionally, which is right when the id came from that folder, and wrong when it came
107
+ // from `--project`: a mistyped or already-discarded id, run from a folder holding a DIFFERENT live
108
+ // working copy, would rewrite that copy's registration and report it as cleaned up. On the alive
109
+ // path the server's own answer bounds the damage; here nothing does.
110
+ folders = folders.filter((e) => e.projectId === tempProjectId);
111
+ // Then recover the source project from whichever folder recorded it. Every folder in one working
112
+ // copy is a clone of the SAME source project (ADR-016 extends a copy with more repos, it never
113
+ // spans two sources), so any one backup answers for all of them — including a sibling that has no
114
+ // backup of its own, which would otherwise be left pointing at the deleted project while the alive
115
+ // path restores it from `clone_of_project_id`.
116
+ const witness = folders.find((e) => e.takeover && e.takeover.sourceProjectId);
117
+ if (!witness) {
118
+ throw new Error(`Project ${tempProjectId} does not exist on this backend, and no folder on this ` +
119
+ "machine records which project it was a working copy of — refusing to guess. " +
120
+ "Nothing has been changed.");
121
+ }
122
+ cloneOf = witness.takeover.sourceProjectId;
123
+ }
72
124
 
73
125
  // ADR-018 discard guard: deleting the temp project kills coverage for EVERY repo in it. Check each
74
126
  // folder's CURRENT branch against its recorded source branch; a folder still on a feature branch is
75
127
  // in-flight work whose coverage this discard would destroy. Old entries without sourceBranch can't be
76
128
  // verified client-side (the server-side nudge guard still covers the agent-driven path).
129
+ // Skipped when the copy is already gone: this guard exists to warn BEFORE destroying coverage, and
130
+ // there is no coverage left to destroy. Leaving it on would ask the developer to confirm losing
131
+ // something they have already lost, and then refuse non-interactively over it.
77
132
  const stillDiverged = [];
78
- for (const e of folders) {
133
+ for (const e of alreadyGone ? [] : folders) {
79
134
  const sourceBranch = e.takeover && e.takeover.sourceBranch;
80
135
  if (!sourceBranch || !fs.existsSync(e.folderPath)) continue;
81
136
  try {
@@ -103,7 +158,7 @@ async function release(pathArg, opts = {}) {
103
158
  return;
104
159
  }
105
160
  }
106
- } else if (process.stdout.isTTY && !opts.yes) {
161
+ } else if (!alreadyGone && process.stdout.isTTY && !opts.yes) {
107
162
  const sure = await prompt.confirm(
108
163
  `Discard temporary project ${color.bold(project.alias || tempProjectId)}?`);
109
164
  if (!sure) {
@@ -112,7 +167,9 @@ async function release(pathArg, opts = {}) {
112
167
  }
113
168
  }
114
169
 
115
- await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
170
+ if (!alreadyGone) {
171
+ await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
172
+ }
116
173
 
117
174
  // Restore each folder's local registration. ADR-018: the .niro-project file is left ALONE unless it
118
175
  // still carries the OLD-STYLE swapped pin (pre-ADR-018 takeover) — then it points at a now-deleted
@@ -175,7 +232,16 @@ async function release(pathArg, opts = {}) {
175
232
  }
176
233
  }
177
234
 
178
- console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
235
+ if (alreadyGone) {
236
+ console.log(`\n ${ok("✓")} Temporary project ${color.bold(tempProjectId)} was already gone from the server` +
237
+ color.dim(" (deleted from the UI, or reaped after sitting idle)."));
238
+ console.log(repinned.length
239
+ ? ` ${ok("✓")} Cleaned up ${repinned.length} local registration(s) that still pointed at it. ` +
240
+ "Niro routes back to the original project automatically."
241
+ : ` ${ok("✓")} Nothing left to clean up locally.`);
242
+ } else {
243
+ console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
244
+ }
179
245
  if (healed.length) {
180
246
  console.log(color.dim(" Healed old-style pins:"));
181
247
  for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
@@ -22,6 +22,7 @@ const git = require("../lib/git");
22
22
  const prompt = require("../lib/prompt");
23
23
  const watchState = require("../lib/watchState");
24
24
  const resolveCtxLib = require("../lib/resolveContext");
25
+ const spinner = require("../lib/spinner");
25
26
 
26
27
  const ok = (s) => color.green(s);
27
28
  const warn = (s) => color.yellow(s);
@@ -48,7 +49,18 @@ async function takeover(pathArg, opts = {}) {
48
49
  const ctx = await resolveCtxLib.resolveContext(targetDir, { apiUrl: opts.apiUrl });
49
50
  if (ctx.state === "error") throw new Error(`Could not resolve project: ${ctx.error}`);
50
51
  if (!ctx.projects.length) {
51
- throw new Error("This folder's git remote doesn't match any Niro project. Run `niro init` first.");
52
+ // Two different dead ends, and telling them apart is the difference between a fix and a guess.
53
+ // A folder with no git remote is not a broken setup: `niro init` never required one. What is
54
+ // missing is the LOCAL record naming the repo, and for a git worktree that record belongs to the
55
+ // main checkout, not to the folder you are standing in. The old message sent every such user off
56
+ // to add a remote, which does not help: the repo keeps the URL it was registered with.
57
+ if (ctx.state === "no_origin") {
58
+ throw new Error(
59
+ "This folder has no git remote, and no Niro repo is recorded for it on this machine. "
60
+ + "Run `niro init` here, or, if this is a git worktree, run it once in the main checkout, "
61
+ + "then re-run this command.");
62
+ }
63
+ throw new Error("This folder's git remote does not match any Niro project. Run `niro init` first.");
52
64
  }
53
65
  let source = ctx.projects[0];
54
66
  if (opts.project) {
@@ -83,8 +95,29 @@ async function takeover(pathArg, opts = {}) {
83
95
  // already reflecting this folder, and the branch it names has moved on. The server relabels it, which
84
96
  // is right, and a prompt there would be pure friction.
85
97
  const here = path.resolve(repoRoot);
86
- const holder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
98
+ // Matched on the REPO as well as the project, and that second clause is the whole point.
99
+ //
100
+ // The hazard this guard exists for is one REPO checked out in two folders on two branches, both
101
+ // uploading into one copy until every answer is a blend of the two. Matching on the project alone
102
+ // also caught the case immediately below this block: a DIFFERENT repo of the same project, which is
103
+ // the multi-repo extend the very next call performs on purpose (ADR-016).
104
+ //
105
+ // So a developer whose change spans ai-orchestrator and ai-assistant was told their project's copy
106
+ // was "already held by another folder" and asked to take it away from themselves, when the server
107
+ // would have happily added the second repo to the same copy. Verified 2026-08-22.
108
+ //
109
+ // It stands down only when it can PROVE the other folder holds a different repo. An entry written
110
+ // before sourceGitId existed records no repo at all, and for those the honest answer is "cannot
111
+ // tell", which has to mean warn: skipping on an unknown would quietly remove the guard for exactly
112
+ // the older entries most likely to have been left behind. Two tests cover that case.
113
+ // ...and it does not apply at all when the server allows several copies per developer. The hazard
114
+ // named above is two folders uploading into ONE copy; when each folder gets its own, there is no
115
+ // blending to prevent. Refusing anyway is what made LAYERED_STORE_MULTIPLE_WORKTREES_PER_DEVELOPER
116
+ // unreachable: the server answers `multiple_worktrees_allowed: true` on every resolve and the CLI
117
+ // refused the second worktree regardless.
118
+ const holder = ctx.multipleWorktreesAllowed ? null : watchState.allLocal(api.currentBaseUrl()).find((e) =>
87
119
  e.takeover && e.takeover.sourceProjectId === source.project_id
120
+ && (e.takeover.sourceGitId == null || e.takeover.sourceGitId === source.git_id)
88
121
  && path.resolve(e.folderPath) !== here);
89
122
  if (holder) {
90
123
  const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
@@ -114,9 +147,7 @@ async function takeover(pathArg, opts = {}) {
114
147
  // 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
115
148
  // the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
116
149
  // cross-repo answers stay coherent. Either way this folder's repo becomes AGENT_UPLOAD.
117
- const outcome = await api.post(
118
- `/api/projects/${encodeURIComponent(source.project_id)}/clone`,
119
- { local_git_ids: [source.git_id], branch });
150
+ const outcome = await requestClone(source.project_id, source.git_id, branch);
120
151
  const newProjectId = outcome.new_project_id || outcome.newProjectId;
121
152
  const gitIdMap = outcome.git_id_map || outcome.gitIdMap || {};
122
153
  const newGitId = gitIdMap[source.git_id];
@@ -154,6 +185,9 @@ async function takeover(pathArg, opts = {}) {
154
185
  const manifest = folderSync.buildManifest(repoRoot);
155
186
  const filterResult = await agentApi.filterManifest(newGitId, manifest);
156
187
  const includedPaths = filterResult.included_paths || filterResult.includedPaths || [];
188
+ // The gap that cost a day: this command had no oversize warning at all, so a working copy
189
+ // silently omitted the three largest source files in the repo.
190
+ folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
157
191
  const sizes = {};
158
192
  for (const m of manifest) sizes[m.path] = m.size_bytes;
159
193
  console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
@@ -238,4 +272,87 @@ async function takeover(pathArg, opts = {}) {
238
272
  console.log(color.dim(" routing falls back to the original project by itself."));
239
273
  }
240
274
 
241
- module.exports = { takeover };
275
+
276
+ /**
277
+ * Ask the server for a working copy, and wait for it without holding the request open.
278
+ *
279
+ * WHY. The copy takes 44 to 60 seconds. It used to run inside the POST, and the gateway in front of
280
+ * the server gives up at 60, so three requests at once on 2026-08-26 all came back as HTML 504 pages.
281
+ * A 504 also says nothing about whether the work finished: the real error underneath, a duplicate key
282
+ * from a migration that had not been run, went unread for an afternoon because of it.
283
+ *
284
+ * WORKS AGAINST BOTH SERVERS, and that is why it branches on the RESPONSE rather than on a version.
285
+ * A server that understands `async` returns 202 and a job id to poll. An older one ignores the unknown
286
+ * field and returns the outcome inline exactly as before, which is the shape this already handled. So
287
+ * there is no capability check to keep in step, and no flag day.
288
+ */
289
+ async function requestClone(projectId, gitId, branch, opts = {}) {
290
+ const started = await api.post(
291
+ `/api/projects/${encodeURIComponent(projectId)}/clone`,
292
+ { local_git_ids: [gitId], branch, async: true });
293
+
294
+ const jobId = started && (started.job_id || started.jobId);
295
+ if (!jobId) {
296
+ return started; // older server: it did the work inline and this IS the outcome
297
+ }
298
+
299
+ // Poll until it finishes. The interval is deliberately not tight: nothing is waiting on the answer
300
+ // faster than a person reads, and a shorter one only adds load to the server doing the copy.
301
+ // A seam, not a knob: tests pass a small value so they do not sit through real polls.
302
+ const POLL_MS = opts.pollMs || 2000;
303
+ const GIVE_UP_MS = opts.giveUpMs || 15 * 60 * 1000;
304
+ const startedAt = Date.now();
305
+ const deadline = startedAt + GIVE_UP_MS;
306
+ const sp = spinner.start("Building your temporary project...");
307
+ try {
308
+ for (;;) {
309
+ await sleep(POLL_MS);
310
+ let job;
311
+ try {
312
+ job = await api.get(`/api/projects/clone/job/${encodeURIComponent(jobId)}`);
313
+ } catch (e) {
314
+ // A blip while polling is not a failed clone. The copy is running on the server regardless of
315
+ // whether this one request reached it, so keep waiting rather than reporting a failure that
316
+ // has not happened. A 404 is different: the job is gone, and waiting cannot bring it back.
317
+ // Waiting cannot fix an expired token or a revoked account, and 15 minutes of silent retrying
318
+ // before finally reporting it is worse than saying so at once.
319
+ if (e && (e.status === 401 || e.status === 403)) {
320
+ throw e;
321
+ }
322
+ if (e && e.status === 404) {
323
+ throw new Error(
324
+ `The server has no record of clone job ${jobId}. It may have expired, or the server was `
325
+ + "restarted mid-copy. Run `niro new-temp-project` again.");
326
+ }
327
+ if (Date.now() > deadline) throw e;
328
+ continue;
329
+ }
330
+ const state = job && job.state;
331
+ if (state === "SUCCEEDED") {
332
+ sp.stop();
333
+ return job.outcome;
334
+ }
335
+ if (state === "FAILED") {
336
+ sp.stop();
337
+ throw new Error(job.error || "The server could not create the temporary project.");
338
+ }
339
+ if (Date.now() > deadline) {
340
+ sp.stop();
341
+ throw new Error(
342
+ `Still building after ${Math.round(GIVE_UP_MS / 60000)} minutes. The server may still finish; `
343
+ + `check with \`niro status\` before running this again.`);
344
+ }
345
+ // Deliberately NOT re-labelled with the elapsed time. Without a TTY the spinner prints a fresh
346
+ // line every time the label CHANGES, so putting the seconds in it produced one line per poll:
347
+ // measured at 10 lines in 11 seconds. The spinner already shows elapsed time where it can.
348
+ }
349
+ } finally {
350
+ sp.stop();
351
+ }
352
+ }
353
+
354
+ function sleep(ms) {
355
+ return new Promise((resolve) => setTimeout(resolve, ms));
356
+ }
357
+
358
+ module.exports = { takeover, requestClone};
@@ -126,8 +126,9 @@ const IGNORED_DIRS = new Set([
126
126
  "dist", ".venv", "vendor", ".idea", ".gradle", "out",
127
127
  ]);
128
128
 
129
- // Server-side per-file cap (256 KiB). Oversize files are skipped server-side at
130
- // /filter; we surface them as a warning so missing source is never silent.
129
+ // Fallback only, for a server too old to report its own limit. The real limit comes back from
130
+ // /filter as max_file_size_bytes, because a duplicated number is wrong the moment either side is
131
+ // configured: this constant said 256 KiB while a server raised to 2 MiB took the file happily.
131
132
  const PER_FILE_CAP_BYTES = 262144;
132
133
 
133
134
  const DEFAULT_CONCURRENCY = 4;
@@ -482,6 +483,46 @@ async function doInitialSync(gitId, targetDir, includedPaths, { sizes } = {}) {
482
483
  return hashes;
483
484
  }
484
485
 
486
+
487
+ /**
488
+ * Tell the developer which files the server is going to drop for being too large.
489
+ *
490
+ * <p>This is not cosmetic. `niro new-temp-project` silently dropped a developer's three largest source
491
+ * files -- the ones the whole branch was about -- because the warning lived only in `niro init`. Niro
492
+ * then answered questions about those files from the shared project's older copy, confidently, with
493
+ * nothing on screen to say the working copy did not contain them.
494
+ *
495
+ * Uses the limit the SERVER reports, so it cannot drift from what the server actually did. Falls back
496
+ * to the built-in constant only when talking to a server too old to report one.
497
+ *
498
+ * Cross-referenced against excluded_paths where the server sends it, so a file dropped for some other
499
+ * reason (a doc, a lockfile, a property file) is not blamed on its size.
500
+ *
501
+ * @returns the number of files warned about
502
+ */
503
+ function warnOversizeFiles(manifest, filterResult, log) {
504
+ const cap = Number(filterResult.max_file_size_bytes || filterResult.maxFileSizeBytes) || PER_FILE_CAP_BYTES;
505
+ // Over the limit AND a file that would otherwise have been indexed. The second half is what keeps
506
+ // this honest: a 900 KiB markdown file is over the limit too, but it was never going to be indexed,
507
+ // so blaming its size teaches the reader to ignore the warning.
508
+ //
509
+ // Deliberately NOT cross-referenced against excluded_paths, though that was the first attempt. It
510
+ // reads as the more precise test and is strictly worse: a file the server never saw is absent from
511
+ // that list, so a 3 MB source file went unwarned in testing. Size plus type needs no agreement
512
+ // between two lists to be right.
513
+ const hits = (manifest || []).filter((m) =>
514
+ m.size_bytes > cap && isUploadableName(m.path.split("/").pop()));
515
+ for (const m of hits) {
516
+ log(` \u26a0 ${m.path} (${Math.round(m.size_bytes / 1024)} KiB) is over this server's `
517
+ + `${Math.round(cap / 1024)} KiB per-file limit \u2014 it will NOT be indexed.`);
518
+ }
519
+ if (hits.length) {
520
+ log(` ${hits.length} file(s) will be missing from this project. Niro will answer about them `
521
+ + "from whatever version it already has, if any.");
522
+ }
523
+ return hits.length;
524
+ }
525
+
485
526
  module.exports = {
486
527
  buildManifest,
487
528
  walkDir,
@@ -500,4 +541,5 @@ module.exports = {
500
541
  IGNORED_DIRS,
501
542
  BATCH_SIZE,
502
543
  PER_FILE_CAP_BYTES,
544
+ warnOversizeFiles,
503
545
  };
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,12 +112,33 @@ 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
  }
101
119
 
120
+ /**
121
+ * The root of the MAIN working tree for `repoRoot`.
122
+ *
123
+ * A linked worktree (`git worktree add`) is its own folder on its own branch, but Niro onboarding is
124
+ * recorded against the folder that was actually onboarded, which is the main checkout. Without this a
125
+ * worktree has NO local identity: nothing in ~/.niro/projects.json is keyed by its path, so the repo it
126
+ * belongs to cannot be named at all. That is why `niro new-temp-project` could not be run from a
127
+ * worktree of a repo with no git remote, which is the layered store's main use case.
128
+ *
129
+ * Falls back to `repoRoot` itself when this IS the main working tree, when git cannot answer, or when
130
+ * the common dir is not the usual `<root>/.git` (a bare repo, or an unusual layout). That fallback is
131
+ * the behaviour that existed before this function, so it can only add an answer, never change one.
132
+ */
133
+ function mainWorktreeRoot(repoRoot) {
134
+ if (!repoRoot) return null;
135
+ const common = gitOut("git rev-parse --git-common-dir", repoRoot);
136
+ if (!common) return repoRoot;
137
+ const abs = path.resolve(repoRoot, common);
138
+ if (path.basename(abs) !== ".git") return repoRoot;
139
+ return path.dirname(abs);
140
+ }
141
+
102
142
  /**
103
143
  * Run ONE `git check-ignore --stdin` in `repoRoot` and report BOTH the matched
104
144
  * paths AND whether the check itself succeeded. Paths must be POSIX-relative to
@@ -187,4 +227,5 @@ function isTracked(repoRoot, relativePath) {
187
227
  }
188
228
  }
189
229
 
190
- module.exports = { inspect, deriveRepoName, checkIgnored, checkIgnoredPaths, redactRemoteCreds, isTracked };
230
+ module.exports = { inspect, mainWorktreeRoot, deriveRepoName, checkIgnored, checkIgnoredPaths,
231
+ redactRemoteCreds, isTracked, normalizeBranch };
@@ -27,9 +27,17 @@
27
27
  *
28
28
  * Never logs secrets. Resolution failures degrade gracefully (state: "error")
29
29
  * rather than throwing, so callers/agents can branch on `state`.
30
+ *
31
+ * A git URL is not an identity every repo has. `niro init` on a folder with no remote registers it
32
+ * with a synthetic `local://<name>` URL (see init.js / add-repo.js) that this module never sends back,
33
+ * and a folder that gains a remote AFTER onboarding keeps the URL it had at the time. Either way the
34
+ * URL join can never match, so when it comes back empty this falls back to the repo id recorded for
35
+ * the folder locally, and asks the server by that instead. See resolveByLocalGitId below.
30
36
  */
37
+ const path = require("path");
31
38
  const git = require("./git");
32
39
  const api = require("./api");
40
+ const watchState = require("./watchState");
33
41
 
34
42
  async function resolveContext(startPath = process.cwd(), opts = {}) {
35
43
  const { repoRoot, originUrl, branch } = git.inspect(startPath);
@@ -39,7 +47,11 @@ async function resolveContext(startPath = process.cwd(), opts = {}) {
39
47
  }
40
48
 
41
49
  if (!originUrl) {
42
- return { gitRoot: repoRoot, repoUrl: null, branch: branch || null, state: "no_origin", projects: [] };
50
+ // No remote to ask about, but the folder may still be a repo Niro knows: onboarding does not
51
+ // require one. Try the local identity before giving up.
52
+ return resolveByLocalGitId(repoRoot, branch, opts, {
53
+ gitRoot: repoRoot, repoUrl: null, branch: branch || null, state: "no_origin", projects: [],
54
+ });
43
55
  }
44
56
 
45
57
  // Omit branch when null (detached HEAD) -> URL-only resolve. Do NOT normalize
@@ -62,13 +74,83 @@ async function resolveContext(startPath = process.cwd(), opts = {}) {
62
74
  }
63
75
 
64
76
  const projects = normalizeProjects(data);
65
- return {
77
+ const byUrl = {
66
78
  gitRoot: repoRoot,
67
79
  repoUrl: originUrl,
68
80
  branch: branch || null,
69
81
  state: projects.length ? "resolved" : "unresolved",
70
82
  projects,
83
+ // Whether THIS server lets a developer hold more than one working copy of a project. It is a
84
+ // property of the server's configuration, not of the match, and it was being dropped here: the
85
+ // payload carries it on every resolve precisely so the CLI can stop applying a limit the server
86
+ // no longer enforces, and nothing in the CLI read it. The effect was that the server-side
87
+ // multi-worktree support could not be reached from any client.
88
+ multipleWorktreesAllowed: !!(data && data.multiple_worktrees_allowed),
71
89
  };
90
+ if (projects.length) return byUrl;
91
+ // The remote matched nothing. That is the normal answer for a folder onboarded before it had this
92
+ // remote, whose stored URL is the `local://` form. Same fallback as the no-origin case.
93
+ return resolveByLocalGitId(repoRoot, branch, opts, byUrl);
94
+ }
95
+
96
+ /**
97
+ * Resolve by the repo id this machine already recorded for the folder, and return `unresolved`
98
+ * unchanged when that cannot be done. Only ever called after the URL join came back empty, so it can
99
+ * add a match but never replace one.
100
+ *
101
+ * The id is exact where a URL is a guess: `local://demo` is derived from the folder name, so two
102
+ * unrelated folders both called `demo` would resolve to each other, while the id names one repo.
103
+ */
104
+ async function resolveByLocalGitId(repoRoot, branch, opts, unresolved) {
105
+ const apiUrl = opts.apiUrl || api.currentBaseUrl();
106
+ for (const gitId of localGitIdCandidates(repoRoot, apiUrl)) {
107
+ const query = { git_id: gitId };
108
+ if (branch) query.branch = branch;
109
+ let data;
110
+ try {
111
+ data = await api.get("/api/projects/resolve", { query, apiUrl: opts.apiUrl });
112
+ } catch {
113
+ // A server that predates `git_id` rejects the whole request, and would reject it for every
114
+ // candidate. Give back exactly what this folder resolved to before the parameter existed.
115
+ return unresolved;
116
+ }
117
+ const projects = normalizeProjects(data);
118
+ if (projects.length) {
119
+ return {
120
+ ...unresolved,
121
+ state: "resolved",
122
+ projects,
123
+ multipleWorktreesAllowed: !!(data && data.multiple_worktrees_allowed),
124
+ };
125
+ }
126
+ }
127
+ return unresolved;
128
+ }
129
+
130
+ /**
131
+ * Repo ids worth asking the server about for this folder, most specific first.
132
+ *
133
+ * Two folders are consulted: this one, and the MAIN working tree when this is a linked worktree. The
134
+ * second is the one that matters. A worktree is never itself onboarded, so it has no entry of its own,
135
+ * and looking only at the folder we are standing in is why a worktree could not be resolved at all.
136
+ *
137
+ * A folder that has already been taken over holds the COPY's gitId in `gitId`; the repo to resolve is
138
+ * the source it was taken from, which is why `takeover.sourceGitId` wins where it is present. Without
139
+ * that a re-run would ask about a temporary project's own repo, which the server correctly refuses.
140
+ */
141
+ function localGitIdCandidates(repoRoot, apiUrl) {
142
+ const folders = [path.resolve(repoRoot)];
143
+ const main = git.mainWorktreeRoot(repoRoot);
144
+ if (main && path.resolve(main) !== folders[0]) folders.push(path.resolve(main));
145
+
146
+ const ids = [];
147
+ for (const folder of folders) {
148
+ const entry = watchState.get(folder, apiUrl);
149
+ if (!entry) continue;
150
+ const id = (entry.takeover && entry.takeover.sourceGitId) || entry.gitId;
151
+ if (id && !ids.includes(id)) ids.push(id);
152
+ }
153
+ return ids;
72
154
  }
73
155
 
74
156
  /**
@@ -129,4 +211,4 @@ function projectIdFromCreateResponse(project) {
129
211
  return id.trim();
130
212
  }
131
213
 
132
- module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse };
214
+ module.exports = { resolveContext, normalizeProjects, projectIdFromCreateResponse, localGitIdCandidates };
@@ -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 };
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;