@niroai/niro 0.10.1 → 0.12.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 +164 -0
- package/README.md +81 -4
- package/docs/working-on-branches.md +63 -0
- package/package.json +1 -1
- package/src/commands/_resolveProject.js +59 -3
- package/src/commands/cleanup.js +689 -0
- package/src/commands/delete.js +16 -1
- package/src/commands/info.js +51 -2
- package/src/commands/release.js +13 -60
- package/src/commands/remove.js +13 -0
- package/src/commands/takeover.js +52 -2
- package/src/commands/watch.js +93 -25
- package/src/index.js +23 -3
- package/src/lib/folderSync.js +7 -0
- package/src/lib/impactLog.js +251 -0
- package/src/lib/periodicClaim.js +100 -0
- package/src/lib/printError.js +46 -0
- package/src/lib/receiptStore.js +1 -1
- package/src/lib/takeoverRestore.js +156 -0
- package/src/mcp/server.js +30 -10
- package/src/mcp/setup.js +441 -23
- package/src/mcp/teardown.js +7 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record of the impact checks an agent made through the bridge, read by the Edit|Write impact gate.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: CLAUDE.md, the niro skill and the UserPromptSubmit hook all tell the agent to map
|
|
5
|
+
* who depends on code before changing it (get_blast_radius, get_impact_analysis). Prose decays over a
|
|
6
|
+
* session: the agent obeys early, then reads a method with sed and edits it. Niraj, 2026-09-04: "I
|
|
7
|
+
* always felt that before you make any code changes in any method you would want to check what's
|
|
8
|
+
* the impact of that but you hardly ever care to check." The only thing that survives a long session
|
|
9
|
+
* is a check at the moment of the edit. The PreToolUse hook in setup.js (NIRO_IMPACT_COMMAND) is that
|
|
10
|
+
* check, and it needs evidence of what the agent actually called. The bridge sees every tools/call and
|
|
11
|
+
* every answer, so it keeps that evidence here.
|
|
12
|
+
*
|
|
13
|
+
* What is written: one JSON line per answered impact call under ~/.niro/impact/<key>.jsonl, where
|
|
14
|
+
* <key> is the bridge's working directory, symlinks resolved, with every byte that is not an ASCII
|
|
15
|
+
* letter or digit replaced by "-". The hook derives the same key from the payload's cwd with
|
|
16
|
+
* `cd -P` and `tr -c 'A-Za-z0-9' '-'`, so the two sides agree without sharing code (tested in
|
|
17
|
+
* test/impactGateHook.test.js). A line carries the tool name, the names the agent asked about
|
|
18
|
+
* (method_fqn, class_fqn and friends) and every source path the answer mentioned. Lines older than
|
|
19
|
+
* WINDOW_MS are pruned on every write, so the file cannot grow past one hour of checks.
|
|
20
|
+
*
|
|
21
|
+
* Liveness: <key>.alive is touched whenever the server answers. The hook denies an unchecked edit only
|
|
22
|
+
* while that file is fresh; with the bridge gone it can only advise, so Niro being unreachable never
|
|
23
|
+
* blocks an edit. Fail-open by design.
|
|
24
|
+
*
|
|
25
|
+
* Every function here swallows filesystem errors: this sits on the message path between the host and
|
|
26
|
+
* the server, and a full disk or an unwritable home must cost the gate and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
const fs = require("fs");
|
|
29
|
+
const path = require("path");
|
|
30
|
+
const { niroHome } = require("./receiptStore");
|
|
31
|
+
|
|
32
|
+
/** The calls that count as "checked who depends on it". Downstream-only walks (get_call_chain) do not. */
|
|
33
|
+
const IMPACT_TOOLS = new Set(["get_blast_radius", "get_impact_analysis", "get_reverse_call_chain"]);
|
|
34
|
+
/** How long a recorded check keeps a file unlocked. */
|
|
35
|
+
const WINDOW_MS = 60 * 60 * 1000;
|
|
36
|
+
const MAX_LINES = 500;
|
|
37
|
+
const MAX_NAMES = 50;
|
|
38
|
+
const MAX_FILES = 200;
|
|
39
|
+
const MAX_IN_FLIGHT = 100;
|
|
40
|
+
/** The alive file is rewritten at most this often; answers arrive far more frequently than that. */
|
|
41
|
+
const ALIVE_TOUCH_MIN_MS = 10 * 1000;
|
|
42
|
+
/** Argument names across the impact tools that carry what the agent asked about. */
|
|
43
|
+
const NAME_ARGS = [
|
|
44
|
+
"method_fqn", "method_fqns", "class_fqn", "entity_fqn", "entity_fqns", "fqn", "fqns", "targets",
|
|
45
|
+
"http_path", "event_fqn",
|
|
46
|
+
];
|
|
47
|
+
const SOURCE_EXT = "java|js|mjs|cjs|ts|tsx|jsx|py|go|rb|kt|kts|cs|php|rs|scala|swift|c|cc|cpp|h|hpp";
|
|
48
|
+
/**
|
|
49
|
+
* A source path inside an answer: optional leading "/", slash-separated segments, a known extension,
|
|
50
|
+
* bounded by whitespace, quotes, brackets, ":" or ",". The ":" lets `repo::src/a/B.java` yield the
|
|
51
|
+
* repo-relative path, which is what the hook compares against the edited file's path.
|
|
52
|
+
*/
|
|
53
|
+
const PATH_RE = new RegExp(
|
|
54
|
+
"(?:^|[\\s\"'(:,])(/?(?:[A-Za-z0-9_.\\-]+/)*[A-Za-z0-9_.\\-]+\\.(?:" + SOURCE_EXT + "))(?=$|[\\s\"'),:])",
|
|
55
|
+
"g",
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
function impactDir() {
|
|
59
|
+
return path.join(niroHome(), "impact");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Presence of this file switches the gate off for every session on this machine. */
|
|
63
|
+
function offSwitchPath() {
|
|
64
|
+
return path.join(niroHome(), "impact-gate-off");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The per-folder key, byte for byte what `printf '%s' "$(cd -P dir && pwd -P)" | tr -c 'A-Za-z0-9' '-'`
|
|
69
|
+
* produces. Byte-based on purpose: tr works on bytes, so a non-ASCII character becomes several dashes
|
|
70
|
+
* there and must here too, or the hook looks for a file the bridge never wrote.
|
|
71
|
+
*/
|
|
72
|
+
function keyForCwd(cwd) {
|
|
73
|
+
let real = String(cwd || "");
|
|
74
|
+
try {
|
|
75
|
+
real = fs.realpathSync(real);
|
|
76
|
+
} catch (_) {
|
|
77
|
+
/* a folder that does not exist keeps its lexical path, as the hook's `cd -P || printf` fallback does */
|
|
78
|
+
}
|
|
79
|
+
let out = "";
|
|
80
|
+
for (const b of Buffer.from(real, "utf8")) {
|
|
81
|
+
const alnum = (b >= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122);
|
|
82
|
+
out += alnum ? String.fromCharCode(b) : "-";
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function logPathFor(cwd) {
|
|
88
|
+
return path.join(impactDir(), keyForCwd(cwd) + ".jsonl");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function alivePathFor(cwd) {
|
|
92
|
+
return path.join(impactDir(), keyForCwd(cwd) + ".alive");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toolNameOf(message) {
|
|
96
|
+
if (!message || message.method !== "tools/call" || !message.params) return null;
|
|
97
|
+
return typeof message.params.name === "string" ? message.params.name : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isImpactCall(message) {
|
|
101
|
+
const name = toolNameOf(message);
|
|
102
|
+
return !!name && IMPACT_TOOLS.has(name);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The strings the agent asked about, deduplicated and bounded; routing extras like _niroPin are ignored. */
|
|
106
|
+
function namesFromArgs(args) {
|
|
107
|
+
const out = [];
|
|
108
|
+
const push = (v) => {
|
|
109
|
+
if (typeof v !== "string") return;
|
|
110
|
+
const s = v.trim().slice(0, 300);
|
|
111
|
+
if (s && !out.includes(s)) out.push(s);
|
|
112
|
+
};
|
|
113
|
+
if (args && typeof args === "object") {
|
|
114
|
+
for (const key of NAME_ARGS) {
|
|
115
|
+
const v = args[key];
|
|
116
|
+
if (Array.isArray(v)) v.forEach(push);
|
|
117
|
+
else push(v);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out.slice(0, MAX_NAMES);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** All text parts of a tool result, joined; "" for anything that is not a text result. */
|
|
124
|
+
function resultText(msg) {
|
|
125
|
+
const content = msg && msg.result && msg.result.content;
|
|
126
|
+
if (!Array.isArray(content)) return "";
|
|
127
|
+
return content.map((c) => (c && typeof c.text === "string" ? c.text : "")).join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every distinct source path an answer mentions, in order of appearance, bounded. */
|
|
131
|
+
function filesFromResultText(text) {
|
|
132
|
+
const out = [];
|
|
133
|
+
if (typeof text !== "string" || !text) return out;
|
|
134
|
+
PATH_RE.lastIndex = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = PATH_RE.exec(text)) && out.length < MAX_FILES) {
|
|
137
|
+
if (!out.includes(m[1])) out.push(m[1]);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drop lines older than the window, then cap the count; the newest lines win. */
|
|
143
|
+
function pruneLines(lines, now) {
|
|
144
|
+
const cutoff = now - WINDOW_MS;
|
|
145
|
+
const kept = lines.filter((line) => {
|
|
146
|
+
const m = /"ts":(\d+)/.exec(line);
|
|
147
|
+
return !!m && Number(m[1]) >= cutoff;
|
|
148
|
+
});
|
|
149
|
+
return kept.length > MAX_LINES ? kept.slice(kept.length - MAX_LINES) : kept;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Append one record, pruning first. Returns false, never throws, when the file cannot be written. */
|
|
153
|
+
function appendRecord(file, record, now) {
|
|
154
|
+
try {
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
156
|
+
let lines = [];
|
|
157
|
+
try {
|
|
158
|
+
lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
159
|
+
} catch (_) {
|
|
160
|
+
/* first record for this folder */
|
|
161
|
+
}
|
|
162
|
+
lines = pruneLines(lines, now);
|
|
163
|
+
lines.push(JSON.stringify(record));
|
|
164
|
+
fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
165
|
+
return true;
|
|
166
|
+
} catch (_) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One recorder per bridge process. `noteRequest` on every outgoing message, `noteResult` and
|
|
173
|
+
* `touchAlive` on every incoming one; each returns true only when it wrote something, which the
|
|
174
|
+
* tests use and the bridge ignores.
|
|
175
|
+
*/
|
|
176
|
+
function createImpactRecorder({ cwd = process.cwd(), now = Date.now } = {}) {
|
|
177
|
+
const inFlight = new Map();
|
|
178
|
+
let lastAlive = 0;
|
|
179
|
+
return {
|
|
180
|
+
noteRequest(message) {
|
|
181
|
+
if (!isImpactCall(message) || message.id === undefined || message.id === null) return false;
|
|
182
|
+
const args = message.params.arguments || {};
|
|
183
|
+
inFlight.set(message.id, { tool: message.params.name, names: namesFromArgs(args), ts: now() });
|
|
184
|
+
if (inFlight.size > MAX_IN_FLIGHT) inFlight.delete(inFlight.keys().next().value);
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
noteResult(msg) {
|
|
188
|
+
if (!msg || msg.id === undefined || msg.id === null) return false;
|
|
189
|
+
const meta = inFlight.get(msg.id);
|
|
190
|
+
if (!meta) return false;
|
|
191
|
+
inFlight.delete(msg.id);
|
|
192
|
+
// An error answer is still recorded: the agent asked, which is the behaviour the gate exists to
|
|
193
|
+
// produce, and a name the server could not resolve must not leave the file locked.
|
|
194
|
+
const files = filesFromResultText(resultText(msg));
|
|
195
|
+
return appendRecord(logPathFor(cwd), { ts: meta.ts, tool: meta.tool, names: meta.names, files }, now());
|
|
196
|
+
},
|
|
197
|
+
clearAlive() {
|
|
198
|
+
try {
|
|
199
|
+
fs.rmSync(alivePathFor(cwd), { force: true });
|
|
200
|
+
return true;
|
|
201
|
+
} catch (_) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
touchAlive() {
|
|
206
|
+
const t = now();
|
|
207
|
+
if (t - lastAlive < ALIVE_TOUCH_MIN_MS) return false;
|
|
208
|
+
lastAlive = t;
|
|
209
|
+
try {
|
|
210
|
+
fs.mkdirSync(impactDir(), { recursive: true, mode: 0o700 });
|
|
211
|
+
fs.writeFileSync(alivePathFor(cwd), String(t), { mode: 0o600 });
|
|
212
|
+
return true;
|
|
213
|
+
} catch (_) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Remove the evidence files and the off switch. Used by `niro mcp uninstall`. Best effort. */
|
|
221
|
+
function purge() {
|
|
222
|
+
try {
|
|
223
|
+
fs.rmSync(impactDir(), { recursive: true, force: true });
|
|
224
|
+
} catch (_) {
|
|
225
|
+
/* best effort */
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
fs.rmSync(offSwitchPath(), { force: true });
|
|
229
|
+
} catch (_) {
|
|
230
|
+
/* best effort */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
purge,
|
|
236
|
+
IMPACT_TOOLS,
|
|
237
|
+
WINDOW_MS,
|
|
238
|
+
MAX_LINES,
|
|
239
|
+
keyForCwd,
|
|
240
|
+
impactDir,
|
|
241
|
+
offSwitchPath,
|
|
242
|
+
logPathFor,
|
|
243
|
+
alivePathFor,
|
|
244
|
+
isImpactCall,
|
|
245
|
+
namesFromArgs,
|
|
246
|
+
resultText,
|
|
247
|
+
filesFromResultText,
|
|
248
|
+
pruneLines,
|
|
249
|
+
appendRecord,
|
|
250
|
+
createImpactRecorder,
|
|
251
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Has this periodic job already run recently, and may I be the one to run it now?"
|
|
3
|
+
*
|
|
4
|
+
* Two problems, one answer.
|
|
5
|
+
*
|
|
6
|
+
* PM2 restarts the watch daemon on a crash, on a reboot, and on `niro service restart`. A job wired to
|
|
7
|
+
* a plain setInterval that also runs at startup therefore runs on every restart, and a crash loop turns
|
|
8
|
+
* a daily job into a hot loop. So the last run time has to OUTLIVE the process, which means a file.
|
|
9
|
+
*
|
|
10
|
+
* And two daemons can be up at once — the PM2 one plus a `niro watch` somebody left running in a
|
|
11
|
+
* terminal. Both would fire the same job at the same moment. For a job that only reads that is waste;
|
|
12
|
+
* for one that deletes on the server it is two processes racing over the same decision.
|
|
13
|
+
*
|
|
14
|
+
* A timestamp file answers both: whoever writes it first owns the interval, and everybody else sees a
|
|
15
|
+
* recent stamp and stands down. The write is atomic (tmp + rename, the same technique watchState uses)
|
|
16
|
+
* so a reader never sees a half-written file.
|
|
17
|
+
*
|
|
18
|
+
* NOT a mutex. Two processes calling claim() in the same millisecond can both win: each reads the old
|
|
19
|
+
* stamp before either writes. Closing that needs an O_EXCL lock file plus stale-lock recovery, which
|
|
20
|
+
* costs more than it saves here — the jobs this guards are idempotent (a second sweep finds the work
|
|
21
|
+
* already done) and the interval is hours, so the collision window is milliseconds wide once a day.
|
|
22
|
+
* The comment is here so nobody later mistakes this for a lock and puts something non-idempotent
|
|
23
|
+
* behind it.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const os = require("os");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
|
|
29
|
+
const DIR = path.join(os.homedir(), ".niro");
|
|
30
|
+
const FILE = path.join(DIR, "periodic.json");
|
|
31
|
+
|
|
32
|
+
function load() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(FILE, "utf8"));
|
|
35
|
+
// An ARRAY passes `typeof === "object"`, and JSON.stringify then drops the named property we set on
|
|
36
|
+
// it — so the stamp never persisted and the job claimed on EVERY tick, which is precisely the crash
|
|
37
|
+
// loop this module exists to prevent. Anything that is not a plain object starts again from empty.
|
|
38
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
39
|
+
return parsed;
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function save(state) {
|
|
46
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
47
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
48
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
49
|
+
fs.renameSync(tmp, FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** When `name` last ran, in epoch millis, or null if it never has (or the stamp is unreadable). */
|
|
53
|
+
/** Said once per process, not once per tick — a six-hourly job would otherwise fill the log. */
|
|
54
|
+
let warnedAboutWrite = false;
|
|
55
|
+
|
|
56
|
+
function lastRun(name) {
|
|
57
|
+
const at = load()[name];
|
|
58
|
+
const n = Number(at);
|
|
59
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Try to claim the current interval for `name`.
|
|
64
|
+
*
|
|
65
|
+
* Returns true and stamps the file when `intervalMs` has passed since the last run (or it has never
|
|
66
|
+
* run). Returns false and writes nothing otherwise.
|
|
67
|
+
*
|
|
68
|
+
* The stamp is written BEFORE the job runs, not after. A job that dies half way through must not be
|
|
69
|
+
* retried immediately on the next tick — that is the crash loop this exists to prevent — and these
|
|
70
|
+
* jobs are safe to skip for one interval. The cost of the choice is that a run interrupted at 1% waits
|
|
71
|
+
* a full interval to try again, which is the right trade for something that runs every few hours.
|
|
72
|
+
*
|
|
73
|
+
* A stamp in the FUTURE (a clock that jumped back, or a file copied between machines) would otherwise
|
|
74
|
+
* block the job until real time caught up, potentially for years. Treated as "never ran" instead.
|
|
75
|
+
*/
|
|
76
|
+
function claim(name, intervalMs, now = Date.now()) {
|
|
77
|
+
const last = lastRun(name);
|
|
78
|
+
if (last !== null && last <= now && now - last < intervalMs) return false;
|
|
79
|
+
const state = load();
|
|
80
|
+
state[name] = now;
|
|
81
|
+
try {
|
|
82
|
+
save(state);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Cannot persist the stamp (read-only home, a file left by a `sudo` run, a full disk). Refuse the
|
|
85
|
+
// claim rather than run: an unstampable job would run on EVERY tick, which is worse than not
|
|
86
|
+
// running at all.
|
|
87
|
+
//
|
|
88
|
+
// But SAY SO, once. Returning a quiet false disables the job for good, and the developer has no
|
|
89
|
+
// way to know: their folders simply stop being tidied and nothing ever mentions it.
|
|
90
|
+
if (!warnedAboutWrite) {
|
|
91
|
+
warnedAboutWrite = true;
|
|
92
|
+
console.warn(` Could not write ${FILE} (${err.message}). Periodic housekeeping is paused; ` +
|
|
93
|
+
"fix the permissions on ~/.niro to re-enable it.");
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { claim, lastRun, FILE };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a failed command is written to the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.js's `run` for one reason: it was dropping text the user needed, and there was
|
|
5
|
+
* no way to test that. index.js calls main() at load, so nothing could require it.
|
|
6
|
+
*
|
|
7
|
+
* The bug it exists to stop: the printer took `err.stack.split("\n")[0]`, which is the error's class
|
|
8
|
+
* name plus the FIRST LINE of its message. Any message built over several lines therefore printed its
|
|
9
|
+
* opening line and silently swallowed the rest. Two real cases, both of which end in the user staring
|
|
10
|
+
* at a sentence that promises a list and then shows nothing:
|
|
11
|
+
*
|
|
12
|
+
* "Multiple projects match this repo — pass --project:" ...and then no projects.
|
|
13
|
+
* "You already have 5 working copies..." ...and then no folders to go and look in.
|
|
14
|
+
*
|
|
15
|
+
* Nothing was broken in either feature. The text existed, was correct, and never reached the screen.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Print `err` the way the CLI always has, plus the lines that were being lost.
|
|
20
|
+
*
|
|
21
|
+
* The first line still comes from the stack, so an error's real class name survives (a TypeError says
|
|
22
|
+
* TypeError, not Error). Subsequent lines come from the MESSAGE, never the stack, because a stack trace
|
|
23
|
+
* is noise to somebody running a CLI and this repo deliberately does not print them.
|
|
24
|
+
*
|
|
25
|
+
* A single-line message prints exactly as it did before, byte for byte.
|
|
26
|
+
*
|
|
27
|
+
* @param {*} err the thrown value; need not be an Error
|
|
28
|
+
* @param {(...args: any[]) => void} write where to send each line (console.error by default)
|
|
29
|
+
*/
|
|
30
|
+
function printError(err, write = console.error) {
|
|
31
|
+
// With a stack, its first line already carries the class name and the message's first line, so only
|
|
32
|
+
// the REMAINING message lines are added. Without one — anything thrown that is not an Error — there
|
|
33
|
+
// is no such line, so the whole message is printed rather than silently starting at line two.
|
|
34
|
+
const hasStack = Boolean(err && err.stack);
|
|
35
|
+
const first = hasStack ? String(err.stack).split("\n")[0] : String(err);
|
|
36
|
+
write(first);
|
|
37
|
+
// Only lines the FIRST line did not already carry. With a stack that is everything after line one.
|
|
38
|
+
// Without one, String(err) may already be the whole message (an Error whose stack was stripped
|
|
39
|
+
// stringifies to "Error: <the lot>"), so printing from line one again said it all twice.
|
|
40
|
+
const messageLines = err && typeof err.message === "string" ? err.message.split("\n") : [];
|
|
41
|
+
const rest = hasStack ? messageLines.slice(1) : messageLines.filter((l) => !first.includes(l));
|
|
42
|
+
for (const line of rest) write(line);
|
|
43
|
+
if (err && err.status) write(` (HTTP ${err.status})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { printError };
|
package/src/lib/receiptStore.js
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undoing a takeover locally: point a folder's registration back at the source project.
|
|
3
|
+
*
|
|
4
|
+
* This is the LOCAL half of discarding a temporary project. The server half (deleting the copy) is
|
|
5
|
+
* `niro discard-temp-project`; the sweep that finds copies already gone from the server is
|
|
6
|
+
* `niro cleanup-temp-projects`. Both end in exactly this loop, so it lives here rather than in
|
|
7
|
+
* either command. Copying it would be the worst option available: the two comments below record
|
|
8
|
+
* incidents that cost silent data loss, and a second copy is a second place for that reasoning to
|
|
9
|
+
* rot out of step with the first.
|
|
10
|
+
*
|
|
11
|
+
* It never contacts the server and it never deletes a registration. A folder whose temporary project
|
|
12
|
+
* is gone still needs to be synced to SOMETHING, so it is moved back to the source project rather
|
|
13
|
+
* than unregistered.
|
|
14
|
+
*/
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
|
|
18
|
+
const niroProjectFile = require("./niroProjectFile");
|
|
19
|
+
const watchState = require("./watchState");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Order a group so that folders carrying a takeover backup are written LAST.
|
|
23
|
+
*
|
|
24
|
+
* The backup is the only thing on this machine that says "this project id is a temporary project".
|
|
25
|
+
* Restoring a folder spends its backup, so if the one folder holding the group's backup is written
|
|
26
|
+
* first and the run then stops (Ctrl-C, a closed lid, a full disk), the rest of the group becomes
|
|
27
|
+
* invisible: `niro cleanup-temp-projects` no longer recognises the id as a temporary project and
|
|
28
|
+
* reports "nothing to clean up", while `niro discard-temp-project` refuses because no folder records
|
|
29
|
+
* what the copy was made from. The only repair left is editing the JSON by hand.
|
|
30
|
+
*
|
|
31
|
+
* Writing the witnesses last means an interrupted run always leaves at least one behind, so the next
|
|
32
|
+
* run finds the group and finishes the job. Order within each half is untouched.
|
|
33
|
+
*/
|
|
34
|
+
function witnessesLast(folders) {
|
|
35
|
+
const withoutBackup = folders.filter((e) => !(e.takeover && e.takeover.sourceProjectId));
|
|
36
|
+
const withBackup = folders.filter((e) => e.takeover && e.takeover.sourceProjectId);
|
|
37
|
+
return [...withoutBackup, ...withBackup];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Move every folder in `folders` off `tempProjectId` and back onto its source project.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array<object>} folders watchState entries (each with `folderPath`), all registered
|
|
44
|
+
* against the same temporary project.
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {string} opts.tempProjectId the temporary project being undone.
|
|
47
|
+
* @param {string} opts.cloneOf the source project id, used for any folder whose own
|
|
48
|
+
* takeover backup does not name one.
|
|
49
|
+
* @param {string} opts.baseUrl backend to tag an entry with when it carries no apiUrl.
|
|
50
|
+
* @param {boolean} opts.healPins write/delete an old-style swapped `.niro-project`. TRUE for
|
|
51
|
+
* an interactive discard, FALSE for the unattended sweep — it
|
|
52
|
+
* touches a git-tracked file in the developer's repo.
|
|
53
|
+
* @returns {{ repinned: string[], healed: Array<{folder: string, to: string}>,
|
|
54
|
+
* failed: Array<{folder: string, error: string}>,
|
|
55
|
+
* skipped: Array<{folder: string, nowGitId: string|null}>,
|
|
56
|
+
* untracked: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
function restoreFolders(folders, { tempProjectId, cloneOf, baseUrl, healPins = true } = {}) {
|
|
59
|
+
// ADR-018: the .niro-project file is left ALONE unless it still carries the OLD-STYLE swapped pin
|
|
60
|
+
// (pre-ADR-018 takeover) — then it points at a now-deleted project and must be healed: previousPin
|
|
61
|
+
// verbatim when recorded; explicit "no pin existed" → delete the file; otherwise the source project
|
|
62
|
+
// id. New-style folders (pin untouched) skip all of this.
|
|
63
|
+
const repinned = [];
|
|
64
|
+
const healed = [];
|
|
65
|
+
const failed = [];
|
|
66
|
+
const skipped = [];
|
|
67
|
+
const untracked = [];
|
|
68
|
+
for (const e of witnessesLast(folders)) {
|
|
69
|
+
try {
|
|
70
|
+
const backup = e.takeover || {};
|
|
71
|
+
const sourceProjectId = backup.sourceProjectId || cloneOf;
|
|
72
|
+
const entryDir = path.resolve(e.folderPath);
|
|
73
|
+
|
|
74
|
+
// Compare-and-write, exactly as watchState.remove does its compare-and-delete, and for the
|
|
75
|
+
// reason its own comment gives: a folder can be re-pointed by ANOTHER PROCESS between the read
|
|
76
|
+
// that produced `e` and this write. The sweep makes that window wide, because it reads the whole
|
|
77
|
+
// registry once and then does a server round-trip per temporary project before writing anything.
|
|
78
|
+
//
|
|
79
|
+
// What it costs to get wrong is not a stale row. A `niro new-temp-project` landing mid-sweep
|
|
80
|
+
// would be overwritten: its brand new copy is then named by no entry at all, so discard cannot
|
|
81
|
+
// find it and a later sweep cannot see it, AND the folder is left pointing at the SHARED source
|
|
82
|
+
// project's repo with an empty baseline — so the next daemon pass uploads the private
|
|
83
|
+
// feature-branch tree into the project the whole team reads.
|
|
84
|
+
//
|
|
85
|
+
// A missing entry is deliberately NOT a mismatch. `niro discard-temp-project --project <id>` can
|
|
86
|
+
// be run in a folder that has no registration yet, and creating one there is existing, intended
|
|
87
|
+
// behaviour. Only a DIFFERENT gitId means somebody re-pointed this folder under us.
|
|
88
|
+
const current = watchState.peek(e.folderPath);
|
|
89
|
+
if (current && current.gitId !== e.gitId) {
|
|
90
|
+
skipped.push({ folder: e.folderPath, nowGitId: current.gitId || null });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// healPins is FALSE for the unattended sweep, and that is the whole point of the option. This
|
|
95
|
+
// block writes and DELETES `.niro-project`, a file tracked in the developer's git repository. It
|
|
96
|
+
// is right for `niro discard-temp-project`, which a person just ran in that folder; it is not
|
|
97
|
+
// something a six-hourly background job should do. A fourth review found the sweep reaching it —
|
|
98
|
+
// through this module — while a test and four documents claimed pins were never touched.
|
|
99
|
+
if (healPins && fs.existsSync(entryDir)) {
|
|
100
|
+
const pin = niroProjectFile.read(entryDir);
|
|
101
|
+
const pinIsSwapped = pin && pin.id === tempProjectId
|
|
102
|
+
&& path.dirname(pin.source) === entryDir; // only heal THIS folder's own pin, never a parent's
|
|
103
|
+
if (pinIsSwapped) {
|
|
104
|
+
const recordedNoPin = ("previousPin" in backup) && backup.previousPin === null;
|
|
105
|
+
if (backup.previousPin) {
|
|
106
|
+
niroProjectFile.write(entryDir, backup.previousPin, { overwrite: true });
|
|
107
|
+
healed.push({ folder: e.folderPath, to: backup.previousPin });
|
|
108
|
+
} else if (recordedNoPin) {
|
|
109
|
+
niroProjectFile.remove(entryDir);
|
|
110
|
+
healed.push({ folder: e.folderPath, to: "(no pin — removed)" });
|
|
111
|
+
} else {
|
|
112
|
+
niroProjectFile.write(entryDir, sourceProjectId, { overwrite: true });
|
|
113
|
+
healed.push({ folder: e.folderPath, to: sourceProjectId });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Watch/daemon registration goes back to the source project regardless of pin style.
|
|
119
|
+
watchState.set(e.folderPath, {
|
|
120
|
+
...e,
|
|
121
|
+
gitId: backup.previousGitId || null,
|
|
122
|
+
projectId: sourceProjectId,
|
|
123
|
+
// The baseline describes what the TEMPORARY project holds, so it MUST NOT survive the move
|
|
124
|
+
// back to the source repo. Carrying it across told the daemon the source already had every
|
|
125
|
+
// file at its sandbox-era fingerprint — which it did not, because those edits only ever went
|
|
126
|
+
// to the temporary copy. The next pass then compared disk against that baseline, found no
|
|
127
|
+
// difference, and uploaded nothing. Every edit made during the temporary project was silently
|
|
128
|
+
// lost to the source project, with no error and nothing to prompt a re-sync.
|
|
129
|
+
//
|
|
130
|
+
// Clearing it makes the next pass treat every file as new and upload it. That is a full
|
|
131
|
+
// re-upload once, which is the cost of being sure: the client cannot know which files the
|
|
132
|
+
// source repo is missing, so the only safe assumption is all of them.
|
|
133
|
+
//
|
|
134
|
+
// The opposite direction already gets this right — takeover.js seeds fileHashes with what it
|
|
135
|
+
// actually uploaded to the new gitId. A baseline belongs to ONE gitId; when that changes, so
|
|
136
|
+
// must it.
|
|
137
|
+
fileHashes: {},
|
|
138
|
+
lastSyncedAt: null,
|
|
139
|
+
takeover: undefined,
|
|
140
|
+
}, e.apiUrl || baseUrl);
|
|
141
|
+
repinned.push(e.folderPath);
|
|
142
|
+
// No repo id to go back to means this folder had no local registration BEFORE it was taken over
|
|
143
|
+
// (takeover.js records `previousGitId: existing ? existing.gitId : null`). Restoring null is
|
|
144
|
+
// therefore correct — it is the state the folder was in before — but the entry is now inert:
|
|
145
|
+
// watchState.allLocal filters on gitId, so the daemon stops listing it. Callers report these
|
|
146
|
+
// separately rather than counting them as "repaired and syncing", because the honest sentence is
|
|
147
|
+
// "back where it was, and not locally tracked", not "fixed".
|
|
148
|
+
if (!backup.previousGitId) untracked.push(e.folderPath);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
failed.push({ folder: e.folderPath, error: err.message });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { repinned, healed, failed, skipped, untracked };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { restoreFolders, witnessesLast };
|
package/src/mcp/server.js
CHANGED
|
@@ -52,6 +52,12 @@ function niroProjectsFilePath() {
|
|
|
52
52
|
return path.join(home, ".niro", "projects.json");
|
|
53
53
|
}
|
|
54
54
|
const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
|
|
55
|
+
const { createImpactRecorder } = require("../lib/impactLog");
|
|
56
|
+
|
|
57
|
+
// Evidence for the Edit|Write impact gate (docs/usecases/impact-gate.md): which impact tools this
|
|
58
|
+
// session called, and that the server is answering. Keyed by this process's working directory, which
|
|
59
|
+
// is the session folder the hook sees as `cwd` (checked on 8 live bridges, 2026-09-04).
|
|
60
|
+
const impact = createImpactRecorder({ cwd: process.cwd() });
|
|
55
61
|
|
|
56
62
|
/**
|
|
57
63
|
* This package's version, sent as X-Niro-Cli-Version so the server can tell an out-of-date install to
|
|
@@ -150,17 +156,26 @@ function resolveProjectIdFromNiroProjects() {
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
/**
|
|
153
|
-
* Step 3: Detect the git
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
159
|
+
* Step 3: Detect the current git branch, HEAD and dirty state — plus the origin remote when there is
|
|
160
|
+
* one — for the freshness/takeover notices, server-side project resolution, and per-branch temp routing.
|
|
161
|
+
* The gate is HEAD (is this a git repo with a commit?), NOT origin: a local repo with commits but no
|
|
162
|
+
* origin remote (a `local://` niro project, or a repo not yet pushed) still has a branch that drives
|
|
163
|
+
* routing, so its git state must be reported with a null remote rather than dropped entirely.
|
|
164
|
+
* Returns { remote (nullable), branch, commit, dirty } or null if not a git repo / no commit / git unavailable.
|
|
157
165
|
*/
|
|
158
166
|
function detectGitContext(dir) {
|
|
159
167
|
try {
|
|
160
168
|
const opts = { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
|
|
161
169
|
if (dir) opts.cwd = dir;
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
// HEAD first: this is the "real git repo with a commit?" test. The old `if (!remote) return null`
|
|
171
|
+
// bailed on a MISSING ORIGIN and so discarded the branch + commit of every no-origin repo — those
|
|
172
|
+
// repos then sent no _niroGit and never routed to their temp project. The owned-temp match needs
|
|
173
|
+
// only the branch, not the remote, so keep going and let the remote be null.
|
|
174
|
+
let commit = null;
|
|
175
|
+
try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
|
|
176
|
+
if (!commit) return null; // not a git repo, or a repo with no commit yet — nothing to report
|
|
177
|
+
let remote = null;
|
|
178
|
+
try { remote = execSync("git remote get-url origin 2>/dev/null", opts).trim() || null; } catch (_) {}
|
|
164
179
|
// normalizeBranch, not a bare trim: on a detached HEAD this command PRINTS "HEAD" instead of
|
|
165
180
|
// failing, and sending that verbatim told the server the checkout was on a branch called "HEAD".
|
|
166
181
|
// Nothing indexes such a branch, so the routing ladder read every bisect / tag checkout as
|
|
@@ -168,10 +183,6 @@ function detectGitContext(dir) {
|
|
|
168
183
|
// a null branch. `git.inspect` has always collapsed "HEAD" to null; this shares that one rule.
|
|
169
184
|
let branch = null;
|
|
170
185
|
try { branch = normalizeBranch(execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)); } catch (_) {}
|
|
171
|
-
// The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
|
|
172
|
-
// the bridge must report them — without git_commit the server-side check no-ops entirely.
|
|
173
|
-
let commit = null;
|
|
174
|
-
try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
|
|
175
186
|
let dirty = false;
|
|
176
187
|
try { dirty = execSync("git status --porcelain 2>/dev/null", opts).trim().length > 0; } catch (_) {}
|
|
177
188
|
return { remote, branch, commit, dirty };
|
|
@@ -535,6 +546,8 @@ function connectSse() {
|
|
|
535
546
|
} else {
|
|
536
547
|
// Cancel the client-side deadline when a delivered result matches an in-flight request.
|
|
537
548
|
pending.onIncoming(msg);
|
|
549
|
+
impact.noteResult(msg);
|
|
550
|
+
impact.touchAlive();
|
|
538
551
|
rewritten = captureReceipt(msg);
|
|
539
552
|
}
|
|
540
553
|
} catch (_) { /* not correlatable JSON — forward unchanged */ }
|
|
@@ -827,6 +840,7 @@ async function main() {
|
|
|
827
840
|
// Remember the host's initialize so we can replay it verbatim to re-authenticate a fresh
|
|
828
841
|
// session after a transparent reconnect.
|
|
829
842
|
if (message.method === "initialize") initializeRaw = line;
|
|
843
|
+
impact.noteRequest(message);
|
|
830
844
|
|
|
831
845
|
// Arm the deadline for a real request before sending; a delivered result (or the
|
|
832
846
|
// send-failure path below) cancels it.
|
|
@@ -836,6 +850,9 @@ async function main() {
|
|
|
836
850
|
await sendMessage(message);
|
|
837
851
|
} catch (err) {
|
|
838
852
|
process.stderr.write(`[niro-mcp] Failed to send message: ${err.message}\n`);
|
|
853
|
+
// The attempt still counts for the impact gate: a server that cannot be reached must not leave
|
|
854
|
+
// the file locked until the alive marker ages out (review finding, 2026-09-05).
|
|
855
|
+
impact.noteResult({ jsonrpc: "2.0", id: message.id, error: { code: -32001, message: err.message } });
|
|
839
856
|
// The POST never reached the server, so no result will arrive: fail this request fast
|
|
840
857
|
// instead of waiting out the deadline.
|
|
841
858
|
if (trackable) {
|
|
@@ -852,6 +869,9 @@ async function main() {
|
|
|
852
869
|
rl.on("close", () => {
|
|
853
870
|
process.exit(0);
|
|
854
871
|
});
|
|
872
|
+
// A bridge that is gone must not keep the impact gate denying edits for up to 30 minutes on the
|
|
873
|
+
// strength of a stale alive marker. A hard kill still leaves it; the hook ages it out.
|
|
874
|
+
process.on("exit", () => impact.clearAlive());
|
|
855
875
|
}
|
|
856
876
|
|
|
857
877
|
main();
|