@niroai/niro 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/add-repo.js +40 -13
- package/src/commands/info.js +70 -0
- package/src/commands/init.js +41 -3
- package/src/commands/mcp.js +29 -1
- package/src/commands/takeover.js +42 -5
- package/src/commands/watch.js +219 -17
- package/src/index.js +49 -15
- package/src/lib/onboardTarget.js +258 -0
- package/src/lib/receiptStore.js +85 -0
- package/src/lib/tempProject.js +37 -0
- package/src/lib/watchState.js +30 -1
- package/src/mcp/server.js +56 -1
- package/src/mcp/setup.js +251 -26
- package/src/mcp/teardown.js +17 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this folder a sane thing to onboard? The check that runs BEFORE
|
|
3
|
+
* `niro init` / `niro add repo` register anything on the server or upload a byte.
|
|
4
|
+
*
|
|
5
|
+
* Two independent rules:
|
|
6
|
+
*
|
|
7
|
+
* 1. PROTECTED ROOT — never bypassable. $HOME, the filesystem root, any ancestor
|
|
8
|
+
* of $HOME (/home, /Users) and the well-known system dirs are refused outright.
|
|
9
|
+
* Onboarding one of these is never intended, and the damage is not the kind
|
|
10
|
+
* --force should be able to cause by accident: source from every project on the
|
|
11
|
+
* machine uploaded as one blob, a .niro-project pin dropped at $HOME, and a
|
|
12
|
+
* watch daemon streaming the whole home tree. Worse, .gitignore is only honoured
|
|
13
|
+
* when the onboarded folder is itself a git work tree, so onboarding $HOME
|
|
14
|
+
* silently uploads files that each nested repo deliberately ignores.
|
|
15
|
+
*
|
|
16
|
+
* 2. PROJECT INDICATOR — bypassable with --force. The folder must LOOK like a
|
|
17
|
+
* project: a build descriptor at the folder itself, or in a directory just below
|
|
18
|
+
* it. This mirrors ai-orchestrator
|
|
19
|
+
* ProjectStructureResolverService.detectProjects(), which marks any directory
|
|
20
|
+
* holding a descriptor from the ConfigFileParser SUPPORTED_FILES union as a
|
|
21
|
+
* project root. folderSync already carries that union as STRUCTURAL_FILENAMES /
|
|
22
|
+
* STRUCTURAL_EXTENSIONS (documented there as byte-identical to the server), so
|
|
23
|
+
* this REUSES those sets. There is deliberately no second list to drift.
|
|
24
|
+
*
|
|
25
|
+
* Two deliberate differences from the server:
|
|
26
|
+
*
|
|
27
|
+
* - Depth-bounded (INDICATOR_MAX_DEPTH) instead of a full-tree walk. The question
|
|
28
|
+
* here is "is THIS folder a project", not "which projects live somewhere inside
|
|
29
|
+
* it". A descriptor buried deep is precisely the accident being caught: $HOME
|
|
30
|
+
* contains thousands of them.
|
|
31
|
+
* - No parser veto. The server asks the owning parser isProjectIndicator(file) to
|
|
32
|
+
* reject a false match (a Python module literally named setup.py). The CLI cannot
|
|
33
|
+
* parse, so a name match is accepted. That errs toward ALLOWING onboarding, which
|
|
34
|
+
* is the safe direction for a guard: the worst case is the behaviour we had before.
|
|
35
|
+
*/
|
|
36
|
+
const fs = require("fs");
|
|
37
|
+
const os = require("os");
|
|
38
|
+
const path = require("path");
|
|
39
|
+
const color = require("./color");
|
|
40
|
+
const folderSync = require("./folderSync");
|
|
41
|
+
|
|
42
|
+
// How far below the target we look for a build descriptor. 0 = the folder itself.
|
|
43
|
+
// 2 covers a monorepo root whose deployables sit under a grouping dir
|
|
44
|
+
// (services/foo/pom.xml, packages/foo/package.json) and still refuses a folder whose
|
|
45
|
+
// only descriptors are buried deeper than any real project root would be.
|
|
46
|
+
const INDICATOR_MAX_DEPTH = 2;
|
|
47
|
+
|
|
48
|
+
// Hard bound on the walk so a pathological tree cannot stall the command.
|
|
49
|
+
const MAX_DIRS_SCANNED = 2000;
|
|
50
|
+
|
|
51
|
+
// Refused outright, by exact path match. Ancestors of $HOME (/home, /Users) are
|
|
52
|
+
// caught by the ancestor rule below as well; listed here for the case where $HOME
|
|
53
|
+
// lives somewhere unusual.
|
|
54
|
+
const SYSTEM_DIRS = new Set([
|
|
55
|
+
"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/media",
|
|
56
|
+
"/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp",
|
|
57
|
+
"/usr", "/var", "/Applications", "/Library", "/System", "/Users", "/Volumes",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Windows paths are case-insensitive; POSIX paths are not.
|
|
61
|
+
function samePath(a, b) {
|
|
62
|
+
if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase();
|
|
63
|
+
return a === b;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve symlinks before comparing, so a symlinked route to a protected path (macOS
|
|
67
|
+
// /tmp -> /private/tmp, an $HOME reached through /mnt/home/<user>) cannot walk past the
|
|
68
|
+
// refusal on a string mismatch. Falls back to the lexical path when the target does not
|
|
69
|
+
// exist or is unreadable — the comparison is then no worse than before.
|
|
70
|
+
function realPath(p) {
|
|
71
|
+
try {
|
|
72
|
+
return fs.realpathSync(p);
|
|
73
|
+
} catch {
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Why this folder must never be onboarded, or null when it is fine.
|
|
80
|
+
* Returns a human phrase that slots into "Refusing to onboard <phrase>".
|
|
81
|
+
*/
|
|
82
|
+
function protectedRootReason(dir) {
|
|
83
|
+
const target = realPath(path.resolve(dir));
|
|
84
|
+
|
|
85
|
+
if (samePath(target, path.parse(target).root)) return "the filesystem root";
|
|
86
|
+
|
|
87
|
+
let home = null;
|
|
88
|
+
try {
|
|
89
|
+
home = os.homedir() ? realPath(path.resolve(os.homedir())) : null;
|
|
90
|
+
} catch {
|
|
91
|
+
home = null; // no home resolvable -> only the root/system rules apply
|
|
92
|
+
}
|
|
93
|
+
if (home) {
|
|
94
|
+
if (samePath(target, home)) return "your home folder";
|
|
95
|
+
// home lives INSIDE target => target is an ancestor of home (/home, /Users, ...).
|
|
96
|
+
const rel = path.relative(target, home);
|
|
97
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
98
|
+
return "a parent of your home folder";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (SYSTEM_DIRS.has(target)) return "a system folder";
|
|
103
|
+
// Also refuse the lexical path when it differs from the resolved one, so a symlink
|
|
104
|
+
// POINTING AT an ordinary folder cannot be used to name a protected path either way.
|
|
105
|
+
if (SYSTEM_DIRS.has(path.resolve(dir))) return "a system folder";
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The indicator kind this filename represents, or null. Name match wins over
|
|
111
|
+
* extension match, mirroring the server's parserRegistry-then-extensionRegistry
|
|
112
|
+
* lookup order. Returned as a display label ("pom.xml", "*.csproj").
|
|
113
|
+
*/
|
|
114
|
+
function indicatorName(fileName) {
|
|
115
|
+
const lower = path.basename(fileName).toLowerCase();
|
|
116
|
+
if (folderSync.STRUCTURAL_FILENAMES.has(lower)) return lower;
|
|
117
|
+
const ext = path.extname(lower);
|
|
118
|
+
if (ext && folderSync.STRUCTURAL_EXTENSIONS.has(ext)) return `*${ext}`;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Look for project roots at `targetDir` and just below it.
|
|
124
|
+
*
|
|
125
|
+
* Stops descending as soon as a directory IS a project root: once a descriptor is
|
|
126
|
+
* found there is nothing further to prove, and it keeps the common case (a normal
|
|
127
|
+
* repo with a descriptor at its top) down to a single readdir.
|
|
128
|
+
*
|
|
129
|
+
* Returns { roots, indicators: Map(label -> count), truncated }.
|
|
130
|
+
*/
|
|
131
|
+
function findProjectIndicators(targetDir, opts = {}) {
|
|
132
|
+
const maxDepth = Number.isInteger(opts.maxDepth) ? opts.maxDepth : INDICATOR_MAX_DEPTH;
|
|
133
|
+
// Injectable so the breadth-first guarantee can be tested without a 2000-directory fixture.
|
|
134
|
+
const maxDirs = Number.isInteger(opts.maxDirs) ? opts.maxDirs : MAX_DIRS_SCANNED;
|
|
135
|
+
const indicators = new Map();
|
|
136
|
+
let roots = 0;
|
|
137
|
+
let dirsScanned = 0;
|
|
138
|
+
let truncated = false;
|
|
139
|
+
|
|
140
|
+
// Breadth-first, one depth level at a time. Depth-first would let a single wide or
|
|
141
|
+
// deep subtree spend the whole MAX_DIRS_SCANNED budget before the walk ever looked
|
|
142
|
+
// at the target's own siblings, so a real project root could go unseen while junk
|
|
143
|
+
// was scanned. Breadth-first means level 0 is always examined, then all of level 1,
|
|
144
|
+
// and only the deepest level can ever be cut short.
|
|
145
|
+
let frontier = [path.resolve(targetDir)];
|
|
146
|
+
for (let depth = 0; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
147
|
+
const next = [];
|
|
148
|
+
for (const dir of frontier) {
|
|
149
|
+
if (dirsScanned >= maxDirs) { truncated = true; break; }
|
|
150
|
+
dirsScanned++;
|
|
151
|
+
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
155
|
+
} catch {
|
|
156
|
+
continue; // unreadable (permissions, vanished) -> nothing to prove here
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let isRoot = false;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (!entry.isFile()) continue;
|
|
162
|
+
const label = indicatorName(entry.name);
|
|
163
|
+
if (!label) continue;
|
|
164
|
+
isRoot = true;
|
|
165
|
+
indicators.set(label, (indicators.get(label) || 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
// A project root proves what we came to prove; there is no reason to look inside it.
|
|
168
|
+
if (isRoot) { roots++; continue; }
|
|
169
|
+
if (depth >= maxDepth) continue;
|
|
170
|
+
|
|
171
|
+
// Sorted so the walk is deterministic: readdir order is filesystem-dependent, and
|
|
172
|
+
// which directories fall inside the budget must not vary between machines.
|
|
173
|
+
const children = entries
|
|
174
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !folderSync.IGNORED_DIRS.has(e.name))
|
|
175
|
+
.map((e) => e.name)
|
|
176
|
+
.sort();
|
|
177
|
+
for (const name of children) next.push(path.join(dir, name));
|
|
178
|
+
}
|
|
179
|
+
if (truncated) break;
|
|
180
|
+
frontier = next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { roots, indicators, truncated };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// "pom.xml x10, package.json x1" — the three commonest kinds, then a tail count.
|
|
187
|
+
function describeIndicators(indicators) {
|
|
188
|
+
const sorted = [...indicators.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
189
|
+
const shown = sorted.slice(0, 3).map(([label, n]) => (n > 1 ? `${label} x${n}` : label));
|
|
190
|
+
const rest = sorted.length - shown.length;
|
|
191
|
+
return rest > 0 ? `${shown.join(", ")}, and ${rest} more` : shown.join(", ");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The gate itself. Prints the refusal and returns false when this folder must not be
|
|
196
|
+
* onboarded; returns true when the caller may proceed.
|
|
197
|
+
*
|
|
198
|
+
* opts.force bypasses the INDICATOR rule only. It deliberately does not bypass a
|
|
199
|
+
* protected root: there is no legitimate reason to upload $HOME, and a scripted
|
|
200
|
+
* --force must not be able to do it by accident.
|
|
201
|
+
*
|
|
202
|
+
* opts.alreadyRegistered likewise bypasses the INDICATOR rule only, for a folder this
|
|
203
|
+
* backend already holds a repo for. Callers pass it from watchState so that adding the
|
|
204
|
+
* gate never breaks a sync that was already working.
|
|
205
|
+
*/
|
|
206
|
+
function ensureOnboardable(targetDir, opts = {}) {
|
|
207
|
+
const resolved = path.resolve(targetDir);
|
|
208
|
+
|
|
209
|
+
const reason = protectedRootReason(resolved);
|
|
210
|
+
if (reason) {
|
|
211
|
+
console.error(color.red(`\n ✗ Refusing to onboard ${reason} (${resolved}).`));
|
|
212
|
+
console.error(" That would upload source from every project underneath it, pin this folder");
|
|
213
|
+
console.error(" with a .niro-project file, and watch the whole tree for changes.");
|
|
214
|
+
console.error(` Change into the project folder you meant, then run the command there.`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { roots, indicators, truncated } = findProjectIndicators(resolved, opts);
|
|
219
|
+
if (indicators.size === 0) {
|
|
220
|
+
// A folder this backend ALREADY has a repo for is exempt from the descriptor rule.
|
|
221
|
+
// That rule gates NEW onboarding; it is not a reason to start failing a re-sync of a
|
|
222
|
+
// folder onboarded by an earlier CLI, which would strand the user with no way back in.
|
|
223
|
+
// The protected-root rule above still applied, and still refuses.
|
|
224
|
+
if (opts.alreadyRegistered) {
|
|
225
|
+
console.log(color.dim(
|
|
226
|
+
` No build file in ${resolved}, but it is already registered here — continuing.`
|
|
227
|
+
));
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (opts.force) {
|
|
231
|
+
console.log(color.yellow(
|
|
232
|
+
`\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
|
|
233
|
+
));
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
console.error(color.red(`\n ✗ No project found in ${resolved}.`));
|
|
237
|
+
console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
|
|
238
|
+
console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
|
|
239
|
+
console.error(" Pass --force if you really mean to upload this folder.");
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.log(
|
|
244
|
+
` ${color.green("✓")} Found ${roots} project${roots === 1 ? "" : "s"} ` +
|
|
245
|
+
`(${describeIndicators(indicators)}).`
|
|
246
|
+
);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
ensureOnboardable,
|
|
252
|
+
protectedRootReason,
|
|
253
|
+
findProjectIndicators,
|
|
254
|
+
indicatorName,
|
|
255
|
+
describeIndicators,
|
|
256
|
+
INDICATOR_MAX_DEPTH,
|
|
257
|
+
MAX_DIRS_SCANNED,
|
|
258
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-developer off switch for the end-of-task receipt line.
|
|
3
|
+
*
|
|
4
|
+
* The line itself is composed by the server and travels back inside the mark_task_complete tool
|
|
5
|
+
* result, which the model prints wrapped in backticks. Nothing is stored locally to display it.
|
|
6
|
+
*
|
|
7
|
+
* This file used to be a letterbox: the bridge wrote each receipt to disk and a Claude Code Stop
|
|
8
|
+
* hook printed it. That worked, but Claude Code prefixes anything a Stop hook prints with
|
|
9
|
+
* "Stop says: …", which read as a system warning rather than a note from Niro. Printing from the
|
|
10
|
+
* model reads better and works in all twelve clients instead of one, so the hook, the letterbox and
|
|
11
|
+
* the terminal colouring all went with it.
|
|
12
|
+
*
|
|
13
|
+
* What remains is the switch. It stays local because it is a personal preference: the server serves
|
|
14
|
+
* a whole account and cannot know that one developer wants the line quiet. The bridge reads it and
|
|
15
|
+
* strips the receipt from the tool result, so the model never sees it and cannot print it.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
/** ~/.niro, resolved the same way as the rest of the CLI (Windows has no HOME). */
|
|
22
|
+
function niroHome() {
|
|
23
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir() || "";
|
|
24
|
+
return path.join(home, ".niro");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Presence of this file switches the line off.
|
|
29
|
+
*
|
|
30
|
+
* A file rather than a key inside projects.json so that toggling it can never corrupt, or be
|
|
31
|
+
* corrupted by, the sync state the watch daemon depends on.
|
|
32
|
+
*/
|
|
33
|
+
function disabledFlagPath() {
|
|
34
|
+
return path.join(niroHome(), "receipts-off");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isDisabled() {
|
|
38
|
+
try {
|
|
39
|
+
return fs.existsSync(disabledFlagPath());
|
|
40
|
+
} catch {
|
|
41
|
+
// Cannot read the home directory: treat as ON. Failing to a silent state would make the
|
|
42
|
+
// feature look broken with nothing to explain why.
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn the line on or off. Returns true when the state actually changed. */
|
|
48
|
+
function setDisabled(disabled) {
|
|
49
|
+
const flag = disabledFlagPath();
|
|
50
|
+
try {
|
|
51
|
+
if (disabled) {
|
|
52
|
+
if (fs.existsSync(flag)) return false;
|
|
53
|
+
fs.mkdirSync(path.dirname(flag), { recursive: true, mode: 0o700 });
|
|
54
|
+
fs.writeFileSync(flag, "", { mode: 0o600 });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (!fs.existsSync(flag)) return false;
|
|
58
|
+
fs.unlinkSync(flag);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove the off switch and any leftovers from the retired letterbox.
|
|
67
|
+
*
|
|
68
|
+
* Used by `niro mcp uninstall`, and worth keeping past the letterbox itself: anyone who ran a
|
|
69
|
+
* version that wrote receipts to disk still has that directory, and an uninstall that leaves files
|
|
70
|
+
* behind is not an uninstall.
|
|
71
|
+
*/
|
|
72
|
+
function purge() {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(path.join(niroHome(), "receipts"), { recursive: true, force: true });
|
|
75
|
+
} catch {
|
|
76
|
+
/* best effort */
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(disabledFlagPath(), { force: true });
|
|
80
|
+
} catch {
|
|
81
|
+
/* best effort */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { disabledFlagPath, isDisabled, setDisabled, purge };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the state of a temporary project.
|
|
3
|
+
*
|
|
4
|
+
* A developer gets ONE temporary project per project, and `niro new-temp-project` MOVES it rather than
|
|
5
|
+
* adding a second. So "which folder does Niro currently reflect?" has exactly one answer, and it lives
|
|
6
|
+
* on the server: the branch recorded against the temporary project's repo.
|
|
7
|
+
*
|
|
8
|
+
* That matters because a folder cannot tell locally whether it still holds the slot. Every folder taken
|
|
9
|
+
* over for one project shares the SAME repo id, so the server cannot tell them apart either — if two
|
|
10
|
+
* folders upload, their branches blend into one copy and every answer drawn from it is a mixture. The
|
|
11
|
+
* only way to know is to ask which branch the copy is on and compare it with your own.
|
|
12
|
+
*/
|
|
13
|
+
const api = require("./api");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The branch a temporary project is currently bound to, or null if it cannot be determined.
|
|
17
|
+
*
|
|
18
|
+
* Returns null rather than throwing on a server or network problem: this is used to decide whether to
|
|
19
|
+
* hold back an upload, and "I could not reach the server" must never be read as "you have lost the
|
|
20
|
+
* slot" — that would stop a perfectly healthy folder syncing every time the network hiccuped.
|
|
21
|
+
*/
|
|
22
|
+
async function currentBranch(tempProjectId, gitId, opts = {}) {
|
|
23
|
+
if (!tempProjectId) return null;
|
|
24
|
+
let project;
|
|
25
|
+
try {
|
|
26
|
+
project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`, opts);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const repos = project.git_details || project.gitDetails || [];
|
|
31
|
+
// Match the repo by id when we can. Falling back to the first is safe for the single-repo case and
|
|
32
|
+
// still right for multi-repo: every repo in one temporary project is taken over onto one branch.
|
|
33
|
+
const match = repos.find((r) => r.id === gitId) || repos[0];
|
|
34
|
+
return match && match.branch ? match.branch : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { currentBranch };
|
package/src/lib/watchState.js
CHANGED
|
@@ -127,6 +127,34 @@ function update(folderPath, patch) {
|
|
|
127
127
|
save(state);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Merge per-path hash changes into an entry instead of replacing the whole map.
|
|
132
|
+
*
|
|
133
|
+
* `update({ fileHashes })` replaces the map wholesale, so whichever writer saves last wins and every
|
|
134
|
+
* entry the other writer added in the meantime is silently gone. The delta sync computes a map from
|
|
135
|
+
* its own walk and writes it at the end, which can take minutes on a large upload; any live per-file
|
|
136
|
+
* event that landed during that window is erased. Losing a baseline entry is not cosmetic — the file
|
|
137
|
+
* stops being tracked, so it is never re-sent and the divergence is permanent. That is the difference
|
|
138
|
+
* between the 27 July incident, which healed itself in 35 minutes, and 31 July, which never did.
|
|
139
|
+
*
|
|
140
|
+
* Removals must be explicit rather than implied by absence, so nothing disappears by accident.
|
|
141
|
+
*
|
|
142
|
+
* load/save are synchronous and save renames atomically, so this is safe against other writers in
|
|
143
|
+
* THIS process. The watch daemon is a separate process, so a cross-process interleave is still
|
|
144
|
+
* possible; this shrinks the window from minutes to microseconds rather than closing it entirely.
|
|
145
|
+
*/
|
|
146
|
+
function mergeFileHashes(folderPath, { set = {}, remove = [], patch = {} } = {}) {
|
|
147
|
+
const state = load();
|
|
148
|
+
const key = path.resolve(folderPath);
|
|
149
|
+
if (!state[key]) return;
|
|
150
|
+
const merged = { ...(state[key].fileHashes || {}) };
|
|
151
|
+
for (const rp of remove) delete merged[rp];
|
|
152
|
+
// Applied after the removals, so a file deleted and re-created within one run keeps its new hash.
|
|
153
|
+
Object.assign(merged, set);
|
|
154
|
+
state[key] = { ...state[key], ...patch, fileHashes: merged };
|
|
155
|
+
save(state);
|
|
156
|
+
}
|
|
157
|
+
|
|
130
158
|
/**
|
|
131
159
|
* Remove a folder's entry. When `expectedGitId` is given, only remove if the CURRENT entry still has that
|
|
132
160
|
* gitId (compare-and-delete): the self-heal path removes a poison entry after an async repoExists round-trip,
|
|
@@ -214,4 +242,5 @@ function hashFile(filePath) {
|
|
|
214
242
|
}
|
|
215
243
|
}
|
|
216
244
|
|
|
217
|
-
module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
|
|
245
|
+
module.exports = { get, peek, set, update, remove, allLocal, findOverlapping, matchesBackend, hashFile,
|
|
246
|
+
mergeFileHashes, FILE };
|
package/src/mcp/server.js
CHANGED
|
@@ -42,6 +42,7 @@ const readline = require("readline");
|
|
|
42
42
|
const { execSync } = require("child_process");
|
|
43
43
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
44
44
|
const { redactRemoteCreds } = require("../lib/git");
|
|
45
|
+
const receiptStore = require("../lib/receiptStore");
|
|
45
46
|
|
|
46
47
|
/** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
|
|
47
48
|
* normally unset — `process.env.HOME || ""` there produced a relative path that never existed, silently
|
|
@@ -507,6 +508,7 @@ function connectSse() {
|
|
|
507
508
|
// Swallow the response to an internally-replayed initialize (sentinel id) so the host
|
|
508
509
|
// never sees a duplicate initialize result after a transparent reconnect.
|
|
509
510
|
let swallowed = false;
|
|
511
|
+
let rewritten = null;
|
|
510
512
|
try {
|
|
511
513
|
const msg = JSON.parse(data);
|
|
512
514
|
// ADR-018 handshake: only an initialize result carries capabilities. When the server
|
|
@@ -528,9 +530,12 @@ function connectSse() {
|
|
|
528
530
|
} else {
|
|
529
531
|
// Cancel the client-side deadline when a delivered result matches an in-flight request.
|
|
530
532
|
pending.onIncoming(msg);
|
|
533
|
+
rewritten = captureReceipt(msg);
|
|
531
534
|
}
|
|
532
535
|
} catch (_) { /* not correlatable JSON — forward unchanged */ }
|
|
533
|
-
|
|
536
|
+
// captureReceipt returns a rewritten payload when it stripped the receipt field so the
|
|
537
|
+
// Stop hook can print it instead; otherwise the original bytes go through untouched.
|
|
538
|
+
if (!swallowed) process.stdout.write((rewritten || data) + "\n");
|
|
534
539
|
}
|
|
535
540
|
}
|
|
536
541
|
});
|
|
@@ -662,6 +667,56 @@ function sendMessage(message) {
|
|
|
662
667
|
});
|
|
663
668
|
}
|
|
664
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Stash the end-of-task receipt line, if this incoming message carries one.
|
|
672
|
+
*
|
|
673
|
+
* The server composes the line and returns it inside the mark_task_complete tool result. The model
|
|
674
|
+
* is asked to print it, but a model can forget an instruction; the Claude Code Stop hook prints it
|
|
675
|
+
* whether or not the model cooperates, and this is where it gets the text. See
|
|
676
|
+
* `docs/usecases/task-receipt.md`.
|
|
677
|
+
*
|
|
678
|
+
* Deliberately does NOT filter by tool name: the bridge does not track which request id belonged to
|
|
679
|
+
* which tool, and a `receipt` string in a result is unambiguous enough on its own. Any future tool
|
|
680
|
+
* that wants to say something at end of task gets this for free.
|
|
681
|
+
*
|
|
682
|
+
* Silent on every failure. This sits on the path that hands results back to the developer's
|
|
683
|
+
* assistant, so a malformed message, an unwritable home directory, or an unexpected shape must cost
|
|
684
|
+
* the receipt and nothing else.
|
|
685
|
+
*/
|
|
686
|
+
function captureReceipt(msg) {
|
|
687
|
+
// Nothing to do unless the developer has switched the line off. The receipt is displayed by the
|
|
688
|
+
// model printing it from the tool result — there is no local copy and no hook any more.
|
|
689
|
+
if (!receiptStore.isDisabled()) return null;
|
|
690
|
+
try {
|
|
691
|
+
const content = msg && msg.result && msg.result.content;
|
|
692
|
+
if (!Array.isArray(content)) return null;
|
|
693
|
+
for (const item of content) {
|
|
694
|
+
if (!item || typeof item.text !== "string") continue;
|
|
695
|
+
// Cheap reject before parsing: tool results are frequently large, and almost none of them
|
|
696
|
+
// are the one message per task that carries a receipt.
|
|
697
|
+
if (!item.text.includes('"receipt"')) continue;
|
|
698
|
+
let parsed;
|
|
699
|
+
try {
|
|
700
|
+
parsed = JSON.parse(item.text);
|
|
701
|
+
} catch (_) {
|
|
702
|
+
continue; // not JSON — some other tool's prose that happens to contain the word
|
|
703
|
+
}
|
|
704
|
+
if (parsed && typeof parsed.receipt === "string") {
|
|
705
|
+
// Strip it here rather than asking the server not to send it. The switch is per developer
|
|
706
|
+
// and lives on this machine; the server serves a whole account and cannot know that one
|
|
707
|
+
// person wants it quiet.
|
|
708
|
+
delete parsed.receipt;
|
|
709
|
+
item.text = JSON.stringify(parsed);
|
|
710
|
+
return JSON.stringify(msg);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} catch (_) {
|
|
714
|
+
/* never let this disturb the message path */
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
|
|
665
720
|
/**
|
|
666
721
|
* Enrich an outgoing tools/call: send the folder's `.niro-project` pin as `_niroPin` (ADR-018 — a
|
|
667
722
|
* SOURCE binding the server's routing ladder consumes, NOT an explicit project choice), and
|