@misaka-net/misakanet-setup 0.5.4 → 0.5.6

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/README.md CHANGED
@@ -16,7 +16,7 @@ knowledge base on its own instead of guessing.
16
16
 
17
17
  | # | Goal | Mechanism |
18
18
  |---|---|---|
19
- | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token so reads are not metered by the anonymous 5/day/IP limit. Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. All four are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line**. For Claude Code only, the five **read-only** tools are also added to `permissions.allow` — "can call it" otherwise still means a permission prompt on the very first search, so `0.5.3` pre-allows them (`--verify` reports it, `--report` prints `permissions:`); `write_lesson` is deliberately **not** pre-allowed, because a tool that writes should ask |
19
+ | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token when registration succeeds — reads need no token (anonymous reads are unmetered since 2026-09-18); the token is what opens the **write** path. Every entry also carries the self-declared context hints `X-MisakaNet-Client` / `-Agent` / `-Os` / `-Version`, which the endpoint records on the read's analytics row and never treats as identity (`codewhale` is the one exception: its server entry has no custom-header field). Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. All four are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line**. For Claude Code only, the five **read-only** tools are also added to `permissions.allow` — "can call it" otherwise still means a permission prompt on the very first search, so `0.5.3` pre-allows them (`--verify` reports it, `--report` prints `permissions:`); `write_lesson` is deliberately **not** pre-allowed, because a tool that writes should ask |
20
20
  | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md` (issue, retry, risky-operation triggers; desensitisation rules). Wording is **imperative first** ("必须先调 misakanet_search") rather than descriptive, because a field test showed a weaker model reads a descriptive block as background and never calls the tool |
21
21
  | 4 | *(opt-in)* you **hear** it | `--voice` adds a `PostToolUse` hook that plays the cue the server asked for (`lesson-found` / `failure-warning` / `connect-success` / `pair-success`). Off by default; mute with `MISAKANET_VOICE=0`; see `docs/integrations/mcp-voice-hooks.md` |
22
22
  | 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "distil and submit" reminder; a failed tool call injects a "search before you retry" reminder built from the error text. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
@@ -16,8 +16,8 @@
16
16
  * undone by the other.
17
17
  *
18
18
  * Three things, all required, or the install is theatre:
19
- * 1. the agent CAN call it → MCP server entry (with the token, so reads are not
20
- * metered by the anonymous 5/day limit)
19
+ * 1. the agent CAN call it → MCP server entry (with the token when we have one, plus the
20
+ * self-declared context hints; reads are unlimited either way)
21
21
  * 2. the agent KNOWS when to call → rules block in its own rules file
22
22
  * 3. the checkpoint FIRES → a hook, because "summarise every 20 turns" is dead
23
23
  * text: agents do not keep counters
@@ -25,13 +25,21 @@
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
- import { delimiter, join, dirname, resolve } from 'node:path';
42
+ import { basename, delimiter, join, dirname, resolve } from 'node:path';
35
43
  import { fileURLToPath } from 'node:url';
36
44
 
37
45
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -70,6 +78,25 @@ const valueOf = (flag, dflt) => {
70
78
  };
71
79
  const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
72
80
  const DRY = has('--dry-run');
81
+ /**
82
+ * Permission tiers (2026-09-18).
83
+ *
84
+ * The installer does three different kinds of thing, and they do not need the same permission:
85
+ *
86
+ * ① register an MCP endpoint — the user's own agent config; the same privilege as adding
87
+ * any MCP server;
88
+ * ② write a rules block — changes the agent's behaviour ("search before retrying"),
89
+ * and for Hermes that means editing `~/.hermes/SOUL.md`, its
90
+ * *identity* file;
91
+ * ③ install hooks + state — acts automatically on every turn.
92
+ *
93
+ * Asking for all three in one command is why a machine with an approval process can only answer
94
+ * "no" (issue #1753, 2026-09-18): an operator willing to approve ① has no way to say it. `--mcp-only`
95
+ * is that way to say it — ① only, and the run says so in its own report instead of looking partial.
96
+ */
97
+ const MCP_ONLY = has('--mcp-only');
98
+ const LIST_WRITES = has('--list-writes');
99
+
73
100
  /**
74
101
  * `--silent` (issue #1784): the enterprise/MDM form. A GPO or Intune script pipes this program's
75
102
  * stdout into a log file, where the ✓/· narration is noise (and names local paths).
@@ -120,7 +147,87 @@ function readText(path) {
120
147
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
121
148
  * manifest — a failing test is a better place for that than a request header.
122
149
  */
123
- const VERSION = '0.5.4'
150
+ const VERSION = '0.5.6'
151
+
152
+ /**
153
+ * The shape of a client id, in one place.
154
+ *
155
+ * It gates two different things: what the endpoint accepts as `client_id` on registration, and — since
156
+ * 2026-09-19 — what may be written into an assistant's config as `X-MisakaNet-Client`. The second use
157
+ * is why the shape matters here and not only at the endpoint: that value can come from a file we found
158
+ * on disk, and it is interpolated into a TOML inline table, a YAML mapping and JSON. A string matching
159
+ * this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
160
+ */
161
+ const CLIENT_ID_SHAPE = /^[A-Za-z0-9._-]{8,64}$/
162
+
163
+ // ── argument surface ─────────────────────────────────────────────────
164
+ // Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
165
+ // (reproduced on a clean HOME — it rewrote the user's CLAUDE.md and settings.json), and an
166
+ // unknown flag was silently ignored. `--help` is the first thing a cautious person types, so
167
+ // "looking before you leap" was the action with the worst consequences.
168
+ const FLAGS = [
169
+ ['--home <dir>', '把配置写到这个目录,而不是真实家目录(不是沙箱:--uninstall 也认这个目录)'],
170
+ ['--only <a,b>', `只处理列出的助手(${AGENTS.join(' / ')})`],
171
+ ['--dry-run', '只说不做:打印会改哪些文件,不写任何东西'],
172
+ ['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
173
+ ['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
174
+ ['--report-json', '同一份报告的 JSON 编码'],
175
+ ['--strict', '配合 --report:NOT READY 时退出码 1(默认仍退 0)'],
176
+ ['--ci', '= --report --strict'],
177
+ ['--verify', '自检:端点可达、钩子、MCP 注册、版本新旧'],
178
+ ['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
179
+ ['--upgrade', '与安装等价(覆盖安装即升级)'],
180
+ ['--client-id <id>', '固定本机身份:同一个 id 永远拿回同一个 node(重装/换机也能延续)'],
181
+ ['--mcp-only', '只注册 MCP 端点,不改 agent 行为(不写规则块、不装钩子)——权限分级用'],
182
+ ['--list-writes', '只打印会改哪些文件(文件 | 动作 | 撤销方式),不写任何东西'],
183
+ ['--no-register', '不注册匿名节点(不写 token;读不受影响,写入类工具不可用)'],
184
+ ['--voice', '打开语音/桌面通知(默认关)'],
185
+ ['--help, -h', '打印这份帮助并退出'],
186
+ ['--version', '打印版本并退出'],
187
+ ];
188
+ const VALUE_FLAGS = ['--home', '--only', '--client-id'];
189
+
190
+ function printHelp() {
191
+ console.log(`MisakaNet 安装程序 ${VERSION}
192
+ 用法:npx @misaka-net/misakanet-setup [选项]
193
+
194
+ ${FLAGS.map(([flag, desc]) => ` ${flag.padEnd(16)} ${desc}`).join('\n')}
195
+
196
+ 退出码(安装模式):0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。
197
+ 健康检查用 --verify 或 --report --strict,不要看安装模式的退出码。`);
198
+ }
199
+
200
+ if (has('--help') || has('-h')) {
201
+ printHelp();
202
+ process.exit(0);
203
+ }
204
+ if (has('--version')) {
205
+ console.log(VERSION);
206
+ process.exit(0);
207
+ }
208
+
209
+ /** What we would write, and how to take it back — printed by --list-writes, and used by the report. */
210
+ const WRITE_MANIFEST = [];
211
+
212
+ const KNOWN = new Set(FLAGS.flatMap(([flag]) => flag.split(/[,\s]/).filter((f) => f.startsWith('-'))));
213
+ {
214
+ const unknown = args.filter((a) => a.startsWith('-') && !KNOWN.has(a));
215
+ if (unknown.length) {
216
+ console.error(`无法识别的选项:${unknown.join(' ')}`
217
+ + `\n(本安装器不认识它,为避免误解你的意图,这里直接停下、什么都没写。--help 看全部选项。)`);
218
+ process.exit(2);
219
+ }
220
+ // A value flag whose value is missing or is itself a flag used to be accepted: `--home --voice`
221
+ // resolved HOME to `$(pwd)/--voice` and installed for real into a directory named `--voice`.
222
+ for (const flag of VALUE_FLAGS) {
223
+ const i = args.indexOf(flag);
224
+ if (i >= 0 && (!args[i + 1] || args[i + 1].startsWith('-'))) {
225
+ console.error(`${flag} 需要一个值(例如 ${flag} /tmp/home),现在后面跟的是 `
226
+ + `“${args[i + 1] ?? '空'}”——已停下,什么都没写。`);
227
+ process.exit(2);
228
+ }
229
+ }
230
+ }
124
231
 
125
232
  function backup(path) {
126
233
  if (DRY || !readText(path)) return;
@@ -148,10 +255,161 @@ function sameJson(a, b) {
148
255
  return canonical(a) === canonical(b);
149
256
  }
150
257
 
151
- function writeText(path, text) {
258
+ /**
259
+ * Write through a sibling temp file and rename it into place.
260
+ *
261
+ * A direct `writeFileSync` is not atomic: if the process dies (SIGINT, OOM, full disk) between
262
+ * truncating and finishing, the target is left half-written — and these targets are the user's
263
+ * `~/.claude.json`, `settings.json` and rule files, where "half-written" can mean an assistant that
264
+ * will not start. Proven, not assumed: after a direct write the file keeps its inode across a
265
+ * rewrite; after a `rename` it changes (pinned in workers/misakanet-setup.test.mjs).
266
+ *
267
+ * Two details that matter:
268
+ * * `mode` is applied at creation, so a secret is never momentarily world-readable (the token and
269
+ * the Hermes `.env` used to be written and then `chmod 600`-ed — a window on every run);
270
+ * * an existing file that is not writable is still refused. `rename` would happily replace a
271
+ * read-only file (it only needs write permission on the *directory*), so the previous EACCES
272
+ * behaviour is kept explicitly rather than silently lost.
273
+ */
274
+ function writeText(path, text, { mode } = {}) {
152
275
  if (DRY) return;
153
276
  mkdirSync(dirname(path), { recursive: true });
154
- writeFileSync(path, text);
277
+ if (existsSync(path)) {
278
+ try {
279
+ accessSync(path, constants.W_OK);
280
+ } catch {
281
+ const error = new Error(`EACCES: permission denied, open '${path}'`);
282
+ error.code = 'EACCES';
283
+ throw error;
284
+ }
285
+ }
286
+ const tmp = `${path}.misakanet-tmp`;
287
+ try {
288
+ // fsync, not just rename: `rename` guarantees the reader sees the old file or the new one, but
289
+ // it does not guarantee the *contents* reached the disk — a power loss after the rename can
290
+ // still leave an empty file. For an assistant config that is the difference between "the tool
291
+ // did not install" and "my assistant will not start". `writeFileSync` does not expose the
292
+ // descriptor, hence the explicit open/write/fsync/close.
293
+ const fd = openSync(tmp, 'w', mode ?? 0o666);
294
+ try {
295
+ writeSync(fd, text);
296
+ fsyncSync(fd);
297
+ } finally {
298
+ closeSync(fd);
299
+ }
300
+ renameSync(tmp, path);
301
+ } catch (err) {
302
+ // Windows can refuse to replace an existing file with `rename` (EPERM when another process has
303
+ // the file open, EEXIST in some states). The old fallback was a direct `writeFileSync(path, …)`
304
+ // — which is the non-atomic write this function exists to avoid, and which truncates the user's
305
+ // config if the same lock is still held. Clearing the destination and retrying the rename keeps
306
+ // the replacement atomic, and when the file really is locked both attempts fail, so the caller
307
+ // reports "写入配置失败" instead of quietly writing half a config.
308
+ //
309
+ // (`writeFileSync` here also read as "write after a check on the same path" to CodeQL
310
+ // js/file-system-race — alert #272, the shape the fix above is about.)
311
+ if (err && (err.code === 'EPERM' || err.code === 'EEXIST')) {
312
+ rmSync(path, { force: true });
313
+ renameSync(tmp, path);
314
+ return;
315
+ }
316
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
317
+ throw err;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The files each assistant's install writes, so a permission problem can be reported *before*
323
+ * anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
324
+ * this"). It mirrors what the per-agent install functions write; those keep their own try/catch as
325
+ * the safety net, because a mirror can fall out of date and the real error is still the truth.
326
+ */
327
+ const AGENT_WRITE_PATHS = {
328
+ claude: (home) => [join(home, '.claude.json'), join(home, '.claude', 'settings.json'),
329
+ join(home, '.claude', 'CLAUDE.md')],
330
+ codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
331
+ hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
332
+ codewhale: (home) => [join(home, '.codewhale', 'mcp.json')],
333
+ openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
334
+ };
335
+
336
+ /**
337
+ * Which of these paths this process may not write.
338
+ *
339
+ * Reported *before* anything is written, so a user without permission gets one clear list instead
340
+ * of a half-finished install. `--dry-run` uses the same list, which is what makes a dry run honest:
341
+ * it says "I would change this" and "I cannot change that" in the same breath.
342
+ */
343
+ function unwritableTargets(paths) {
344
+ const blocked = [];
345
+ for (const path of paths) {
346
+ if (!canCreateHere(path)) blocked.push(path);
347
+ }
348
+ return blocked;
349
+ }
350
+
351
+ /**
352
+ * May this process create or overwrite `path`?
353
+ *
354
+ * Asked by *attempting* the access and reading the error, never by `existsSync(path)` first: a
355
+ * check-then-use pair is a race — the file can appear or disappear between the two calls, so the
356
+ * answer can describe a world that is already gone — and CodeQL reports exactly that shape as
357
+ * `js/file-system-race` (alerts #272/#273 on this file and its test, 2026-09-18). The attempt
358
+ * itself is also the *only* question with one answer: `access(W_OK)` on a file that does not exist
359
+ * yet says ENOENT, which is not "unwritable".
360
+ *
361
+ * When the file does not exist yet the question belongs to the nearest ancestor that does, because
362
+ * the install creates the missing parents (`mkdirSync(..., {recursive: true})`). The walk-up is
363
+ * what makes `--dry-run` honest on a first install: an earlier version asked about `dirname(path)`
364
+ * exactly once, so a home whose `~/.hermes` did not exist yet was reported "改不了" even though the
365
+ * installer was about to create that directory.
366
+ */
367
+ function canCreateHere(path) {
368
+ for (let target = path; ;) {
369
+ try {
370
+ accessSync(target, constants.W_OK);
371
+ return true;
372
+ } catch (err) {
373
+ if (!err || err.code !== 'ENOENT') return false; // EACCES/EROFS/EPERM/…
374
+ const parent = dirname(target);
375
+ if (parent === target) return false; // walked up to the root: nothing exists
376
+ target = parent;
377
+ }
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Is this settings.json hook entry one *this installer* wrote?
383
+ *
384
+ * Every command we install points into our own namespace directory
385
+ * (`<home>/.misakanet-agent/hook.mjs`, and `.../voice/voice-hook.mjs`), so that directory name
386
+ * is the identity marker. Matching the *filename* instead (`…includes('hook.mjs')`) treated a
387
+ * user's own `my-hook.mjs` as ours: `--uninstall` deleted the user's hook — reproduced
388
+ * 2026-09-17 — while `packages/misakanet-setup/README.md` promises "leaves your own hooks
389
+ * alone". The same loose test also made install skip adding our hook (believing it was
390
+ * already there) and made `--verify` report a foreign hook as ours.
391
+ *
392
+ * `checkpoint_reminder.mjs` is the legacy 0.4.x shape, from before the hook was copied into
393
+ * the state dir — our file name, not a generic one.
394
+ */
395
+ function isOurHookEntry(entry) {
396
+ const commands = (entry?.hooks || [])
397
+ .map((h) => h?.command)
398
+ .filter((c) => typeof c === 'string');
399
+ // Namespace, not the absolute path: an entry written by an older install under a
400
+ // *different* HOME still points at `<something>/.misakanet-agent/hook.mjs`, and the 0.4.2
401
+ // upgrade path has to recognise exactly that (a fixture with /home/u covers it). Matching
402
+ // the directory name also survives Windows separators. `checkpoint_reminder.mjs` is the
403
+ // legacy shape from before the hook was copied into the state dir — our file name, not a
404
+ // generic one.
405
+ return commands.some(
406
+ (c) => c.includes('.misakanet-agent') || c.includes('checkpoint_reminder.mjs'),
407
+ );
408
+ }
409
+
410
+ /** Hook entries we wrote, across every event in a settings.json `hooks` object. */
411
+ function ourHookEntries(hooks) {
412
+ return Object.values(hooks || {}).flat().filter(isOurHookEntry);
155
413
  }
156
414
 
157
415
  /** Insert or refresh a marker-delimited block. Uses a function replacement (no $-escapes). */
@@ -175,7 +433,10 @@ function injectBlock(path, block) {
175
433
  function stripBlock(path) {
176
434
  const text = readText(path);
177
435
  if (!text) return false;
178
- const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
436
+ // `\n?` on both sides: install writes a blank line, then the block, so consuming only the
437
+ // block left the file one newline longer than it started (uninstall claimed "已恢复原状"
438
+ // while `diff` showed an extra empty line).
439
+ const pattern = new RegExp(`\\n?[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
179
440
  if (!pattern.test(text)) return false;
180
441
  backup(path);
181
442
  const stripped = text.replace(pattern, '');
@@ -189,6 +450,45 @@ function stripBlock(path) {
189
450
 
190
451
  const stateDir = () => join(HOME, '.misakanet-agent');
191
452
 
453
+ /**
454
+ * The headers every MCP entry we write carries.
455
+ *
456
+ * Two kinds, and the difference matters: `Authorization` is a credential (only when we have a token),
457
+ * while the other four are **self-declared hints** the service records on its analytics row — the
458
+ * stable pseudonym `X-MisakaNet-Client` (so a caller's history stays together without an account), the
459
+ * assistant name, the platform, and the installer version that wrote this. The endpoint treats them as
460
+ * hints and never as identity, which is what makes it acceptable to write them automatically
461
+ * (see AGENTS.md §3.3).
462
+ *
463
+ * Written only for agents whose config format supports custom headers; `--mcp-only` gets them too,
464
+ * because "the tool is callable" is exactly what tier ① means.
465
+ */
466
+ function mcpHeaders(bearer, agent) {
467
+ const headers = {};
468
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
469
+ const clientId = declaredClientId();
470
+ if (clientId) headers['X-MisakaNet-Client'] = clientId;
471
+ headers['X-MisakaNet-Agent'] = agent;
472
+ headers['X-MisakaNet-Os'] = `${process.platform}/${process.arch}`.slice(0, 40);
473
+ headers['X-MisakaNet-Version'] = VERSION;
474
+ return headers;
475
+ }
476
+
477
+ /**
478
+ * The pseudonym to declare, or `''` when this machine has not said.
479
+ *
480
+ * Explicit intent beats a file we merely found, the same rule the register path applies. The file is
481
+ * `integrations/agent-autostart`'s (it keeps one there for the Python channel), so picking it up is
482
+ * continuity worth having — but it is data from outside this process on its way into three config
483
+ * syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
484
+ * not a hint worth writing; it is noise that could close a quote or a mapping.
485
+ */
486
+ function declaredClientId() {
487
+ if (knownClientId) return knownClientId;
488
+ const found = readText(join(stateDir(), 'client_id')).trim();
489
+ return CLIENT_ID_SHAPE.test(found) ? found : '';
490
+ }
491
+
192
492
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
193
493
 
194
494
  /**
@@ -435,6 +735,16 @@ async function installHook() {
435
735
  }
436
736
 
437
737
  async function ensureIdentity() {
738
+ // The explicit intent is read first, because it decides the `X-MisakaNet-Client` hint on every run
739
+ // — including one that registers nothing (the token is already there) — so it cannot sit below the
740
+ // early returns. `--client-id` and `MISAKANET_CLIENT_ID` are the same intent.
741
+ const explicit = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
742
+ const usable = CLIENT_ID_SHAPE.test(explicit);
743
+ if (explicit && !usable) {
744
+ need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
745
+ }
746
+ if (usable) knownClientId = explicit;
747
+
438
748
  const file = join(stateDir(), 'token');
439
749
  let existing = '';
440
750
  try {
@@ -456,13 +766,11 @@ async function ensureIdentity() {
456
766
  // what it looks like, and it is right — the value may be ours, but the shape is the bug.
457
767
  // An exported MISAKANET_CLIENT_ID is explicit intent (the same standard the hook applies to
458
768
  // 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();
460
- if (exported && !/^[A-Za-z0-9._-]{8,64}$/.test(exported)) {
461
- need('ignored MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
462
- }
463
- const clientId = (exported && /^[A-Za-z0-9._-]{8,64}$/.test(exported))
464
- ? exported
465
- : `setup-${crypto.randomUUID()}`;
769
+ // `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
770
+ // the closing guidance told users to keep an identity and the only way to supply one was a shell
771
+ // export — an instruction a non-technical user cannot follow, and on Windows a different one.
772
+ const clientId = usable ? explicit : `setup-${randomUUID()}`;
773
+ knownClientId = clientId;
466
774
  const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
467
775
  // Validate before persisting: a response body is not something to write to disk unchecked
468
776
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
@@ -470,23 +778,41 @@ async function ensureIdentity() {
470
778
  // a visible, debuggable outcome instead of a silent 401 later.
471
779
  const token = typeof result?.token === 'string' ? result.token.trim() : '';
472
780
  if (!/^mcp_[A-Za-z0-9_-]{20,}$/.test(token)) {
473
- need('注册没成功或返回的凭据形状不对(可能离线)→ 读课程不受影响;想要写入类工具时重跑本命令');
781
+ // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
782
+ // what is lost here (it needs no token and is not metered) — the write path is, and saying so
783
+ // plainly is the difference between a user retrying and a user concluding the tool is broken.
784
+ need('匿名注册没成功(多半是网络)→ 读课程不受影响,但写入类工具(write_lesson)现在用不了;'
785
+ + '网络恢复后重跑本命令就能拿到 token');
474
786
  return '';
475
787
  }
476
- writeFileSync(file, token);
477
- try {
478
- chmodSync(file, 0o600);
479
- } catch { /* windows */ }
788
+ // Created with mode 0600 rather than written and then chmodded: the old order left a window in
789
+ // which the token sat in a world-readable file on every single run.
790
+ writeText(file, token, { mode: 0o600 });
480
791
  ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
481
- if (!exported) {
792
+ if (!usable) {
482
793
  // Printed, not stored: this process must not turn a file it found into request data
483
794
  // (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
484
- ok(`想在这个节点上继续累积(重装/换机后仍是同一个):export MISAKANET_CLIENT_ID=${clientId}`);
795
+ // Kept out of the "done" list on purpose — it is something to *keep*, not something that
796
+ // happened, and a bare `export …` line reads as a chore to a non-technical user. It is shown
797
+ // with the closing guidance instead, in plain words.
798
+ clientIdHint = clientId;
485
799
  }
486
800
  return token;
487
801
  }
488
802
 
489
803
  // ── per-agent install ────────────────────────────────────────────────
804
+ /** Is our MCP entry already in this agent's config? (read-only; used by the report's scope line) */
805
+ function mcpEntryPresent(agent) {
806
+ const text = {
807
+ claude: () => JSON.stringify(readJson(join(HOME, '.claude.json'), {})?.mcpServers?.misakanet || null),
808
+ codex: () => readText(join(HOME, '.codex', 'config.toml')),
809
+ hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
810
+ openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
811
+ codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
812
+ }[agent]?.() || '';
813
+ return text.includes('misakanet') && text !== 'null';
814
+ }
815
+
490
816
  function detect(agent) {
491
817
  const paths = {
492
818
  claude: ['.claude.json', '.claude'],
@@ -507,8 +833,7 @@ async function installClaude(hookPath, bearer) {
507
833
  }
508
834
  const doc = data || {};
509
835
  doc.mcpServers = doc.mcpServers || {};
510
- const entry = { type: 'http', url: ENDPOINT };
511
- if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
836
+ const entry = { type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'claude-code') };
512
837
  if (sameJson(doc.mcpServers.misakanet, entry)) {
513
838
  ok('Claude Code:MCP 已注册(无改动)');
514
839
  } else {
@@ -519,8 +844,30 @@ async function installClaude(hookPath, bearer) {
519
844
  }
520
845
 
521
846
  const rules = join(HOME, '.claude', 'CLAUDE.md');
522
- ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
523
-
847
+ if (!MCP_ONLY) ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
848
+ else skip('Claude Code:规则块未写(--mcp-only:只注册端点)');
849
+
850
+ if (MCP_ONLY) {
851
+ // Tier ① = "the tool can be called", and that includes the read-only grants: the comment below
852
+ // says it plainly — without them the first search is *denied*, so an operator who approved
853
+ // "add an MCP server" would be shipping a tool their users cannot use. The grants are therefore
854
+ // tier ①. What tier ③ adds is the hooks, and those are not written here.
855
+ const settingsPath = join(HOME, '.claude', 'settings.json');
856
+ const settings = readJson(settingsPath, {}) || {};
857
+ const allow = ((settings.permissions = settings.permissions || {}).allow =
858
+ Array.isArray(settings.permissions.allow) ? settings.permissions.allow : []);
859
+ let changed = false;
860
+ for (const tool of CLAUDE_ALLOWED_TOOLS) {
861
+ if (!allow.includes(tool)) { allow.push(tool); changed = true; }
862
+ }
863
+ if (changed) {
864
+ backup(settingsPath);
865
+ writeText(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
866
+ }
867
+ ok(`Claude Code:只读工具已放行(${CLAUDE_ALLOWED_TOOLS.length} 个${changed ? '' : ',无改动'})`);
868
+ skip('Claude Code:自动沉淀钩子未装(--mcp-only:只注册端点与工具放行)');
869
+ return;
870
+ }
524
871
  if (!hookPath) {
525
872
  need('Claude Code:跳过了"自动沉淀"钩子(钩子文件没取到)');
526
873
  return;
@@ -545,7 +892,7 @@ async function installClaude(hookPath, bearer) {
545
892
 
546
893
  for (const [event, command] of Object.entries(wanted)) {
547
894
  const bucket = settings.hooks[event] || [];
548
- if (JSON.stringify(bucket).includes('hook.mjs') || JSON.stringify(bucket).includes('checkpoint_reminder')) continue;
895
+ if (bucket.some(isOurHookEntry)) continue;
549
896
  bucket.push({ hooks: [{ type: 'command', command }] });
550
897
  settings.hooks[event] = bucket;
551
898
  changed = true;
@@ -576,13 +923,13 @@ async function installClaude(hookPath, bearer) {
576
923
  // matcher-less entry of ours as stale rather than "already installed", or re-running the
577
924
  // installer would leave a hook that stays silent forever (the exact failure this release
578
925
  // is about).
579
- const stale = post.filter((e) => JSON.stringify(e).includes('voice-hook') && e.matcher !== '*');
926
+ const stale = post.filter((e) => isOurHookEntry(e) && e.matcher !== '*');
580
927
  if (stale.length) {
581
928
  post = post.filter((e) => !stale.includes(e));
582
929
  settings.hooks.PostToolUse = post;
583
930
  changed = true;
584
931
  }
585
- if (!JSON.stringify(post).includes('voice-hook')) {
932
+ if (!post.some(isOurHookEntry)) {
586
933
  // `matcher: '*'` is load-bearing, not cosmetic: measured on 2026-09-16, a PostToolUse
587
934
  // entry **without** a matcher never fired in this host, while the same command with
588
935
  // `matcher: '*'` fired on every tool call. The hook itself filters by the `voice`
@@ -617,12 +964,17 @@ async function installClaude(hookPath, bearer) {
617
964
 
618
965
  const codexTable = (bearer) => {
619
966
  const lines = ['[mcp_servers.misakanet]', 'type = "streamable-http"', `url = "${ENDPOINT}"`];
620
- if (bearer) {
621
- // http_headers, not bearer_token_env_var: the env var needs the user to export it, and
622
- // this user will not.
623
- lines.push(`http_headers = { Authorization = "Bearer ${bearer}" }`);
624
- } else {
625
- lines.push('# 没有 token:读走匿名通道(5/天/IP)');
967
+ // http_headers, not bearer_token_env_var: the env var needs the user to export it, and this user
968
+ // will not. All the headers go into the ONE inline table — a second `http_headers` line is a
969
+ // duplicate key, and TOML refuses the whole file for it. (Bare TOML keys may contain `-`, which is
970
+ // why the hint names need no quoting.)
971
+ const headers = Object.entries(mcpHeaders(bearer, 'codex'))
972
+ .map(([key, value]) => `${key} = "${value}"`).join(', ');
973
+ lines.push(`http_headers = { ${headers} }`);
974
+ if (!bearer) {
975
+ // Only worth saying when it is true. Since 2026-09-18 anonymous reads are not metered — what a
976
+ // token buys is the write path, not more reads.
977
+ lines.push('# 没有 token:读不限次数,写入类工具(write_lesson)不可用');
626
978
  }
627
979
  return `${lines.join('\n')}\n`;
628
980
  };
@@ -674,7 +1026,8 @@ async function installCodex(hookPath, bearer) {
674
1026
  }
675
1027
 
676
1028
  const rules = join(HOME, '.codex', 'AGENTS.md');
677
- ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1029
+ if (!MCP_ONLY) ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1030
+ else skip('Codex:规则块未写(--mcp-only:只注册端点)');
678
1031
  // Verified on codex-cli 0.154.0 (2026-09-15), so this is no longer a "could not
679
1032
  // confirm" note: `codex mcp list` shows misakanet enabled with the Bearer token,
680
1033
  // `codex doctor` reports config.toml parse ok + 1 streamable_http server + 0
@@ -801,11 +1154,18 @@ async function installHermes(hookPath, bearer) {
801
1154
  need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
802
1155
  return;
803
1156
  }
804
- ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1157
+ if (!MCP_ONLY) ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1158
+ else skip('Hermes:规则块未写(--mcp-only:只注册端点;SOUL.md 是它的人格文件)');
805
1159
 
806
1160
  const envKey = HERMES_ENV_KEY;
807
1161
  const lines = [` misakanet: ${YAML_START}`, ` url: ${ENDPOINT}`];
808
- if (bearer) lines.push(' headers:', ` Authorization: Bearer \${${envKey}}`);
1162
+ lines.push(' headers:');
1163
+ if (bearer) lines.push(` Authorization: Bearer \${${envKey}}`);
1164
+ // Hermes reads its headers from YAML, so these are literals rather than env templates: they are
1165
+ // hints, not secrets (a token would have to go through `.env` like the Authorization line above).
1166
+ for (const [key, value] of Object.entries(mcpHeaders('', 'hermes'))) {
1167
+ lines.push(` ${key}: ${value}`);
1168
+ }
809
1169
  lines.push(` ${YAML_END}`);
810
1170
  // No trailing newline: the three call sites below each own the one they need, and the
811
1171
  // "insert after mcp_servers:" case must NOT add one (the line it replaces already ends with
@@ -833,7 +1193,7 @@ async function installHermes(hookPath, bearer) {
833
1193
  }
834
1194
 
835
1195
  if (!bearer) {
836
- skip('Hermes:这次没有 token → 走匿名通道(5/天/IP),写入类工具不可用');
1196
+ skip('Hermes:这次没有 token → 读仍然不限次数,写入类工具不可用');
837
1197
  return;
838
1198
  }
839
1199
  const envPath = join(HOME, '.hermes', '.env');
@@ -845,10 +1205,9 @@ async function installHermes(hookPath, bearer) {
845
1205
  : `${envText}${envText && !envText.endsWith('\n') ? '\n' : ''}${assignment}\n`;
846
1206
  if (next !== envText) {
847
1207
  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 */ }
1208
+ // The standalone token file is 0600; leaving the token in a world-readable .env (default umask
1209
+ // is usually 0644) is the same leak through another door, so the mode is set at creation.
1210
+ writeText(envPath, next, { mode: 0o600 });
852
1211
  ok(`Hermes:token 写入 ${envPath}(${envKey},配置里只留 \${${envKey}} 模板)`);
853
1212
  } else {
854
1213
  ok('Hermes:token 已就绪(无改动)');
@@ -883,6 +1242,11 @@ async function installHermes(hookPath, bearer) {
883
1242
  * The token is the one thing codewhale will not take inline: `bearer_token_env_var` names
884
1243
  * an environment variable, so "install once" for this agent ends with the user exporting
885
1244
  * `MISAKANET_TOKEN` once. That is stated rather than silently half-installed.
1245
+ *
1246
+ * This is also the one agent that gets no `X-MisakaNet-*` hints: its server entry has a fixed
1247
+ * field set (`command`/`args`/`env`/`url`/timeouts/`bearer_token_env_var`) with no custom-header
1248
+ * field, and inventing one would risk the file being rejected outright. Hints are optional by
1249
+ * design — a call without them is still a normal anonymous read.
886
1250
  */
887
1251
  function codewhaleProjects() {
888
1252
  const text = readText(join(HOME, '.codewhale', 'config.toml'));
@@ -932,7 +1296,9 @@ async function installCodewhale(bearer) {
932
1296
  const projects = codewhaleProjects();
933
1297
  for (const project of projects) {
934
1298
  if (!existsSync(project)) continue;
935
- ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
1299
+ if (!MCP_ONLY) {
1300
+ ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
1301
+ } else skip('codewhale:规则块未写(--mcp-only)');
936
1302
  }
937
1303
  if (!projects.length) {
938
1304
  need('codewhale:没有受信任的项目目录(config.toml 里没有 trust_level = "trusted")→ '
@@ -949,7 +1315,8 @@ async function installOpenclaw(bearer) {
949
1315
  if (!existsSync(workspace)) {
950
1316
  need(`OpenClaw:找不到 workspace(${workspace})→ 先运行一次 openclaw 生成它`);
951
1317
  } else {
952
- ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1318
+ if (!MCP_ONLY) ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1319
+ else skip('OpenClaw:规则块未写(--mcp-only)');
953
1320
  }
954
1321
 
955
1322
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
@@ -959,8 +1326,8 @@ async function installOpenclaw(bearer) {
959
1326
  + `或手动执行 \`${manual}\``);
960
1327
  return;
961
1328
  }
962
- const entry = { url: ENDPOINT, transport: 'streamable-http' };
963
- if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
1329
+ const entry = { url: ENDPOINT, transport: 'streamable-http',
1330
+ headers: mcpHeaders(bearer, 'openclaw') };
964
1331
  data.mcp = data.mcp || {};
965
1332
  data.mcp.servers = data.mcp.servers || {};
966
1333
  if (sameJson(data.mcp.servers.misakanet, entry)) {
@@ -1017,9 +1384,9 @@ async function verify() {
1017
1384
  tokenPresent = '';
1018
1385
  }
1019
1386
  if (tokenPresent) {
1020
- ok('写入通道:token 已就绪(解除每天 5 次读限额,write_lesson 可用)');
1387
+ ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
1021
1388
  } else {
1022
- skip('写入通道:无 token(只读也完全可用,但读有 5/天/IP 限额)');
1389
+ skip('写入通道:无 token(读不限次数、只读完全可用,写入类工具不可用)');
1023
1390
  }
1024
1391
  const hookPath = join(stateDir(), 'hook.mjs');
1025
1392
  let hookPresent = false;
@@ -1034,12 +1401,14 @@ async function verify() {
1034
1401
  } else {
1035
1402
  const settingsPath = join(HOME, '.claude', 'settings.json');
1036
1403
  const settings = readJson(settingsPath, {}) || {};
1037
- const commands = Object.values(settings.hooks || {}).flat()
1404
+ // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1405
+ // that this machine is installed (and its interpreter may not even be ours).
1406
+ const commands = ourHookEntries(settings.hooks)
1038
1407
  .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1039
- .filter((c) => typeof c === 'string' && c.includes('hook.mjs'));
1408
+ .filter((c) => typeof c === 'string');
1040
1409
  if (!commands.length) {
1041
1410
  allOk = false;
1042
- need('Claude Code:钩子没装(settings.json 里没有指向 hook.mjs 的命令)');
1411
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1043
1412
  } else {
1044
1413
  const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1045
1414
  if (!existsSync(exe)) {
@@ -1095,8 +1464,8 @@ async function verify() {
1095
1464
  need(`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 `
1096
1465
  + `hermes mcp add misakanet --url ${ENDPOINT} --auth header`);
1097
1466
  } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
1098
- ok('Hermes:MCP 条目已写入配置(无 token → 匿名 5 次/天/IP)');
1099
- skip(`Hermes:想让检索不计匿名额度,重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
1467
+ ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
1468
+ skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
1100
1469
  } else {
1101
1470
  ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
1102
1471
  skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
@@ -1127,6 +1496,96 @@ async function verify() {
1127
1496
  return allOk;
1128
1497
  }
1129
1498
 
1499
+ /**
1500
+ * Hand the config back: drop containers that are empty now that our entries are gone, and delete
1501
+ * files that held nothing but our own lines.
1502
+ *
1503
+ * `--uninstall` promises to remove exactly what this installer wrote, and removing only the
1504
+ * *leaves* left the containers it had created behind. On a bare home (`{}` config, nothing else)
1505
+ * that turned into: `~/.claude.json` came back as `{"mcpServers": {}}`, `~/.claude/settings.json`
1506
+ * as `{"hooks": {}, "permissions": {"allow": []}}`, `~/.openclaw/openclaw.json` as
1507
+ * `{"mcp": {"servers": {}}}`, `~/.codewhale/mcp.json` as `{"servers": {}}`, and
1508
+ * `~/.hermes/config.yaml` gained a dangling `mcp_servers:` key. Found 2026-09-18 by the
1509
+ * packaged-tarball e2e, which compares the whole tree before and after — four of five agents
1510
+ * drifted, and only Codex (a line-based format) came back byte-exact.
1511
+ *
1512
+ * An empty object or array is semantically nothing in every format handled here, so pruning it
1513
+ * restores the *meaning* of the user's file. The *formatting* of a JSON file is not restored (the
1514
+ * install re-serialized it) — that is stated rather than pretended, and the e2e check asserts
1515
+ * semantics plus "no trace of ours" for JSON, byte equality for the line-based formats.
1516
+ */
1517
+ function pruneEmptyContainers(value) {
1518
+ if (Array.isArray(value)) {
1519
+ value.forEach(pruneEmptyContainers);
1520
+ } else if (value && typeof value === 'object') {
1521
+ for (const [key, child] of Object.entries(value)) {
1522
+ pruneEmptyContainers(child);
1523
+ if (child && typeof child === 'object' && Object.keys(child).length === 0) delete value[key];
1524
+ }
1525
+ }
1526
+ return value;
1527
+ }
1528
+
1529
+ /**
1530
+ * The `mcp_servers:` line, when nothing is left under it.
1531
+ *
1532
+ * The install adds that key itself when the file has none (the last branch of `installHermes`), so
1533
+ * removing only our block left the key dangling. Any indented key below it means the user has
1534
+ * servers of their own, and then the line stays.
1535
+ */
1536
+ function dropEmptyMcpServersKey(text) {
1537
+ const lines = text.split('\n');
1538
+ const kept = [];
1539
+ for (let i = 0; i < lines.length; i += 1) {
1540
+ const isBareKey = /^mcp_servers:[ \t]*$/.test(lines[i]);
1541
+ const hasChild = lines.slice(i + 1).some((line) => /^[ \t]+\S/.test(line));
1542
+ if (isBareKey && !hasChild) continue;
1543
+ kept.push(lines[i]);
1544
+ }
1545
+ return kept.join('\n');
1546
+ }
1547
+
1548
+ /** The tail of `--uninstall`: give emptiness back, and take blank files away. */
1549
+ function restoreEmptiedFiles() {
1550
+ // JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
1551
+ // it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
1552
+ const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
1553
+ join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json')];
1554
+ for (const file of jsonFiles) {
1555
+ const data = readJson(file, null);
1556
+ if (!data || typeof data !== 'object') continue;
1557
+ const next = `${JSON.stringify(pruneEmptyContainers(data), null, 2)}\n`;
1558
+ if (next === readText(file)) continue;
1559
+ backup(file);
1560
+ writeText(file, next);
1561
+ ok(`移除空配置块 → ${file}`);
1562
+ }
1563
+
1564
+ // Hermes' YAML: the key the install may have created.
1565
+ const hermesCfg = join(HOME, '.hermes', 'config.yaml');
1566
+ const hermesText = readText(hermesCfg);
1567
+ if (hermesText) {
1568
+ const tidied = dropEmptyMcpServersKey(hermesText);
1569
+ if (tidied !== hermesText) {
1570
+ backup(hermesCfg);
1571
+ writeText(hermesCfg, tidied);
1572
+ ok(`移除空的 mcp_servers 键 → ${hermesCfg}`);
1573
+ }
1574
+ }
1575
+
1576
+ // Files that only ever held our own lines: a blank rules file, an emptied `.env`. An empty file
1577
+ // cannot carry user information, and `backup()` never copies an empty file, so removing it is the
1578
+ // honest restore and leaves nothing dangling.
1579
+ const textFiles = [join(HOME, '.claude', 'CLAUDE.md'), join(HOME, '.codex', 'AGENTS.md'),
1580
+ join(HOME, '.codex', 'config.toml'), join(HOME, '.hermes', 'SOUL.md'),
1581
+ hermesCfg, join(HOME, '.hermes', '.env')];
1582
+ for (const file of textFiles) {
1583
+ if (!existsSync(file) || readText(file).trim() !== '') continue;
1584
+ rmSync(file, { force: true });
1585
+ ok(`删除空文件 → ${redact(file)}`);
1586
+ }
1587
+ }
1588
+
1130
1589
  function uninstall() {
1131
1590
  for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md']) {
1132
1591
  if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
@@ -1170,8 +1629,8 @@ function uninstall() {
1170
1629
  if (settings?.hooks) {
1171
1630
  let changed = false;
1172
1631
  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'));
1632
+ // Only entries we wrote: a user's own hook that happens to be named *-hook.mjs stays.
1633
+ const kept = (settings.hooks[event] || []).filter((entry) => !isOurHookEntry(entry));
1175
1634
  // Drop our permission grants too, so uninstall leaves the file as it was.
1176
1635
  if (Array.isArray(settings.permissions?.allow)) {
1177
1636
  const pruned = settings.permissions.allow.filter((tool) => !CLAUDE_ALLOWED_TOOLS.includes(tool));
@@ -1244,6 +1703,9 @@ function uninstall() {
1244
1703
  writeText(ocCfg, `${JSON.stringify(ocData, null, 2)}\n`);
1245
1704
  ok(`移除 MCP 注册 → ${ocCfg}`);
1246
1705
  }
1706
+
1707
+ // Last, not first: the containers only become empty *after* the entries above are out.
1708
+ restoreEmptiedFiles();
1247
1709
  }
1248
1710
 
1249
1711
  /**
@@ -1272,7 +1734,7 @@ function voiceStatus() {
1272
1734
  const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
1273
1735
  const buckets = settings?.hooks?.PostToolUse;
1274
1736
  if (!Array.isArray(buckets)) return 'absent';
1275
- const ours = buckets.filter((entry) => JSON.stringify(entry).includes('voice-hook'));
1737
+ const ours = buckets.filter(isOurHookEntry);
1276
1738
  if (!ours.length) return 'absent';
1277
1739
  return ours.some((entry) => entry.matcher === '*') ? 'on' : 'stale';
1278
1740
  } catch {
@@ -1335,6 +1797,11 @@ function reportValues(allOk) {
1335
1797
  ['token', token ? 'present' : 'absent'],
1336
1798
  ['permissions', permissions],
1337
1799
  ['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
1800
+ // `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
1801
+ // from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
1802
+ ['install-scope', existsSync(join(stateDir(), 'hook.mjs'))
1803
+ ? 'full'
1804
+ : (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
1338
1805
  ['voice', voiceStatus()],
1339
1806
  ['open-items', manual.length],
1340
1807
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
@@ -1450,8 +1917,19 @@ if (mode !== 'report' && !SILENT) {
1450
1917
 
1451
1918
  if (mode === 'uninstall') {
1452
1919
  uninstall();
1453
- say(render());
1454
- if (!SILENT) console.log('\n已恢复原状(每个改过的文件都有 .misakanet.bak 备份)。');
1920
+ if (MCP_ONLY) {
1921
+ // The honest failure mode of tier ①: the tool is callable, but nothing tells the agent (or the
1922
+ // user) to call it — the rules block and the first-turn announcement live in tiers ②/③. Printed
1923
+ // as a "manual step" so it survives --silent (a deployment log keeps the `!` lines).
1924
+ need('只注册了端点:你的 agent 不会自己想到去查 —— 需要你自己说「先查 MisakaNet」,'
1925
+ + '或者请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)');
1926
+ }
1927
+ say(render());
1928
+ if (!SILENT) {
1929
+ console.log('\n已移除本安装器写入的内容(规则块、MCP 注册、钩子、状态目录)。');
1930
+ console.log(`备份保留在 <被改过的文件>.misakanet.bak(例如 ${join(HOME, '.claude', 'CLAUDE.md')}.misakanet.bak)`
1931
+ + ',确认无误后可自行删除。');
1932
+ }
1455
1933
  process.exit(0);
1456
1934
  }
1457
1935
 
@@ -1516,19 +1994,127 @@ const targets = (only.length ? only : AGENTS).filter((a) => {
1516
1994
  return true;
1517
1995
  });
1518
1996
 
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);
1997
+ // Install mode used to end without any `process.exit`, so it returned **0 no matter what**:
1998
+ // "no agent detected", "registration failed", "a target could not be written" and a clean
1999
+ // install were indistinguishable, and `npx … && echo ok` asserted success for a run that
2000
+ // changed nothing (reproduced 2026-09-17 on a machine with no agent config at all).
2001
+ //
2002
+ // The contract now matches the other modes (0 fine / 1 ran but not ready / 2 could not run):
2003
+ // * 0 - at least one target's config was written (advisory items may still be listed in `!`)
2004
+ // * 1 - nothing was installed: no target detected, or every target failed
2005
+ // * 2 - the installer itself could not do its job (I/O, permissions, a blown-up config)
2006
+ //
2007
+ // The exit code deliberately answers "did the install happen", not "is everything green":
2008
+ // a run that leaves the user with working read access but no write token is a *success* with a
2009
+ // listed item. Use `--verify` / `--report --strict` for the health question — that separation
2010
+ // is what makes this signal usable in a script.
2011
+ let installed = 0;
2012
+ // Set when a stable client id was minted: shown to the user at the end (see ensureIdentity).
2013
+ let clientIdHint = '';
2014
+ /**
2015
+ * The pseudonym this run knows the machine by — an explicit `--client-id` / `MISAKANET_CLIENT_ID`, or
2016
+ * the one this run minted. This is what `X-MisakaNet-Client` carries.
2017
+ *
2018
+ * Deliberately separate from `clientIdHint`, which exists only for the closing "keep this number"
2019
+ * guidance: a user who supplied an id must not be told to keep it, but their config still has to say
2020
+ * who they are — otherwise the hint would be missing for exactly the users who took the trouble to
2021
+ * have an identity at all.
2022
+ */
2023
+ let knownClientId = '';
2024
+ /**
2025
+ * `--list-writes`: the manifest an operator needs to approve, or to undo by hand.
2026
+ *
2027
+ * `--dry-run` already says which files it would touch; this adds the two things an approval process
2028
+ * asks for — *what* each write does (new file / append a marked block / add a config key) and *how to
2029
+ * take it back* — and it is machine-readable so a ticket can carry it.
2030
+ */
2031
+ if (LIST_WRITES) {
2032
+ // Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
2033
+ // (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
2034
+ // already exist → we append or update keys; missing → we create them.
2035
+ // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
2036
+ // Windows (all three windows legs went red on the first CI run of this change), and a manifest
2037
+ // that hides a tier is worse than no manifest.
2038
+ const tierOf = (path) => {
2039
+ const base = basename(path);
2040
+ if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return 2;
2041
+ if (path.includes('.misakanet-agent')) return 3; // our own state dir
2042
+ // `settings.json` carries both the read-only grants (①) and the hooks (③); the caller adds the
2043
+ // second row only when the hooks are in scope.
2044
+ if (base === 'settings.json' && path.includes('.claude')) return 1;
2045
+ return 1; // an MCP entry in the agent's config
2046
+ };
2047
+ const rows = new Map();
2048
+ const add = (path, action, tier) => {
2049
+ const key = `${tier}|${path}`;
2050
+ if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier });
2051
+ };
2052
+ for (const agent of AGENTS) {
2053
+ if (!detect(agent)) continue;
2054
+ for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
2055
+ add(path, existsSync(path) ? 'append-or-update' : 'create', tierOf(path));
2056
+ if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
2057
+ add(path, 'append-or-update(钩子)', 3);
2058
+ }
2059
+ }
2060
+ }
2061
+ add(join(stateDir(), 'token'), 'create (0600)', 1);
2062
+ if (!MCP_ONLY) add(join(stateDir(), 'hook.mjs'), 'create', 3);
2063
+
2064
+ const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
2065
+ const list = [...rows.values()].filter((row) => (MCP_ONLY ? row.tier === 1 : true))
2066
+ .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
2067
+ for (const row of list) {
2068
+ console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
1531
2069
  }
2070
+ const shown = new Set(list.map((row) => row.tier));
2071
+ if (MCP_ONLY) {
2072
+ console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
2073
+ + '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
2074
+ } else {
2075
+ console.log(`# 本次申请 ${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')});`
2076
+ + '只想批 ① 就加 --mcp-only。');
2077
+ }
2078
+ console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
2079
+ process.exit(0);
2080
+ }
2081
+
2082
+ try {
2083
+ if (!targets.length) {
2084
+ need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
2085
+ } else {
2086
+ const hookPath = MCP_ONLY ? null : await installHook();
2087
+ if (!MCP_ONLY) stampVersion();
2088
+ const bearer = has('--no-register') ? '' : await ensureIdentity();
2089
+ for (const agent of targets) {
2090
+ // One target failing must not skip the rest: an uncaught throw used to abort the loop, so
2091
+ // the remaining agents were never touched while the run still looked fine.
2092
+ const blocked = unwritableTargets((AGENT_WRITE_PATHS[agent] || (() => []))(HOME));
2093
+ if (blocked.length) {
2094
+ // Reported before attempting, so the user gets "these files are read-only" instead of a raw
2095
+ // EACCES from four call frames deep — and `--dry-run` says it too, which is the whole point
2096
+ // of a dry run.
2097
+ need(`${agent}:这些文件改不了(只读或权限不足)→ ${redact(blocked.join('、'))}`
2098
+ + ';修好后重跑本命令,其它助手不受影响');
2099
+ continue;
2100
+ }
2101
+ try {
2102
+ if (agent === 'claude') await installClaude(hookPath, bearer);
2103
+ else if (agent === 'codex') await installCodex(hookPath, bearer);
2104
+ else if (agent === 'hermes') await installHermes(hookPath, bearer);
2105
+ else if (agent === 'openclaw') await installOpenclaw(bearer);
2106
+ else if (agent === 'codewhale') await installCodewhale(bearer);
2107
+ installed += 1;
2108
+ } catch (err) {
2109
+ need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
2110
+ + '→ 修好这个文件或权限后重跑本命令,其它助手不受影响');
2111
+ }
2112
+ }
2113
+ }
2114
+ } catch (err) {
2115
+ say(render());
2116
+ console.error(`安装没能跑完 —— 退出码 2(跑不起来,与「装不上」是两回事):${redact((err && err.message) || err)}`);
2117
+ process.exit(2);
1532
2118
  }
1533
2119
 
1534
2120
  // Under `--silent` this is the whole install-mode output: only the `!` lines survive render(), and
@@ -1536,11 +2122,30 @@ if (!targets.length) {
1536
2122
  // the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
1537
2123
  // of the same run is `--report-json`, not this text.
1538
2124
  say(render());
2125
+ if (MCP_ONLY && !SILENT) {
2126
+ // The honest failure mode of tier ①, said where the user will read it: they are not installing a
2127
+ // behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook
2128
+ // (③), are exactly what makes an agent look things up on its own.
2129
+ console.log('\n⚠️ --mcp-only:只注册了端点与只读工具放行 —— **你的 agent 不会自己想到去查**。'
2130
+ + '\n 需要你自己说「先查 MisakaNet」,或请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)。');
2131
+ }
1539
2132
  if (!SILENT) {
1540
2133
  console.log(`
1541
2134
  接下来:
1542
2135
  1) **把这个助手窗口关掉再打开一次**(新功能要重开会话才生效)
1543
2136
  2) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
1544
2137
  3) 想确认状态:npx @misaka-net/misakanet-setup --verify
1545
- 4) 想关掉:npx @misaka-net/misakanet-setup --uninstall`);
2138
+ 4) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
2139
+ + (clientIdHint
2140
+ ? `
2141
+ 5) 记不记都行:这台机器的身份编号是 ${clientIdHint}
2142
+ 想让它以后重装/换机还算同一个身份,再跑安装时加:--client-id ${clientIdHint}`
2143
+ : ''));
2144
+ }
2145
+ if (!installed) {
2146
+ // Say it in one line on stderr as well: a script needs the reason next to the exit code, and
2147
+ // "nothing was installed" is exactly the case that used to exit 0 in silence.
2148
+ console.error('\n没有装上任何助手 —— 退出码 1。上面每条 ! 都给了下一步;装好助手再重跑即可。');
2149
+ process.exit(1);
1546
2150
  }
2151
+ 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.6",
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
+ });