@misaka-net/misakanet-setup 0.5.4 → 0.5.5

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.
@@ -25,11 +25,19 @@
25
25
  * Everything is idempotent, every rewritten file is backed up to *.misakanet.bak, and
26
26
  * --uninstall removes exactly what was added (same markers), leaving user config intact.
27
27
  */
28
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, chmodSync, statSync } from 'node:fs';
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync,
29
+ chmodSync, statSync, renameSync, accessSync, constants,
30
+ openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
29
31
  // No child_process import on purpose: this installer must never hand a file-derived value to
30
32
  // another program (the plugin scanner's SHELL_INJECTION_PATTERN, alert #269, and argv secrets
31
33
  // are visible to every process on the box). Every target is configured by writing its own
32
34
  // config file; commands aimed at the user are printed, never executed.
35
+ // `randomUUID` from `node:crypto`, not the global `crypto`: the global Web Crypto object only
36
+ // became available without a flag in Node 19, so on Node 18 (which `engines` supports) any
37
+ // registration crashed the whole run with `crypto is not defined` and exit code 2. Found
38
+ // 2026-09-18 by the first Node 18 leg of misakanet-setup-ci.yml — the promise was in
39
+ // package.json since 0.4 and had never been executed on the version it names.
40
+ import { randomUUID } from 'node:crypto';
33
41
  import { homedir } from 'node:os';
34
42
  import { delimiter, join, dirname, resolve } from 'node:path';
35
43
  import { fileURLToPath } from 'node:url';
@@ -120,7 +128,71 @@ function readText(path) {
120
128
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
121
129
  * manifest — a failing test is a better place for that than a request header.
122
130
  */
123
- const VERSION = '0.5.4'
131
+ const VERSION = '0.5.5'
132
+
133
+ // ── argument surface ─────────────────────────────────────────────────
134
+ // Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
135
+ // (reproduced on a clean HOME — it rewrote the user's CLAUDE.md and settings.json), and an
136
+ // unknown flag was silently ignored. `--help` is the first thing a cautious person types, so
137
+ // "looking before you leap" was the action with the worst consequences.
138
+ const FLAGS = [
139
+ ['--home <dir>', '把配置写到这个目录,而不是真实家目录(不是沙箱:--uninstall 也认这个目录)'],
140
+ ['--only <a,b>', `只处理列出的助手(${AGENTS.join(' / ')})`],
141
+ ['--dry-run', '只说不做:打印会改哪些文件,不写任何东西'],
142
+ ['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
143
+ ['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
144
+ ['--report-json', '同一份报告的 JSON 编码'],
145
+ ['--strict', '配合 --report:NOT READY 时退出码 1(默认仍退 0)'],
146
+ ['--ci', '= --report --strict'],
147
+ ['--verify', '自检:端点可达、钩子、MCP 注册、版本新旧'],
148
+ ['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
149
+ ['--upgrade', '与安装等价(覆盖安装即升级)'],
150
+ ['--client-id <id>', '固定本机身份:同一个 id 永远拿回同一个 node(重装/换机也能延续)'],
151
+ ['--no-register', '不注册匿名节点(不写 token,检索仍是 5 次/天/IP)'],
152
+ ['--voice', '打开语音/桌面通知(默认关)'],
153
+ ['--help, -h', '打印这份帮助并退出'],
154
+ ['--version', '打印版本并退出'],
155
+ ];
156
+ const VALUE_FLAGS = ['--home', '--only', '--client-id'];
157
+
158
+ function printHelp() {
159
+ console.log(`MisakaNet 安装程序 ${VERSION}
160
+ 用法:npx @misaka-net/misakanet-setup [选项]
161
+
162
+ ${FLAGS.map(([flag, desc]) => ` ${flag.padEnd(16)} ${desc}`).join('\n')}
163
+
164
+ 退出码(安装模式):0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。
165
+ 健康检查用 --verify 或 --report --strict,不要看安装模式的退出码。`);
166
+ }
167
+
168
+ if (has('--help') || has('-h')) {
169
+ printHelp();
170
+ process.exit(0);
171
+ }
172
+ if (has('--version')) {
173
+ console.log(VERSION);
174
+ process.exit(0);
175
+ }
176
+
177
+ const KNOWN = new Set(FLAGS.flatMap(([flag]) => flag.split(/[,\s]/).filter((f) => f.startsWith('-'))));
178
+ {
179
+ const unknown = args.filter((a) => a.startsWith('-') && !KNOWN.has(a));
180
+ if (unknown.length) {
181
+ console.error(`无法识别的选项:${unknown.join(' ')}`
182
+ + `\n(本安装器不认识它,为避免误解你的意图,这里直接停下、什么都没写。--help 看全部选项。)`);
183
+ process.exit(2);
184
+ }
185
+ // A value flag whose value is missing or is itself a flag used to be accepted: `--home --voice`
186
+ // resolved HOME to `$(pwd)/--voice` and installed for real into a directory named `--voice`.
187
+ for (const flag of VALUE_FLAGS) {
188
+ const i = args.indexOf(flag);
189
+ if (i >= 0 && (!args[i + 1] || args[i + 1].startsWith('-'))) {
190
+ console.error(`${flag} 需要一个值(例如 ${flag} /tmp/home),现在后面跟的是 `
191
+ + `“${args[i + 1] ?? '空'}”——已停下,什么都没写。`);
192
+ process.exit(2);
193
+ }
194
+ }
195
+ }
124
196
 
125
197
  function backup(path) {
126
198
  if (DRY || !readText(path)) return;
@@ -148,10 +220,161 @@ function sameJson(a, b) {
148
220
  return canonical(a) === canonical(b);
149
221
  }
150
222
 
151
- function writeText(path, text) {
223
+ /**
224
+ * Write through a sibling temp file and rename it into place.
225
+ *
226
+ * A direct `writeFileSync` is not atomic: if the process dies (SIGINT, OOM, full disk) between
227
+ * truncating and finishing, the target is left half-written — and these targets are the user's
228
+ * `~/.claude.json`, `settings.json` and rule files, where "half-written" can mean an assistant that
229
+ * will not start. Proven, not assumed: after a direct write the file keeps its inode across a
230
+ * rewrite; after a `rename` it changes (pinned in workers/misakanet-setup.test.mjs).
231
+ *
232
+ * Two details that matter:
233
+ * * `mode` is applied at creation, so a secret is never momentarily world-readable (the token and
234
+ * the Hermes `.env` used to be written and then `chmod 600`-ed — a window on every run);
235
+ * * an existing file that is not writable is still refused. `rename` would happily replace a
236
+ * read-only file (it only needs write permission on the *directory*), so the previous EACCES
237
+ * behaviour is kept explicitly rather than silently lost.
238
+ */
239
+ function writeText(path, text, { mode } = {}) {
152
240
  if (DRY) return;
153
241
  mkdirSync(dirname(path), { recursive: true });
154
- writeFileSync(path, text);
242
+ if (existsSync(path)) {
243
+ try {
244
+ accessSync(path, constants.W_OK);
245
+ } catch {
246
+ const error = new Error(`EACCES: permission denied, open '${path}'`);
247
+ error.code = 'EACCES';
248
+ throw error;
249
+ }
250
+ }
251
+ const tmp = `${path}.misakanet-tmp`;
252
+ try {
253
+ // fsync, not just rename: `rename` guarantees the reader sees the old file or the new one, but
254
+ // it does not guarantee the *contents* reached the disk — a power loss after the rename can
255
+ // still leave an empty file. For an assistant config that is the difference between "the tool
256
+ // did not install" and "my assistant will not start". `writeFileSync` does not expose the
257
+ // descriptor, hence the explicit open/write/fsync/close.
258
+ const fd = openSync(tmp, 'w', mode ?? 0o666);
259
+ try {
260
+ writeSync(fd, text);
261
+ fsyncSync(fd);
262
+ } finally {
263
+ closeSync(fd);
264
+ }
265
+ renameSync(tmp, path);
266
+ } catch (err) {
267
+ // Windows can refuse to replace an existing file with `rename` (EPERM when another process has
268
+ // the file open, EEXIST in some states). The old fallback was a direct `writeFileSync(path, …)`
269
+ // — which is the non-atomic write this function exists to avoid, and which truncates the user's
270
+ // config if the same lock is still held. Clearing the destination and retrying the rename keeps
271
+ // the replacement atomic, and when the file really is locked both attempts fail, so the caller
272
+ // reports "写入配置失败" instead of quietly writing half a config.
273
+ //
274
+ // (`writeFileSync` here also read as "write after a check on the same path" to CodeQL
275
+ // js/file-system-race — alert #272, the shape the fix above is about.)
276
+ if (err && (err.code === 'EPERM' || err.code === 'EEXIST')) {
277
+ rmSync(path, { force: true });
278
+ renameSync(tmp, path);
279
+ return;
280
+ }
281
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * The files each assistant's install writes, so a permission problem can be reported *before*
288
+ * anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
289
+ * this"). It mirrors what the per-agent install functions write; those keep their own try/catch as
290
+ * the safety net, because a mirror can fall out of date and the real error is still the truth.
291
+ */
292
+ const AGENT_WRITE_PATHS = {
293
+ claude: (home) => [join(home, '.claude.json'), join(home, '.claude', 'settings.json'),
294
+ join(home, '.claude', 'CLAUDE.md')],
295
+ codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
296
+ hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
297
+ codewhale: (home) => [join(home, '.codewhale', 'mcp.json')],
298
+ openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
299
+ };
300
+
301
+ /**
302
+ * Which of these paths this process may not write.
303
+ *
304
+ * Reported *before* anything is written, so a user without permission gets one clear list instead
305
+ * of a half-finished install. `--dry-run` uses the same list, which is what makes a dry run honest:
306
+ * it says "I would change this" and "I cannot change that" in the same breath.
307
+ */
308
+ function unwritableTargets(paths) {
309
+ const blocked = [];
310
+ for (const path of paths) {
311
+ if (!canCreateHere(path)) blocked.push(path);
312
+ }
313
+ return blocked;
314
+ }
315
+
316
+ /**
317
+ * May this process create or overwrite `path`?
318
+ *
319
+ * Asked by *attempting* the access and reading the error, never by `existsSync(path)` first: a
320
+ * check-then-use pair is a race — the file can appear or disappear between the two calls, so the
321
+ * answer can describe a world that is already gone — and CodeQL reports exactly that shape as
322
+ * `js/file-system-race` (alerts #272/#273 on this file and its test, 2026-09-18). The attempt
323
+ * itself is also the *only* question with one answer: `access(W_OK)` on a file that does not exist
324
+ * yet says ENOENT, which is not "unwritable".
325
+ *
326
+ * When the file does not exist yet the question belongs to the nearest ancestor that does, because
327
+ * the install creates the missing parents (`mkdirSync(..., {recursive: true})`). The walk-up is
328
+ * what makes `--dry-run` honest on a first install: an earlier version asked about `dirname(path)`
329
+ * exactly once, so a home whose `~/.hermes` did not exist yet was reported "改不了" even though the
330
+ * installer was about to create that directory.
331
+ */
332
+ function canCreateHere(path) {
333
+ for (let target = path; ;) {
334
+ try {
335
+ accessSync(target, constants.W_OK);
336
+ return true;
337
+ } catch (err) {
338
+ if (!err || err.code !== 'ENOENT') return false; // EACCES/EROFS/EPERM/…
339
+ const parent = dirname(target);
340
+ if (parent === target) return false; // walked up to the root: nothing exists
341
+ target = parent;
342
+ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Is this settings.json hook entry one *this installer* wrote?
348
+ *
349
+ * Every command we install points into our own namespace directory
350
+ * (`<home>/.misakanet-agent/hook.mjs`, and `.../voice/voice-hook.mjs`), so that directory name
351
+ * is the identity marker. Matching the *filename* instead (`…includes('hook.mjs')`) treated a
352
+ * user's own `my-hook.mjs` as ours: `--uninstall` deleted the user's hook — reproduced
353
+ * 2026-09-17 — while `packages/misakanet-setup/README.md` promises "leaves your own hooks
354
+ * alone". The same loose test also made install skip adding our hook (believing it was
355
+ * already there) and made `--verify` report a foreign hook as ours.
356
+ *
357
+ * `checkpoint_reminder.mjs` is the legacy 0.4.x shape, from before the hook was copied into
358
+ * the state dir — our file name, not a generic one.
359
+ */
360
+ function isOurHookEntry(entry) {
361
+ const commands = (entry?.hooks || [])
362
+ .map((h) => h?.command)
363
+ .filter((c) => typeof c === 'string');
364
+ // Namespace, not the absolute path: an entry written by an older install under a
365
+ // *different* HOME still points at `<something>/.misakanet-agent/hook.mjs`, and the 0.4.2
366
+ // upgrade path has to recognise exactly that (a fixture with /home/u covers it). Matching
367
+ // the directory name also survives Windows separators. `checkpoint_reminder.mjs` is the
368
+ // legacy shape from before the hook was copied into the state dir — our file name, not a
369
+ // generic one.
370
+ return commands.some(
371
+ (c) => c.includes('.misakanet-agent') || c.includes('checkpoint_reminder.mjs'),
372
+ );
373
+ }
374
+
375
+ /** Hook entries we wrote, across every event in a settings.json `hooks` object. */
376
+ function ourHookEntries(hooks) {
377
+ return Object.values(hooks || {}).flat().filter(isOurHookEntry);
155
378
  }
156
379
 
157
380
  /** Insert or refresh a marker-delimited block. Uses a function replacement (no $-escapes). */
@@ -175,7 +398,10 @@ function injectBlock(path, block) {
175
398
  function stripBlock(path) {
176
399
  const text = readText(path);
177
400
  if (!text) return false;
178
- const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
401
+ // `\n?` on both sides: install writes a blank line, then the block, so consuming only the
402
+ // block left the file one newline longer than it started (uninstall claimed "已恢复原状"
403
+ // while `diff` showed an extra empty line).
404
+ const pattern = new RegExp(`\\n?[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
179
405
  if (!pattern.test(text)) return false;
180
406
  backup(path);
181
407
  const stripped = text.replace(pattern, '');
@@ -456,13 +682,16 @@ async function ensureIdentity() {
456
682
  // what it looks like, and it is right — the value may be ours, but the shape is the bug.
457
683
  // An exported MISAKANET_CLIENT_ID is explicit intent (the same standard the hook applies to
458
684
  // its token); otherwise this run mints one and prints it so the user can keep it.
459
- const exported = (process.env.MISAKANET_CLIENT_ID || '').trim();
685
+ // `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
686
+ // the closing guidance told users to keep an identity and the only way to supply one was a shell
687
+ // export — an instruction a non-technical user cannot follow, and on Windows a different one.
688
+ const exported = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
460
689
  if (exported && !/^[A-Za-z0-9._-]{8,64}$/.test(exported)) {
461
- need('ignored MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
690
+ need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
462
691
  }
463
692
  const clientId = (exported && /^[A-Za-z0-9._-]{8,64}$/.test(exported))
464
693
  ? exported
465
- : `setup-${crypto.randomUUID()}`;
694
+ : `setup-${randomUUID()}`;
466
695
  const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
467
696
  // Validate before persisting: a response body is not something to write to disk unchecked
468
697
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
@@ -470,18 +699,24 @@ async function ensureIdentity() {
470
699
  // a visible, debuggable outcome instead of a silent 401 later.
471
700
  const token = typeof result?.token === 'string' ? result.token.trim() : '';
472
701
  if (!/^mcp_[A-Za-z0-9_-]{20,}$/.test(token)) {
473
- need('注册没成功或返回的凭据形状不对(可能离线)→ 读课程不受影响;想要写入类工具时重跑本命令');
702
+ // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon, and
703
+ // "读课程不受影响" was misleading — without a token the read path is capped at the anonymous
704
+ // quota. Someone who does not know that will hit a wall five queries later and blame the tool.
705
+ need('匿名注册没成功(多半是网络)→ 现在检索限额是每天 5 次/IP,写入类工具也用不了;'
706
+ + '网络恢复后重跑本命令就能拿到 token,其它功能不受影响');
474
707
  return '';
475
708
  }
476
- writeFileSync(file, token);
477
- try {
478
- chmodSync(file, 0o600);
479
- } catch { /* windows */ }
709
+ // Created with mode 0600 rather than written and then chmodded: the old order left a window in
710
+ // which the token sat in a world-readable file on every single run.
711
+ writeText(file, token, { mode: 0o600 });
480
712
  ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
481
713
  if (!exported) {
482
714
  // Printed, not stored: this process must not turn a file it found into request data
483
715
  // (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
484
- ok(`想在这个节点上继续累积(重装/换机后仍是同一个):export MISAKANET_CLIENT_ID=${clientId}`);
716
+ // Kept out of the "done" list on purpose — it is something to *keep*, not something that
717
+ // happened, and a bare `export …` line reads as a chore to a non-technical user. It is shown
718
+ // with the closing guidance instead, in plain words.
719
+ clientIdHint = clientId;
485
720
  }
486
721
  return token;
487
722
  }
@@ -545,7 +780,7 @@ async function installClaude(hookPath, bearer) {
545
780
 
546
781
  for (const [event, command] of Object.entries(wanted)) {
547
782
  const bucket = settings.hooks[event] || [];
548
- if (JSON.stringify(bucket).includes('hook.mjs') || JSON.stringify(bucket).includes('checkpoint_reminder')) continue;
783
+ if (bucket.some(isOurHookEntry)) continue;
549
784
  bucket.push({ hooks: [{ type: 'command', command }] });
550
785
  settings.hooks[event] = bucket;
551
786
  changed = true;
@@ -576,13 +811,13 @@ async function installClaude(hookPath, bearer) {
576
811
  // matcher-less entry of ours as stale rather than "already installed", or re-running the
577
812
  // installer would leave a hook that stays silent forever (the exact failure this release
578
813
  // is about).
579
- const stale = post.filter((e) => JSON.stringify(e).includes('voice-hook') && e.matcher !== '*');
814
+ const stale = post.filter((e) => isOurHookEntry(e) && e.matcher !== '*');
580
815
  if (stale.length) {
581
816
  post = post.filter((e) => !stale.includes(e));
582
817
  settings.hooks.PostToolUse = post;
583
818
  changed = true;
584
819
  }
585
- if (!JSON.stringify(post).includes('voice-hook')) {
820
+ if (!post.some(isOurHookEntry)) {
586
821
  // `matcher: '*'` is load-bearing, not cosmetic: measured on 2026-09-16, a PostToolUse
587
822
  // entry **without** a matcher never fired in this host, while the same command with
588
823
  // `matcher: '*'` fired on every tool call. The hook itself filters by the `voice`
@@ -845,10 +1080,9 @@ async function installHermes(hookPath, bearer) {
845
1080
  : `${envText}${envText && !envText.endsWith('\n') ? '\n' : ''}${assignment}\n`;
846
1081
  if (next !== envText) {
847
1082
  backup(envPath);
848
- writeText(envPath, next);
849
- // The standalone token file is chmod 600 two hundred lines up; leaving the token in a
850
- // world-readable .env (default umask is usually 0644) is the same leak through another door.
851
- try { chmodSync(envPath, 0o600); } catch { /* windows */ }
1083
+ // The standalone token file is 0600; leaving the token in a world-readable .env (default umask
1084
+ // is usually 0644) is the same leak through another door, so the mode is set at creation.
1085
+ writeText(envPath, next, { mode: 0o600 });
852
1086
  ok(`Hermes:token 写入 ${envPath}(${envKey},配置里只留 \${${envKey}} 模板)`);
853
1087
  } else {
854
1088
  ok('Hermes:token 已就绪(无改动)');
@@ -1034,12 +1268,14 @@ async function verify() {
1034
1268
  } else {
1035
1269
  const settingsPath = join(HOME, '.claude', 'settings.json');
1036
1270
  const settings = readJson(settingsPath, {}) || {};
1037
- const commands = Object.values(settings.hooks || {}).flat()
1271
+ // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1272
+ // that this machine is installed (and its interpreter may not even be ours).
1273
+ const commands = ourHookEntries(settings.hooks)
1038
1274
  .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1039
- .filter((c) => typeof c === 'string' && c.includes('hook.mjs'));
1275
+ .filter((c) => typeof c === 'string');
1040
1276
  if (!commands.length) {
1041
1277
  allOk = false;
1042
- need('Claude Code:钩子没装(settings.json 里没有指向 hook.mjs 的命令)');
1278
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1043
1279
  } else {
1044
1280
  const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1045
1281
  if (!existsSync(exe)) {
@@ -1127,6 +1363,96 @@ async function verify() {
1127
1363
  return allOk;
1128
1364
  }
1129
1365
 
1366
+ /**
1367
+ * Hand the config back: drop containers that are empty now that our entries are gone, and delete
1368
+ * files that held nothing but our own lines.
1369
+ *
1370
+ * `--uninstall` promises to remove exactly what this installer wrote, and removing only the
1371
+ * *leaves* left the containers it had created behind. On a bare home (`{}` config, nothing else)
1372
+ * that turned into: `~/.claude.json` came back as `{"mcpServers": {}}`, `~/.claude/settings.json`
1373
+ * as `{"hooks": {}, "permissions": {"allow": []}}`, `~/.openclaw/openclaw.json` as
1374
+ * `{"mcp": {"servers": {}}}`, `~/.codewhale/mcp.json` as `{"servers": {}}`, and
1375
+ * `~/.hermes/config.yaml` gained a dangling `mcp_servers:` key. Found 2026-09-18 by the
1376
+ * packaged-tarball e2e, which compares the whole tree before and after — four of five agents
1377
+ * drifted, and only Codex (a line-based format) came back byte-exact.
1378
+ *
1379
+ * An empty object or array is semantically nothing in every format handled here, so pruning it
1380
+ * restores the *meaning* of the user's file. The *formatting* of a JSON file is not restored (the
1381
+ * install re-serialized it) — that is stated rather than pretended, and the e2e check asserts
1382
+ * semantics plus "no trace of ours" for JSON, byte equality for the line-based formats.
1383
+ */
1384
+ function pruneEmptyContainers(value) {
1385
+ if (Array.isArray(value)) {
1386
+ value.forEach(pruneEmptyContainers);
1387
+ } else if (value && typeof value === 'object') {
1388
+ for (const [key, child] of Object.entries(value)) {
1389
+ pruneEmptyContainers(child);
1390
+ if (child && typeof child === 'object' && Object.keys(child).length === 0) delete value[key];
1391
+ }
1392
+ }
1393
+ return value;
1394
+ }
1395
+
1396
+ /**
1397
+ * The `mcp_servers:` line, when nothing is left under it.
1398
+ *
1399
+ * The install adds that key itself when the file has none (the last branch of `installHermes`), so
1400
+ * removing only our block left the key dangling. Any indented key below it means the user has
1401
+ * servers of their own, and then the line stays.
1402
+ */
1403
+ function dropEmptyMcpServersKey(text) {
1404
+ const lines = text.split('\n');
1405
+ const kept = [];
1406
+ for (let i = 0; i < lines.length; i += 1) {
1407
+ const isBareKey = /^mcp_servers:[ \t]*$/.test(lines[i]);
1408
+ const hasChild = lines.slice(i + 1).some((line) => /^[ \t]+\S/.test(line));
1409
+ if (isBareKey && !hasChild) continue;
1410
+ kept.push(lines[i]);
1411
+ }
1412
+ return kept.join('\n');
1413
+ }
1414
+
1415
+ /** The tail of `--uninstall`: give emptiness back, and take blank files away. */
1416
+ function restoreEmptiedFiles() {
1417
+ // JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
1418
+ // it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
1419
+ const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
1420
+ join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json')];
1421
+ for (const file of jsonFiles) {
1422
+ const data = readJson(file, null);
1423
+ if (!data || typeof data !== 'object') continue;
1424
+ const next = `${JSON.stringify(pruneEmptyContainers(data), null, 2)}\n`;
1425
+ if (next === readText(file)) continue;
1426
+ backup(file);
1427
+ writeText(file, next);
1428
+ ok(`移除空配置块 → ${file}`);
1429
+ }
1430
+
1431
+ // Hermes' YAML: the key the install may have created.
1432
+ const hermesCfg = join(HOME, '.hermes', 'config.yaml');
1433
+ const hermesText = readText(hermesCfg);
1434
+ if (hermesText) {
1435
+ const tidied = dropEmptyMcpServersKey(hermesText);
1436
+ if (tidied !== hermesText) {
1437
+ backup(hermesCfg);
1438
+ writeText(hermesCfg, tidied);
1439
+ ok(`移除空的 mcp_servers 键 → ${hermesCfg}`);
1440
+ }
1441
+ }
1442
+
1443
+ // Files that only ever held our own lines: a blank rules file, an emptied `.env`. An empty file
1444
+ // cannot carry user information, and `backup()` never copies an empty file, so removing it is the
1445
+ // honest restore and leaves nothing dangling.
1446
+ const textFiles = [join(HOME, '.claude', 'CLAUDE.md'), join(HOME, '.codex', 'AGENTS.md'),
1447
+ join(HOME, '.codex', 'config.toml'), join(HOME, '.hermes', 'SOUL.md'),
1448
+ hermesCfg, join(HOME, '.hermes', '.env')];
1449
+ for (const file of textFiles) {
1450
+ if (!existsSync(file) || readText(file).trim() !== '') continue;
1451
+ rmSync(file, { force: true });
1452
+ ok(`删除空文件 → ${redact(file)}`);
1453
+ }
1454
+ }
1455
+
1130
1456
  function uninstall() {
1131
1457
  for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md']) {
1132
1458
  if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
@@ -1170,8 +1496,8 @@ function uninstall() {
1170
1496
  if (settings?.hooks) {
1171
1497
  let changed = false;
1172
1498
  for (const event of Object.keys(settings.hooks)) {
1173
- const kept = (settings.hooks[event] || []).filter((entry) =>
1174
- !JSON.stringify(entry).includes('hook.mjs') && !JSON.stringify(entry).includes('voice-hook'));
1499
+ // Only entries we wrote: a user's own hook that happens to be named *-hook.mjs stays.
1500
+ const kept = (settings.hooks[event] || []).filter((entry) => !isOurHookEntry(entry));
1175
1501
  // Drop our permission grants too, so uninstall leaves the file as it was.
1176
1502
  if (Array.isArray(settings.permissions?.allow)) {
1177
1503
  const pruned = settings.permissions.allow.filter((tool) => !CLAUDE_ALLOWED_TOOLS.includes(tool));
@@ -1244,6 +1570,9 @@ function uninstall() {
1244
1570
  writeText(ocCfg, `${JSON.stringify(ocData, null, 2)}\n`);
1245
1571
  ok(`移除 MCP 注册 → ${ocCfg}`);
1246
1572
  }
1573
+
1574
+ // Last, not first: the containers only become empty *after* the entries above are out.
1575
+ restoreEmptiedFiles();
1247
1576
  }
1248
1577
 
1249
1578
  /**
@@ -1272,7 +1601,7 @@ function voiceStatus() {
1272
1601
  const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
1273
1602
  const buckets = settings?.hooks?.PostToolUse;
1274
1603
  if (!Array.isArray(buckets)) return 'absent';
1275
- const ours = buckets.filter((entry) => JSON.stringify(entry).includes('voice-hook'));
1604
+ const ours = buckets.filter(isOurHookEntry);
1276
1605
  if (!ours.length) return 'absent';
1277
1606
  return ours.some((entry) => entry.matcher === '*') ? 'on' : 'stale';
1278
1607
  } catch {
@@ -1451,7 +1780,11 @@ if (mode !== 'report' && !SILENT) {
1451
1780
  if (mode === 'uninstall') {
1452
1781
  uninstall();
1453
1782
  say(render());
1454
- if (!SILENT) console.log('\n已恢复原状(每个改过的文件都有 .misakanet.bak 备份)。');
1783
+ if (!SILENT) {
1784
+ console.log('\n已移除本安装器写入的内容(规则块、MCP 注册、钩子、状态目录)。');
1785
+ console.log(`备份保留在 <被改过的文件>.misakanet.bak(例如 ${join(HOME, '.claude', 'CLAUDE.md')}.misakanet.bak)`
1786
+ + ',确认无误后可自行删除。');
1787
+ }
1455
1788
  process.exit(0);
1456
1789
  }
1457
1790
 
@@ -1516,19 +1849,59 @@ const targets = (only.length ? only : AGENTS).filter((a) => {
1516
1849
  return true;
1517
1850
  });
1518
1851
 
1519
- if (!targets.length) {
1520
- need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
1521
- } else {
1522
- const hookPath = await installHook();
1523
- stampVersion();
1524
- const bearer = has('--no-register') ? '' : await ensureIdentity();
1525
- for (const agent of targets) {
1526
- if (agent === 'claude') await installClaude(hookPath, bearer);
1527
- else if (agent === 'codex') await installCodex(hookPath, bearer);
1528
- else if (agent === 'hermes') await installHermes(hookPath, bearer);
1529
- else if (agent === 'openclaw') await installOpenclaw(bearer);
1530
- else if (agent === 'codewhale') await installCodewhale(bearer);
1852
+ // Install mode used to end without any `process.exit`, so it returned **0 no matter what**:
1853
+ // "no agent detected", "registration failed", "a target could not be written" and a clean
1854
+ // install were indistinguishable, and `npx … && echo ok` asserted success for a run that
1855
+ // changed nothing (reproduced 2026-09-17 on a machine with no agent config at all).
1856
+ //
1857
+ // The contract now matches the other modes (0 fine / 1 ran but not ready / 2 could not run):
1858
+ // * 0 - at least one target's config was written (advisory items may still be listed in `!`)
1859
+ // * 1 - nothing was installed: no target detected, or every target failed
1860
+ // * 2 - the installer itself could not do its job (I/O, permissions, a blown-up config)
1861
+ //
1862
+ // The exit code deliberately answers "did the install happen", not "is everything green":
1863
+ // a run that leaves the user with working read access but no write token is a *success* with a
1864
+ // listed item. Use `--verify` / `--report --strict` for the health question — that separation
1865
+ // is what makes this signal usable in a script.
1866
+ let installed = 0;
1867
+ // Set when a stable client id was minted: shown to the user at the end (see ensureIdentity).
1868
+ let clientIdHint = '';
1869
+ try {
1870
+ if (!targets.length) {
1871
+ need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
1872
+ } else {
1873
+ const hookPath = await installHook();
1874
+ stampVersion();
1875
+ const bearer = has('--no-register') ? '' : await ensureIdentity();
1876
+ for (const agent of targets) {
1877
+ // One target failing must not skip the rest: an uncaught throw used to abort the loop, so
1878
+ // the remaining agents were never touched while the run still looked fine.
1879
+ const blocked = unwritableTargets((AGENT_WRITE_PATHS[agent] || (() => []))(HOME));
1880
+ if (blocked.length) {
1881
+ // Reported before attempting, so the user gets "these files are read-only" instead of a raw
1882
+ // EACCES from four call frames deep — and `--dry-run` says it too, which is the whole point
1883
+ // of a dry run.
1884
+ need(`${agent}:这些文件改不了(只读或权限不足)→ ${redact(blocked.join('、'))}`
1885
+ + ';修好后重跑本命令,其它助手不受影响');
1886
+ continue;
1887
+ }
1888
+ try {
1889
+ if (agent === 'claude') await installClaude(hookPath, bearer);
1890
+ else if (agent === 'codex') await installCodex(hookPath, bearer);
1891
+ else if (agent === 'hermes') await installHermes(hookPath, bearer);
1892
+ else if (agent === 'openclaw') await installOpenclaw(bearer);
1893
+ else if (agent === 'codewhale') await installCodewhale(bearer);
1894
+ installed += 1;
1895
+ } catch (err) {
1896
+ need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
1897
+ + '→ 修好这个文件或权限后重跑本命令,其它助手不受影响');
1898
+ }
1899
+ }
1531
1900
  }
1901
+ } catch (err) {
1902
+ say(render());
1903
+ console.error(`安装没能跑完 —— 退出码 2(跑不起来,与「装不上」是两回事):${redact((err && err.message) || err)}`);
1904
+ process.exit(2);
1532
1905
  }
1533
1906
 
1534
1907
  // Under `--silent` this is the whole install-mode output: only the `!` lines survive render(), and
@@ -1542,5 +1915,17 @@ if (!SILENT) {
1542
1915
  1) **把这个助手窗口关掉再打开一次**(新功能要重开会话才生效)
1543
1916
  2) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
1544
1917
  3) 想确认状态:npx @misaka-net/misakanet-setup --verify
1545
- 4) 想关掉:npx @misaka-net/misakanet-setup --uninstall`);
1918
+ 4) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
1919
+ + (clientIdHint
1920
+ ? `
1921
+ 5) 记不记都行:这台机器的身份编号是 ${clientIdHint}
1922
+ 想让它以后重装/换机还算同一个身份,再跑安装时加:--client-id ${clientIdHint}`
1923
+ : ''));
1924
+ }
1925
+ if (!installed) {
1926
+ // Say it in one line on stderr as well: a script needs the reason next to the exit code, and
1927
+ // "nothing was installed" is exactly the case that used to exit 0 in silence.
1928
+ console.error('\n没有装上任何助手 —— 退出码 1。上面每条 ! 都给了下一步;装好助手再重跑即可。');
1929
+ process.exit(1);
1546
1930
  }
1931
+ process.exit(0);
@@ -21,6 +21,7 @@
21
21
  * echo '{"session_id":"demo"}' | node checkpoint_reminder.mjs prompt
22
22
  */
23
23
  import { writeFileSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
24
+ import { createHash } from 'node:crypto';
24
25
  import { homedir } from 'node:os';
25
26
  import { join, dirname } from 'node:path';
26
27
 
@@ -69,11 +70,20 @@ function parsePayload(raw) {
69
70
  }
70
71
 
71
72
  function sessionKey(payload) {
73
+ // An explicit key wins: the same value the strategy below would derive, but chosen by the user or
74
+ // by an agent that knows its own conversation boundaries.
75
+ const pinned = (process.env.MISAKANET_SESSION_KEY || '').trim();
76
+ if (pinned) return pinned.slice(0, 64);
72
77
  for (const key of ['session_id', 'sessionId', 'session', 'thread_id', 'conversation_id']) {
73
78
  const value = payload[key];
74
79
  if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 64);
75
80
  }
76
- return 'default';
81
+ // No session id at all used to fall back to the single name `default`, so every agent on the
82
+ // machine that does not report one shared *one* turn counter: agent A's twentieth turn fired the
83
+ // checkpoint in agent B's session and B never saw its own reminder (2026-09-18 review, 意见 9).
84
+ // Deriving the key from the working directory keeps one counter per project, which is the closest
85
+ // thing to "this conversation" that a hook can see without an id — and it is stable across turns.
86
+ return `default-${createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8)}`;
77
87
  }
78
88
 
79
89
  function statePath(session) {
@@ -277,7 +287,11 @@ function promptMode(payload) {
277
287
  '1) 挑出本会话真正值得复用的「失败→根因→修复→验证」;门槛:可泛化、有可跑判据、且先 ' +
278
288
  'misakanet_search 确认语料里没有重复(有就引用其 id,不要另写一篇)。\n' +
279
289
  '2) 脱敏:密钥/token/凭据 → <REDACTED>;人名/邮箱/真实域名/绝对家目录 → 泛化成 ~/project、example.com;' +
280
- '不要粘会话转录或整段工具输出。\n' +
290
+ // The "内部业务细节抽象成结构性描述" clause is the Python hook's wording, which is the one that
291
+ // carries it (added 2026-09-14, `2af7a41df`). The two hooks had drifted here and nothing
292
+ // compared them; `tests/test_agent_autostart_parity.py` now does, and this line was its first
293
+ // find. The stronger clause wins: it is a redaction rule, and the weaker copy was the Node one.
294
+ '不要粘会话转录或整段工具输出;内部业务细节抽象成结构性描述。\n' +
281
295
  '3) 提交(无需 token):misakanet_submit_intake(kind="missing_lesson", problem="## Problem\\n…\\n\\n' +
282
296
  '## Root Cause\\n…\\n\\n## Solution\\n…\\n\\n## Verification\\n…");若这条其实是「问题」而非经验,' +
283
297
  '用 kind="question";若确实不够泛化/价值不高 → 不提交。\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw / codewhale to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"
@@ -444,10 +444,28 @@ try {
444
444
  main();
445
445
  } catch {
446
446
  /* a hook must never surface as a tool failure */
447
- } finally {
448
- process.exit(0);
449
447
  }
450
448
 
451
- // Backstop: if a detached player leaves this process alive (some players keep a handle),
452
- // leave anyway. The sound is best-effort by design.
453
- setTimeout(() => process.exit(0), 3000).unref();
449
+ // Exit on the next tick rather than through `process.exit(0)` *in* the finally above.
450
+ //
451
+ // The old shape was:
452
+ //
453
+ // } finally { process.exit(0); }
454
+ // setTimeout(() => process.exit(0), 3000).unref(); // ← unreachable, every run
455
+ //
456
+ // `process.exit` ends the process before the backstop line is ever evaluated, so the safety net
457
+ // for "a detached player kept a handle open" had never run — dead code that read like protection.
458
+ // (Found by the 2026-09-18 setup review; verified by reading the tail of this file, which is also
459
+ // why the package's `voice/voice-hook.mjs` copy is regenerated by `prepack`.)
460
+ //
461
+ // Why not just `process.exitCode = 0` with no exit call at all: the debug line above is written with
462
+ // `writeFileSync(1, …)` precisely so it cannot be truncated, and this process must not stay alive
463
+ // waiting on a spawned player — the agent is waiting for the hook to return. `setImmediate` keeps
464
+ // stdout draining for one tick while still ending the process deterministically, and the timer is
465
+ // the real backstop for a handle the spawn left behind.
466
+ setImmediate(() => {
467
+ setTimeout(() => process.exit(0), 3000).unref();
468
+ // If the loop is otherwise empty, `unref`ed timers do not hold it open, so Node exits by itself
469
+ // within this tick — the timer only matters when something else is still keeping us alive.
470
+ process.exitCode = 0;
471
+ });