@misaka-net/misakanet-setup 0.6.0 → 0.7.1

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.
@@ -114,7 +114,7 @@ const SILENT = has('--silent');
114
114
  const REPORT_JSON = has('--report-json');
115
115
  const HOME = resolve(valueOf('--home', homedir()));
116
116
  const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
117
- 'cursor', 'gemini', 'copilot', 'opencode', 'kiro'];
117
+ 'cursor', 'gemini', 'copilot', 'opencode', 'kiro', 'mcode'];
118
118
 
119
119
  const done = [];
120
120
  const manual = [];
@@ -157,7 +157,7 @@ function readText(path) {
157
157
  * except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
158
158
  * failed 9 e2e legs on exactly that).
159
159
  */
160
- const VERSION = '0.6.0' // x-release-please-version
160
+ const VERSION = '0.7.1' // x-release-please-version
161
161
 
162
162
  /**
163
163
  * The shape of a client id, in one place.
@@ -185,7 +185,7 @@ const FLAGS = [
185
185
  ['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
186
186
  ['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
187
187
  ['--report-json', '同一份报告的 JSON 编码'],
188
- ['--strict', '配合 --report:NOT READY 时退出码 1(默认仍退 0)'],
188
+ ['--strict', '配合 --report:NOT READY 时退出码 1,没能验证(探测超时/代理)时 2(默认仍退 0)'],
189
189
  ['--ci', '= --report --strict'],
190
190
  ['--verify', '自检:端点可达、钩子、MCP 注册、版本新旧'],
191
191
  ['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
@@ -206,8 +206,9 @@ function printHelp() {
206
206
 
207
207
  ${FLAGS.map(([flag, desc]) => ` ${flag.padEnd(16)} ${desc}`).join('\n')}
208
208
 
209
- 退出码(安装模式):0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。
210
- 健康检查用 --verify 或 --report --strict,不要看安装模式的退出码。`);
209
+ 退出码(三档,--verify 与 --report --strict 相同):0 READY / 1 NOT READY(机器问题)/
210
+ 2 没能验证(工具给不出机器判定:探测超时、代理不可信,或报告没生成)。
211
+ 安装模式:0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。`);
211
212
  }
212
213
 
213
214
  if (has('--help') || has('-h')) {
@@ -281,6 +282,16 @@ function sameJson(a, b) {
281
282
  * read-only file (it only needs write permission on the *directory*), so the previous EACCES
282
283
  * behaviour is kept explicitly rather than silently lost.
283
284
  */
285
+ /**
286
+ * Every file this run actually wrote.
287
+ *
288
+ * The preview says which files are *in scope*; this says which ones were really touched. The
289
+ * difference is the whole point of the count a user actually reads: on an already-installed
290
+ * machine the preview listed 7 files and every one of them came back "无改动", while the header
291
+ * still said "将修改 7 个文件" — a number nobody could trust (measured 2026-10-23).
292
+ */
293
+ const writtenFiles = new Set();
294
+
284
295
  function writeText(path, text, { mode } = {}) {
285
296
  if (DRY) return;
286
297
  mkdirSync(dirname(path), { recursive: true });
@@ -326,6 +337,7 @@ function writeText(path, text, { mode } = {}) {
326
337
  try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
327
338
  throw err;
328
339
  }
340
+ writtenFiles.add(path);
329
341
  }
330
342
 
331
343
  /**
@@ -413,6 +425,86 @@ const MCP_ONLY_TARGETS = {
413
425
  },
414
426
  };
415
427
 
428
+ /**
429
+ * Display names for every target, derived from the two lists that already define them.
430
+ *
431
+ * Why a table: the "no supported assistant was found" sentence used to spell out six names by
432
+ * hand (`Claude Code / Codex / Hermes / OpenClaw / Codewhale / Cursor`) while `AGENTS` had grown
433
+ * to ten — so a machine with none of the six was told about a smaller product than the one it was
434
+ * running, and Gemini/Copilot/OpenCode/Kiro users could not recognise themselves in it (#3778).
435
+ * The five MCP-only labels are read from `MCP_ONLY_TARGETS` rather than typed a second time, and
436
+ * `supportedAgentLabels()` falls back to the id if a future target is added without a label — an
437
+ * unlabelled target is then still named, instead of silently disappearing from the sentence.
438
+ */
439
+ const AGENT_LABELS = {
440
+ claude: 'Claude Code',
441
+ codex: 'Codex',
442
+ hermes: 'Hermes',
443
+ openclaw: 'OpenClaw',
444
+ codewhale: 'Codewhale',
445
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.label])),
446
+ // Not an MCP-only target: it takes a rules block too (see `installMcode`), so its label cannot be
447
+ // read from that table.
448
+ mcode: 'MiniMax Code',
449
+ };
450
+
451
+ /**
452
+ * MiniMax Code (`mcode`) — a **plugin directory**, not a config file (issue #3777).
453
+ *
454
+ * Three facts decide this whole target, and none of them is guessable from the other clients:
455
+ *
456
+ * * mcode does not read a user-level MCP config. `~/.minimax/config.yaml`'s `mcpServers` is
457
+ * ignored (measured in #3777), so writing an entry there would look installed and register
458
+ * nothing.
459
+ * * It *does* read a **plugin directory** whose manifest is a Claude Code plugin manifest:
460
+ * `~/.minimax/plugins/misakanet/.claude-plugin/plugin.json` (source `LOCAL_CLAUDE`) makes
461
+ * `mcode plugin list` show `misakanet@local enabled`. Placing the directory is the install.
462
+ * * `mcode plugin add misakanet@local` answers `LOCAL_PLUGIN_INSTALL_UNSUPPORTED` — that is its
463
+ * refusal to *link* a local plugin, not evidence that the plugin is missing: the listing still
464
+ * shows it enabled while the directory is in place. Nothing in this installer runs that command.
465
+ *
466
+ * The manifest cannot be copied from the repository's own `.claude-plugin/plugin.json`: the npm
467
+ * package's `files` list does not ship it (`bin/`, `hook/`, `voice/`, `README.md` only), so the
468
+ * expected shape is built here. Rules are ordinary `AGENTS.md`: `~/.minimax/AGENTS.md` is its
469
+ * global scope and applies to every session. There is no hook mechanism, so tier ③ does not exist
470
+ * for this target and the install says so instead of implying behaviour changed.
471
+ *
472
+ * What this target cannot fix: mcode budgets ~2000 ms for HTTP MCP discovery and Node's `fetch`
473
+ * ignores proxy variables unless `NODE_USE_ENV_PROXY=1`. On a proxied machine the result is
474
+ * "installed but 0 tools" — a runtime fact, documented in docs/integrations/mcode.md, not
475
+ * something an installer can write around.
476
+ */
477
+ const MCODE_PLUGIN_REL = join('.minimax', 'plugins', 'misakanet', '.claude-plugin', 'plugin.json');
478
+ const MCODE_RULES_REL = join('.minimax', 'AGENTS.md');
479
+ const MCODE_DESCRIPTION = 'MisakaNet failure memory: search evidence-rated failure lessons before '
480
+ + 're-debugging, and turn new failures into shared knowledge.';
481
+
482
+ const mcodePluginPath = (home) => join(home, MCODE_PLUGIN_REL);
483
+
484
+ /** The manifest we write — the shape `mcode plugin list` reads. */
485
+ function mcodePluginDoc(bearer) {
486
+ return {
487
+ name: 'misakanet',
488
+ version: VERSION,
489
+ description: MCODE_DESCRIPTION,
490
+ mcpServers: {
491
+ misakanet: { type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'mcode') },
492
+ },
493
+ };
494
+ }
495
+
496
+ /** The `misakanet` entry in that manifest, or null when the file is absent or not JSON. */
497
+ function mcodePluginEntry() {
498
+ const doc = readJson(mcodePluginPath(HOME), null);
499
+ return doc && typeof doc === 'object' ? doc.mcpServers?.misakanet || null : null;
500
+ }
501
+
502
+
503
+ /** The supported assistants, named from `AGENTS` — never a hand-written second list. */
504
+ function supportedAgentLabels() {
505
+ return AGENTS.map((agent) => AGENT_LABELS[agent] || agent).join(' / ');
506
+ }
507
+
416
508
  /**
417
509
  * The files each assistant's install writes, so a permission problem can be reported *before*
418
510
  * anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
@@ -429,6 +521,10 @@ const AGENT_WRITE_PATHS = {
429
521
  // for the write manifest, the detection paths and the report's scope line.
430
522
  ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
431
523
  .map(([id, spec]) => [id, (home) => [spec.config(home)]])),
524
+ // Two files, two tiers: the plugin manifest registers the endpoint (①) and the global AGENTS.md
525
+ // carries the rules block (②). Listing both here is what makes the preview, `--list-writes` and
526
+ // the permission pre-check agree with `installMcode`.
527
+ mcode: (home) => [join(home, MCODE_PLUGIN_REL), join(home, MCODE_RULES_REL)],
432
528
  openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
433
529
  };
434
530
 
@@ -481,6 +577,10 @@ function buildWriteManifest(agents) {
481
577
  // make the manifest — and the preview's file count — promise a file that stays absent.
482
578
  if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
483
579
  if (!MCP_ONLY) {
580
+ // The endpoint record lives in the state directory, so it is tier ③ like everything else in
581
+ // there: `--mcp-only` must not create that directory at all (the e2e checks the directory
582
+ // itself, not just the files). The record is therefore written only when tier ③ is in scope.
583
+ add(join(stateDir(), STATE_ENDPOINT_FILE), 'create', 3, 'endpoint');
484
584
  add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
485
585
  add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
486
586
  }
@@ -517,6 +617,7 @@ function writePreview(rows) {
517
617
  rules: '规则块(会改变 agent 的行为)',
518
618
  hook: '钩子',
519
619
  token: 'MCP 凭据',
620
+ endpoint: '端点记录',
520
621
  hookScript: '钩子脚本',
521
622
  version: '版本戳',
522
623
  state: '本机状态',
@@ -524,7 +625,11 @@ function writePreview(rows) {
524
625
  const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
525
626
  const pad = (text) => (text.length >= width ? `${text} ` : text.padEnd(width));
526
627
  return [
527
- `将修改 ${list.length} 个文件${DRY ? '(--dry-run:只是预览,不写任何东西)' : ''}:`,
628
+ // "将修改" was a promise the run could not keep: on an already-installed machine every file
629
+ // in this list came back "无改动" while the header still said "将修改 7 个文件" — and that is
630
+ // the number the reader looks at. The list is a scope, so it says so; the count of files that
631
+ // were really touched is printed after the install (see writtenFiles).
632
+ `将涉及 ${list.length} 个文件${DRY ? '(--dry-run:只是预览,不写任何东西)' : ''}:`,
528
633
  ...list.map((f) => ` ${pad(f.path)}+ ${f.kinds.map((k) => labels[k]).join(' + ')}`),
529
634
  '先看:--list-writes 不改行为:--mcp-only 撤销:--uninstall',
530
635
  ].join('\n');
@@ -623,15 +728,63 @@ function hookWired() {
623
728
  return ourHookEntries(settings?.hooks).length > 0;
624
729
  }
625
730
 
731
+ /**
732
+ * Our rules block in a file, with or without its markers.
733
+ *
734
+ * The markers can be lost — a hand-edit, a merge conflict — and then the block is still ours,
735
+ * sitting in the file as plain text. Two consequences, both measured on a real machine
736
+ * (2026-10-23): the installer looked for the markers, so it appended a *second* copy of a block
737
+ * that was already there (reporting "added", i.e. success); the uninstaller removed by marker,
738
+ * so it took only one of the two away and left the other for ever (also reporting success).
739
+ * Three rounds of install/uninstall left zero residue as long as the markers were intact, which
740
+ * is why this only ever showed up after the markers went missing.
741
+ *
742
+ * So both sides look for the same thing now: the marker pair, or the block's own text.
743
+ */
744
+ function blockRegions(text, block) {
745
+ const regions = [];
746
+ // The marked region is exactly what the old installer matched — and it must stay that narrow:
747
+ // a region that also swallowed the newline in front of the block made every re-install rewrite
748
+ // the file (the e2e caught it: "the rules file was rewritten by a run that had nothing to
749
+ // change"). Taking that newline is the *uninstaller's* job, and only the uninstaller's.
750
+ const marked = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`, 'g');
751
+ for (const m of text.matchAll(marked)) {
752
+ regions.push({ start: m.index, end: m.index + m[0].length, text: m[0], marked: true });
753
+ }
754
+ const body = block.trim();
755
+ let from = 0;
756
+ for (;;) {
757
+ const at = text.indexOf(body, from);
758
+ if (at < 0) break;
759
+ const inside = regions.some((r) => at >= r.start && at < r.end);
760
+ if (!inside) {
761
+ regions.push({ start: at, end: at + body.length, text: body, marked: false });
762
+ }
763
+ from = at + body.length;
764
+ }
765
+ return regions.sort((a, b) => a.start - b.start);
766
+ }
767
+
626
768
  function injectBlock(path, block) {
627
769
  const existing = readText(path);
628
- const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
629
770
  const body = `<!-- ${START} -->\n${block.trim()}\n<!-- ${END} -->\n`;
630
- if (pattern.test(existing)) {
631
- const updated = existing.replace(pattern, () => body);
632
- if (updated === existing) return 'unchanged';
771
+ const regions = blockRegions(existing, block);
772
+ if (regions.length) {
773
+ // Keep the first copy where it is, re-wrap it in its markers, and drop the others: a file
774
+ // that ended up with two copies of the rules gets one. This is the case that used to
775
+ // append a second copy every time the installer ran.
776
+ const first = regions[0];
777
+ let out = '';
778
+ let cursor = 0;
779
+ for (const r of regions) {
780
+ out += existing.slice(cursor, r.start);
781
+ if (r === first) out += body;
782
+ cursor = r.end;
783
+ }
784
+ out += existing.slice(cursor);
785
+ if (out === existing) return 'unchanged';
633
786
  backup(path);
634
- writeText(path, updated);
787
+ writeText(path, out);
635
788
  return 'updated';
636
789
  }
637
790
  backup(path);
@@ -640,20 +793,36 @@ function injectBlock(path, block) {
640
793
  return 'added';
641
794
  }
642
795
 
643
- function stripBlock(path) {
796
+ /**
797
+ * Take our rules block back out — marked or not.
798
+ *
799
+ * The unmarked case is the one that used to leave a permanent copy behind: the block was ours,
800
+ * but without its markers neither side recognised it, so the installer duplicated it and the
801
+ * uninstaller could not see it. `blockRegions()` finds both shapes, so removal matches what
802
+ * installation now recognises.
803
+ */
804
+ function stripBlock(path, block = PROMPT_BLOCK) {
644
805
  const text = readText(path);
645
806
  if (!text) return false;
646
- // `\n?` on both sides: install writes a blank line, then the block, so consuming only the
647
- // block left the file one newline longer than it started (uninstall claimed "已恢复原状"
648
- // while `diff` showed an extra empty line).
649
- const pattern = new RegExp(`\\n?[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
650
- if (!pattern.test(text)) return false;
807
+ const regions = blockRegions(text, block);
808
+ if (!regions.length) return false;
651
809
  backup(path);
652
- const stripped = text.replace(pattern, '');
653
- if (!stripped.trim()) {
810
+ let out = '';
811
+ let cursor = 0;
812
+ for (const r of regions) {
813
+ // The install writes a blank line, then the block; consuming only the block left the file one
814
+ // newline longer than it started (uninstall claimed "已恢复原状" while `diff` showed an extra
815
+ // empty line). So the removal takes that newline with it.
816
+ let start = r.start;
817
+ if (start > 0 && text[start - 1] === '\n') start -= 1;
818
+ out += text.slice(cursor, start);
819
+ cursor = r.end;
820
+ }
821
+ out += text.slice(cursor);
822
+ if (!out.trim()) {
654
823
  if (!DRY) rmSync(path, { force: true });
655
824
  } else {
656
- writeText(path, stripped);
825
+ writeText(path, out);
657
826
  }
658
827
  return true;
659
828
  }
@@ -712,6 +881,48 @@ function declaredClientId() {
712
881
  return CLIENT_ID_SHAPE.test(found) ? found : '';
713
882
  }
714
883
 
884
+ /**
885
+ * An `X-MisakaNet-Client` value already written into a target's config.
886
+ *
887
+ * Three syntaxes, because three kinds of target carry this header: JSON (`"X-MisakaNet-Client": "…"`
888
+ * in `.claude.json`, `openclaw.json` and the `--mcp-only` configs), TOML (Codex writes it **inside**
889
+ * its one-line `http_headers = { … }` table, so it is not at the start of a line) and YAML (Hermes's
890
+ * `config.yaml`, where the key is followed by `:`). The leading `(?:^|[\s{,])` is what lets the TOML
891
+ * case match mid-line while still requiring a plausible delimiter before the key.
892
+ *
893
+ * The capture is limited to the id alphabet so a value that could close a quote or a mapping can
894
+ * never travel from this file into the next one; `CLIENT_ID_SHAPE` then rejects it on length too.
895
+ */
896
+ const CLIENT_ID_IN_CONFIG = /(?:^|[\s{,])["']?X-MisakaNet-Client["']?\s*[:=]\s*["']?([A-Za-z0-9._-]+)/m;
897
+
898
+ /**
899
+ * Adopt the client id a target's config already declares, so a re-run keeps writing the same header.
900
+ *
901
+ * What went wrong without it (#3772): the first run mints `setup-<uuid>`, writes it into every
902
+ * config and prints it — but deliberately does not persist it to `<stateDir>/client_id` (that is the
903
+ * "read a local file, send it in a request" shape #1688 removed). The second run therefore had no id
904
+ * to declare, `mcpHeaders()` dropped the header, every config compared unequal, and all of them were
905
+ * rewritten while the closing report said the same files had been installed again.
906
+ *
907
+ * The value is already in the file this run is about to rewrite, so inheriting it in place needs no
908
+ * new persistence and no file-to-network flow: it stays a file-to-file honouring of what this
909
+ * machine already told the service. The first target to adopt carries the id to the rest through
910
+ * `knownClientId`, which is what keeps every config on the machine pointing at one identity.
911
+ *
912
+ * Explicit intent and the state file win: `declaredClientId()` is checked first, the same precedence
913
+ * the register path applies. `clientIdHint` is set as well, because it only feeds the closing
914
+ * "这台机器的身份编号是 X" guidance — after this change the second run prints the identity instead
915
+ * of saying nothing, which is one of the consequences #3772 listed (the only remaining copy used to
916
+ * be buried inside a config file).
917
+ */
918
+ function adoptExistingClientId(text) {
919
+ if (declaredClientId()) return;
920
+ const found = text.match(CLIENT_ID_IN_CONFIG)?.[1] || '';
921
+ if (!CLIENT_ID_SHAPE.test(found)) return;
922
+ knownClientId = found;
923
+ clientIdHint = found;
924
+ }
925
+
715
926
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
716
927
 
717
928
  /**
@@ -726,6 +937,64 @@ function probeEndpoint() {
726
937
  return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
727
938
  }
728
939
 
940
+ /**
941
+ * The endpoint this machine's MCP entries are supposed to point at.
942
+ *
943
+ * The record comes first: it is the fact about *this machine* — what the installer wrote here.
944
+ * The env var only says what the operator has in mind right now, and the two can differ without
945
+ * anything being wrong (a mirror installed once, then verified without the variable exported).
946
+ * Comparing against the env var alone turned a healthy machine into a NOT READY one — the same
947
+ * shape as the false alarm the CLI already refuses to raise about the endpoint probe.
948
+ */
949
+ function expectedEndpoint() {
950
+ const recorded = readText(join(stateDir(), STATE_ENDPOINT_FILE)).trim();
951
+ if (recorded) return recorded;
952
+ const fromEnv = (process.env.MISAKANET_ENDPOINT || '').trim();
953
+ if (fromEnv) return fromEnv;
954
+ return CANONICAL_ENDPOINT;
955
+ }
956
+
957
+ /**
958
+ * One verdict for every MCP entry this installer writes.
959
+ *
960
+ * "The entry exists" used to be the whole check, so an entry pointing at
961
+ * `https://BROKEN.invalid/mcp` was reported as `✓ MCP 已注册(https://BROKEN.invalid/mcp)` — a
962
+ * green tick next to a URL nobody can reach, and the broken URL printed verbatim after it. The
963
+ * entry has to point at *our* endpoint, not merely have a field with the right name in it.
964
+ */
965
+ function mcpEntryState(entry, urlField) {
966
+ if (!entry) return { state: 'missing' };
967
+ const url = entry[urlField];
968
+ if (!url) return { state: 'no-url-field' };
969
+ const expected = expectedEndpoint();
970
+ if (url !== expected) return { state: 'wrong-url', url, expected };
971
+ return { state: 'ok', url };
972
+ }
973
+
974
+ /**
975
+ * The `[mcp_servers.misakanet]` table in a Codex config, if it is there.
976
+ *
977
+ * The table header is the anchor, not our comment markers: the markers can be lost (a hand-edit,
978
+ * a merge conflict) and the entry is still the entry.
979
+ */
980
+ function codexMisakanetTable(text) {
981
+ const lines = text.split('\n');
982
+ let inTable = false;
983
+ let url = '';
984
+ for (const line of lines) {
985
+ const t = line.trim();
986
+ if (t.startsWith('[')) {
987
+ if (inTable) break; // the next table starts: ours ended
988
+ inTable = t === '[mcp_servers.misakanet]';
989
+ continue;
990
+ }
991
+ if (!inTable) continue;
992
+ const m = t.match(/^url\s*=\s*"([^"]*)"/);
993
+ if (m) url = m[1];
994
+ }
995
+ return inTable ? { url } : null;
996
+ }
997
+
729
998
  /**
730
999
  * The proxy environment, and whether *this* Node will actually use it.
731
1000
  *
@@ -758,6 +1027,29 @@ function proxyProbeAdvice(proxy) {
758
1027
  + '(fetch 需要 Node ≥ 22.21.0 / 24.0.0;公司代理做 TLS 拦截时再加 `NODE_USE_SYSTEM_CA=1`)';
759
1028
  }
760
1029
 
1030
+ /**
1031
+ * How long one probe attempt may take.
1032
+ *
1033
+ * 6 s is the shipped value; `MISAKANET_PROBE_TIMEOUT_MS` exists so the test suite can prove the
1034
+ * timeout *classification* in a few hundred milliseconds instead of six seconds per attempt. It is
1035
+ * test plumbing, not a supported knob: a deployment that sets it is shortening the only honest
1036
+ * signal it has, and the cap below keeps it from turning `--verify` into a hang.
1037
+ */
1038
+ function probeTimeoutMs() {
1039
+ const raw = Number(process.env.MISAKANET_PROBE_TIMEOUT_MS || '');
1040
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60000) : 6000;
1041
+ }
1042
+
1043
+ /**
1044
+ * The probe's retries and the pause between them.
1045
+ *
1046
+ * Fixed numbers, never random: the same machine with the same network must produce the same
1047
+ * `endpoint-probe` value on two runs (#3778). A jittered backoff would make a timeout retry
1048
+ * succeed on one run and fail on the next, which is how "flaky" gets filed against a health check.
1049
+ */
1050
+ const PROBE_ATTEMPTS = 3; // one attempt + at most two retries, per the issue
1051
+ const PROBE_RETRY_DELAY_MS = 300;
1052
+
761
1053
  /**
762
1054
  * One unauthenticated MCP call.
763
1055
  *
@@ -769,6 +1061,16 @@ function proxyProbeAdvice(proxy) {
769
1061
  // Records why the last request failed, so `--verify` can tell a timeout from a 404 from an
770
1062
  // empty-but-successful answer instead of collapsing all three into "unreachable".
771
1063
  let lastRequestError = '';
1064
+ // …and *which kind* of failure it was, which is what `endpoint-probe` reports: a timeout and a
1065
+ // refused connection deserve different verdicts (#3778). Reset per request, so a stale reason from
1066
+ // an earlier call (registration, a version lookup) can never be attributed to the probe.
1067
+ let lastRequestKind = '';
1068
+ // Whether HTTP answered at all. `200 {"hello":"world"}` parses fine and yields no `tools`, and the
1069
+ // old code called that "unreachable" — the endpoint did answer, it just does not speak MCP.
1070
+ let lastResponseOk = false;
1071
+ // The body it answered with, truncated: when the answer is not a handshake, the most useful thing
1072
+ // `--verify` can print is what actually came back (`{}` is not a diagnosis).
1073
+ let lastResponsePreview = '';
772
1074
 
773
1075
  async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
774
1076
  const headers = {
@@ -780,6 +1082,11 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
780
1082
  };
781
1083
  const controller = new AbortController();
782
1084
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1085
+ // Per request, not per process: the probe must not inherit the reason an earlier call failed.
1086
+ lastRequestError = '';
1087
+ lastRequestKind = '';
1088
+ lastResponseOk = false;
1089
+ lastResponsePreview = '';
783
1090
  try {
784
1091
  const response = await fetch(urlOverride || ENDPOINT, {
785
1092
  method: 'POST',
@@ -787,7 +1094,18 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
787
1094
  signal: controller.signal,
788
1095
  body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
789
1096
  });
1097
+ // The exchange reached an HTTP peer. Whether that peer speaks MCP is decided by the body
1098
+ // below, not by this line — but "it answered with something that is not a handshake" and
1099
+ // "nothing answered" are different facts (see probeOutcome()).
1100
+ lastResponseOk = true;
790
1101
  const payload = await response.json();
1102
+ lastResponsePreview = JSON.stringify(payload).slice(0, 160);
1103
+ if (payload && payload.error) {
1104
+ // A JSON-RPC error is an answer from an MCP-speaking server, but the *call* failed. Record
1105
+ // its message so `--verify` can print the server's own words instead of "unreachable".
1106
+ lastRequestError = String(payload.error.message || JSON.stringify(payload.error)).slice(0, 200);
1107
+ lastRequestKind = 'json-rpc-error';
1108
+ }
791
1109
  const result = payload.result || {};
792
1110
  // Three response shapes are in play: `structuredContent` (this worker's tools/call),
793
1111
  // `content[0].text` (the MCP spec's text block), and plain fields (initialize, tools/list).
@@ -807,9 +1125,22 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
807
1125
  // result indistinguishable to every caller — `--verify` then reported a working endpoint as
808
1126
  // unreachable, which is the exact bug its own comment above describes. The shape of the return
809
1127
  // value is unchanged (callers depend on it); the reason is now recorded for the report.
810
- lastRequestError = (err && err.name === 'AbortError')
1128
+ //
1129
+ // The *kind* is recorded too, because it decides the verdict: `timeout` means "this machine
1130
+ // could not answer", `unreachable` means "the connection really failed" (#3778). `ETIMEDOUT`
1131
+ // is the socket-level spelling of the same thing, and undici wraps the real cause in
1132
+ // `err.cause` (`fetch failed` alone names nothing: the interesting words are `bad port`,
1133
+ // `connect ECONNREFUSED …`, `getaddrinfo EAI_AGAIN …`).
1134
+ const cause = err && err.cause;
1135
+ const code = String((cause && cause.code) || (err && err.code) || '');
1136
+ const detail = String((cause && (cause.message || cause.code)) || (err && err.message) || err);
1137
+ const timedOut = (err && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1138
+ || code === 'ETIMEDOUT'
1139
+ || /timed? ?out|timeout|aborted/i.test(`${detail} ${(err && err.message) || ''}`);
1140
+ lastRequestKind = timedOut ? 'timeout' : 'unreachable';
1141
+ lastRequestError = timedOut
811
1142
  ? `timeout after ${timeoutMs}ms`
812
- : ((err && err.message) || String(err));
1143
+ : (code && !detail.includes(code) ? `${detail}(${code})` : detail);
813
1144
  return {};
814
1145
  } finally {
815
1146
  clearTimeout(timer);
@@ -821,6 +1152,65 @@ function mcpCall(tool, toolArgs, timeoutMs = 6000, urlOverride = '') {
821
1152
  return mcpRequest('tools/call', { name: tool, arguments: toolArgs }, timeoutMs, urlOverride);
822
1153
  }
823
1154
 
1155
+ /**
1156
+ * The endpoint probe: up to `PROBE_ATTEMPTS` `tools/list` calls, retrying only on a timeout.
1157
+ *
1158
+ * Why retry at all: one stall on a slow link is not evidence about the endpoint, and a health
1159
+ * check that flips on a single stall is not usable as a gate. Why *only* timeouts: a refused
1160
+ * connection or a DNS failure is an answer, not a hiccup — retrying it just makes `--verify`
1161
+ * slower. Why a fixed pause: see `PROBE_ATTEMPTS`.
1162
+ */
1163
+ async function probeHandshake() {
1164
+ const timeoutMs = probeTimeoutMs();
1165
+ let payload = {};
1166
+ let attempts = 0;
1167
+ while (attempts < PROBE_ATTEMPTS) {
1168
+ attempts += 1;
1169
+ payload = await mcpRequest('tools/list', {}, timeoutMs, probeEndpoint());
1170
+ if ((Array.isArray(payload?.tools) ? payload.tools : []).length) break;
1171
+ if (lastRequestKind !== 'timeout') break;
1172
+ if (attempts < PROBE_ATTEMPTS) {
1173
+ await new Promise((resolve) => setTimeout(resolve, PROBE_RETRY_DELAY_MS));
1174
+ }
1175
+ }
1176
+ return { payload, attempts, timeoutMs };
1177
+ }
1178
+
1179
+ /**
1180
+ * What the probe actually proved, as one of the values `endpoint-probe` reports.
1181
+ *
1182
+ * `ok` the handshake succeeded and the server listed tools
1183
+ * `timeout` no answer within the timeout, after retries — "could not verify", which is
1184
+ * not the same claim as "the endpoint is down"
1185
+ * `unreachable` a real connection failure (ECONNREFUSED / ENOTFOUND / EAI_AGAIN / TLS …)
1186
+ * `inconclusive-proxy` a proxy is configured that this Node cannot use, and nothing better is
1187
+ * known — the probe says nothing about the machine
1188
+ * `non-mcp` HTTP answered, but the exchange produced no MCP handshake (a non-MCP body,
1189
+ * a wrong URL, a gateway, or an MCP error answer)
1190
+ *
1191
+ * The order is the contract, not an accident. `timeout` is checked before the proxy because a
1192
+ * timeout is the better classification and must never be folded into the proxy story; the proxy is
1193
+ * checked before `unreachable` because a refused *direct* connection does not prove the endpoint is
1194
+ * down on a machine whose traffic is meant to go through a proxy this process cannot see. That
1195
+ * precedence is what keeps the proxy case at "could not verify" (2) instead of sending the user to
1196
+ * debug a network `curl` reaches fine (#3778).
1197
+ */
1198
+ function probeOutcome(payload) {
1199
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
1200
+ if (tools.length) return { state: 'ok', tools: tools.length };
1201
+ // Before `lastResponseOk`: headers can arrive and the *body* can still hit the timeout (the
1202
+ // same AbortController covers both), which leaves `lastResponseOk` true while the call is a
1203
+ // timeout. Classifying that as `non-mcp` would name the wrong cause — measured on this repo's
1204
+ // own endpoint, where a slow body occasionally produced exactly that pair.
1205
+ if (lastRequestKind === 'timeout') return { state: 'timeout' };
1206
+ if (lastResponseOk || payload?.error || payload?.protocolVersion || payload?.serverInfo) {
1207
+ return { state: 'non-mcp' };
1208
+ }
1209
+ const proxy = proxyEnvironment();
1210
+ if (proxy && !proxy.enabled) return { state: 'inconclusive-proxy', proxy };
1211
+ return { state: 'unreachable' };
1212
+ }
1213
+
824
1214
  // Imperative first, and the trigger is line 1. Measured twice on real machines: the older
825
1215
  // polite paragraph was followed by one agent and ignored by the next, while a short numbered
826
1216
  // imperative list was followed immediately — a model that skips MCP tools entirely (reported
@@ -1083,6 +1473,11 @@ function mcpEntryPresent(agent) {
1083
1473
  codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
1084
1474
  ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
1085
1475
  .map(([id, spec]) => [id, () => readText(spec.config(HOME))])),
1476
+ // The manifest *is* the registry here, and the URL is part of the answer: a plugin pointing at
1477
+ // a dead mirror is not "registered" for the question this function asks (`install-scope`).
1478
+ mcode: () => (mcpEntryState(mcodePluginEntry(), 'url').state === 'ok'
1479
+ ? readText(mcodePluginPath(HOME))
1480
+ : ''),
1086
1481
  }[agent]?.() || '';
1087
1482
  return text.includes('misakanet') && text !== 'null';
1088
1483
  }
@@ -1095,14 +1490,21 @@ function detect(agent) {
1095
1490
  openclaw: ['.openclaw'],
1096
1491
  codewhale: ['.codewhale'],
1097
1492
  ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.detect])),
1493
+ // Both names are in circulation: `~/.minimax` is the current one, `~/.minimax-code` the older
1494
+ // directory some installs still have (issue #3777). Either is evidence the CLI was here.
1495
+ mcode: ['.minimax', '.minimax-code'],
1098
1496
  }[agent] || [];
1099
1497
  return paths.some((p) => existsSync(join(HOME, p)));
1100
1498
  }
1101
1499
 
1102
1500
  async function installClaude(hookPath, bearer) {
1103
1501
  const cfg = join(HOME, '.claude.json');
1502
+ const existing = readText(cfg);
1503
+ // Before building the entry: a re-run must declare the id this file already carries, or the
1504
+ // comparison below sees a different entry and "deletes" the header (#3772).
1505
+ adoptExistingClientId(existing);
1104
1506
  const data = readJson(cfg, null);
1105
- if (data === null && readText(cfg)) {
1507
+ if (data === null && existing) {
1106
1508
  need(`Claude Code:${cfg} 不是合法 JSON → 请手动加入 mcpServers.misakanet`);
1107
1509
  return;
1108
1510
  }
@@ -1267,6 +1669,8 @@ function hasTopLevelKey(text, key) {
1267
1669
  async function installCodex(hookPath, bearer) {
1268
1670
  const cfg = join(HOME, '.codex', 'config.toml');
1269
1671
  let text = readText(cfg);
1672
+ // Inherit before `codexTable()` rebuilds the inline table from mcpHeaders() (#3772).
1673
+ adoptExistingClientId(text);
1270
1674
  let changed = false;
1271
1675
 
1272
1676
  const topPattern = new RegExp(`^[ \\t]*#\\s*${TOP_START}\\s*$\\n?[\\s\\S]*?^[ \\t]*#\\s*${TOP_END}\\s*$\\n?`, 'm');
@@ -1316,6 +1720,16 @@ async function installCodex(hookPath, bearer) {
1316
1720
  }
1317
1721
 
1318
1722
  const STATE_VERSION_FILE = 'version';
1723
+ /**
1724
+ * The endpoint this machine was pointed at, written at install time.
1725
+ *
1726
+ * `--verify` has to answer "does this machine point at *our* endpoint?", and on a self-hosted or
1727
+ * mirrored install that endpoint is not the canonical one. Without a record of what was written,
1728
+ * verifying without re-exporting `MISAKANET_ENDPOINT` would compare the mirror URL against the
1729
+ * canonical one and report a mismatch that is not there — so the record is what makes the check
1730
+ * honest instead of merely strict.
1731
+ */
1732
+ const STATE_ENDPOINT_FILE = 'endpoint';
1319
1733
 
1320
1734
  const REGISTRY_LATEST = process.env.MISAKANET_REGISTRY_URL
1321
1735
  || 'https://registry.npmjs.org/@misaka-net%2fmisakanet-setup/latest';
@@ -1330,6 +1744,24 @@ const REGISTRY_LATEST = process.env.MISAKANET_REGISTRY_URL
1330
1744
  * `installed_at` is only refreshed when the version actually changes, so a re-run of the same
1331
1745
  * version does not reset the 14-day timer the user is relying on for peace and quiet.
1332
1746
  */
1747
+ /**
1748
+ * Record the endpoint this install wrote, so `--verify` can tell "our endpoint" from "a URL".
1749
+ */
1750
+ function stampEndpoint() {
1751
+ const file = join(stateDir(), STATE_ENDPOINT_FILE);
1752
+ if (readText(file).trim() === ENDPOINT) {
1753
+ ok(`端点记录已存在(${ENDPOINT})`);
1754
+ return;
1755
+ }
1756
+ if (DRY) {
1757
+ ok(`会写入端点记录 → ${file}(${ENDPOINT})`);
1758
+ return;
1759
+ }
1760
+ mkdirSync(stateDir(), { recursive: true });
1761
+ writeText(file, `${ENDPOINT}\n`);
1762
+ ok(`端点记录 → ${file}(${ENDPOINT})`);
1763
+ }
1764
+
1333
1765
  function stampVersion() {
1334
1766
  const file = join(stateDir(), STATE_VERSION_FILE);
1335
1767
  const existing = readJson(file, null);
@@ -1424,6 +1856,9 @@ function openclawWorkspaces() {
1424
1856
  async function installHermes(hookPath, bearer) {
1425
1857
  const cfg = join(HOME, '.hermes', 'config.yaml');
1426
1858
  const text = readText(cfg);
1859
+ // The YAML block below is rebuilt from mcpHeaders() every run, so the id has to come from this
1860
+ // file first or re-running would drop the header (#3772).
1861
+ adoptExistingClientId(text);
1427
1862
  const rules = join(HOME, '.hermes', 'SOUL.md');
1428
1863
  if (!text) {
1429
1864
  need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
@@ -1595,6 +2030,9 @@ async function installOpenclaw(bearer) {
1595
2030
  }
1596
2031
 
1597
2032
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
2033
+ const raw = readText(cfg);
2034
+ // Same inheritance as the other JSON targets: the entry is rebuilt from mcpHeaders() (#3772).
2035
+ adoptExistingClientId(raw);
1598
2036
  const data = readJson(cfg, null);
1599
2037
  if (data === null) {
1600
2038
  need(`OpenClaw:读不到或解析不了 ${cfg} → 先运行一次 openclaw 生成配置,`
@@ -1626,8 +2064,11 @@ async function installOpenclaw(bearer) {
1626
2064
  async function installMcpOnly(agent, bearer) {
1627
2065
  const spec = MCP_ONLY_TARGETS[agent];
1628
2066
  const cfg = spec.config(HOME);
2067
+ const raw = readText(cfg);
2068
+ // All five MCP-only targets share this path; adopt before spec.entry() builds mcpHeaders() (#3772).
2069
+ adoptExistingClientId(raw);
1629
2070
  const data = readJson(cfg, null);
1630
- if (data === null && readText(cfg)) {
2071
+ if (data === null && raw) {
1631
2072
  need(`${spec.label}:${cfg} 不是合法 JSON${spec.parseNote ? `(${spec.parseNote})` : ''}`
1632
2073
  + ` → 请手动加入 ${spec.container}.misakanet(形状见 https://misakanet.org/mcp 的客户端文档)`);
1633
2074
  return;
@@ -1647,6 +2088,65 @@ async function installMcpOnly(agent, bearer) {
1647
2088
  need(`${spec.label}:${spec.manual}`);
1648
2089
  }
1649
2090
 
2091
+ /**
2092
+ * MiniMax Code: write the plugin manifest, then the rules block in its global `AGENTS.md`.
2093
+ *
2094
+ * Two rules from the comment on `MCODE_PLUGIN_REL` drive the whole function:
2095
+ *
2096
+ * * **the file may not be ours.** A plugin directory is a user-owned space, so an existing
2097
+ * manifest with a different `name` (or one that is not JSON) is *preserved* and reported as a
2098
+ * manual step — overwriting it would delete someone else's plugin to install ours.
2099
+ * * **`--mcp-only` is tier ①.** The manifest is the endpoint registration; `~/.minimax/AGENTS.md`
2100
+ * is a rules block (②), so it is skipped there — the same split every other target follows.
2101
+ *
2102
+ * Idempotent through `sameJson` (an editor that reorders keys is not a change) and through
2103
+ * `adoptExistingClientId`, so a re-run without `--client-id` keeps the header the file already
2104
+ * declares (#3772).
2105
+ */
2106
+ async function installMcode(bearer) {
2107
+ const plugin = mcodePluginPath(HOME);
2108
+ const existing = readText(plugin);
2109
+ // Before building the entry: a re-run must declare the id this manifest already carries.
2110
+ if (existing) adoptExistingClientId(existing);
2111
+ const wanted = mcodePluginDoc(bearer);
2112
+
2113
+ if (!existing) {
2114
+ writeText(plugin, `${JSON.stringify(wanted, null, 2)}\n`);
2115
+ ok(`MiniMax Code:写入插件清单 → ${plugin}`);
2116
+ } else {
2117
+ const current = readJson(plugin, null);
2118
+ if (current === null) {
2119
+ need(`MiniMax Code:${plugin} 不是合法 JSON → 已原样保留;请手动修正或删掉它,再重跑本命令`);
2120
+ return;
2121
+ }
2122
+ if (current?.name !== 'misakanet') {
2123
+ need(`MiniMax Code:${plugin} 已存在且不是本安装器写的(name=${JSON.stringify(current?.name ?? null)})`
2124
+ + ' → 已原样保留;要装 MisakaNet 请先把这个插件目录移走,再重跑本命令');
2125
+ return;
2126
+ }
2127
+ if (sameJson(current, wanted)) {
2128
+ ok('MiniMax Code:插件清单已是目标内容(无改动)');
2129
+ } else {
2130
+ backup(plugin);
2131
+ writeText(plugin, `${JSON.stringify(wanted, null, 2)}\n`);
2132
+ ok(`MiniMax Code:更新插件清单 → ${plugin}`);
2133
+ }
2134
+ }
2135
+
2136
+ const rules = join(HOME, MCODE_RULES_REL);
2137
+ if (!MCP_ONLY) ok(`MiniMax Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
2138
+ else skip('MiniMax Code:规则块未写(--mcp-only:只放插件清单)');
2139
+
2140
+ // Said out loud for the same reason Cursor's and Gemini's layers are: an install that looks
2141
+ // complete and has no behaviour layer is the failure this installer refuses to hide.
2142
+ skip('MiniMax Code:没有 Claude Code 那种钩子(mcode 没有 hook 机制)'
2143
+ + ' → 规则块在 ~/.minimax/AGENTS.md(每个会话生效),自动沉淀那一步做不到');
2144
+ need('MiniMax Code:重启后跑 `mcode plugin list` 复核(应显示 misakanet@local enabled);'
2145
+ + '公司代理下还要设 NODE_USE_ENV_PROXY=1 —— 它的 HTTP MCP 发现预算约 2000ms,'
2146
+ + '不设会表现为"装上了但 0 个工具"(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED '
2147
+ + '不是失败,插件目录在位即为生效)');
2148
+ }
2149
+
1650
2150
  // The last endpoint probe, recorded so `--report` can print it without probing twice.
1651
2151
  let lastProbe = { reachable: false, tools: 0, note: '' };
1652
2152
 
@@ -1676,8 +2176,10 @@ async function verify() {
1676
2176
  // written" (our own footprint). A machine whose endpoint answers and whose workspace is
1677
2177
  // read-only is **usable** — and the old verdict called it NOT READY.
1678
2178
  let usable = true;
1679
- // The probe has three answers, not two: a probe that could not run is not a probe that failed.
1680
- // "unknown" is reported as unknown, and only `--strict` refuses to pass on it.
2179
+ // The probe has three answers, not two: 'ready' (a handshake), 'unverified' (timeout, or a
2180
+ // proxy this Node cannot use — the tool could not answer) and 'not-ready' (unreachable, or HTTP
2181
+ // answered without MCP — a real problem with the machine or the address). `unverified` is the
2182
+ // one #3778 added: it is not READY, and it is not a machine verdict either.
1681
2183
  let probeState = 'ready';
1682
2184
  // The install segment is judged on its own: a file we cannot write is a fact about our
1683
2185
  // footprint, and it says nothing about the endpoint.
@@ -1692,11 +2194,12 @@ async function verify() {
1692
2194
  // reported the rate-limit answer as "endpoint unreachable": the endpoint returns HTTP 200
1693
2195
  // with the error inside the JSON-RPC result, so a user who had used their quota was told their
1694
2196
  // network was broken (found 2026-09-15 by running the agent chain test on this machine).
1695
- const probe = await mcpRequest('tools/list', {}, 6000, probeEndpoint());
1696
- const tools = Array.isArray(probe?.tools) ? probe.tools : [];
1697
- lastProbe = { reachable: tools.length > 0, tools: tools.length };
1698
- if (tools.length) {
1699
- ok(`端点可达:${ENDPOINT}(MCP 握手成功,${tools.length} 个工具)`);
2197
+ const { payload: probe, attempts, timeoutMs } = await probeHandshake();
2198
+ const outcome = probeOutcome(probe);
2199
+ const tools = outcome.state === 'ok' ? outcome.tools : 0;
2200
+ lastProbe = { reachable: tools > 0, tools, note: outcome.state };
2201
+ if (lastProbe.reachable) {
2202
+ ok(`端点可达:${ENDPOINT}(MCP 握手成功,${tools} 个工具)`);
1700
2203
 
1701
2204
  // Surface the permission gap: an install that is otherwise perfect still fails the user's first
1702
2205
  // search if the host refuses the tool call, and that is invisible from the config files alone.
@@ -1712,39 +2215,58 @@ async function verify() {
1712
2215
  + '重跑安装命令即可放行');
1713
2216
  }
1714
2217
  } catch { /* a settings file we cannot read is reported by the Claude checks below */ }
1715
- } else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
2218
+ } else if (outcome.state === 'non-mcp') {
1716
2219
  allOk = false;
1717
2220
  usable = false;
1718
2221
  probeState = 'not-ready';
1719
- need(`端点可达但握手异常:${JSON.stringify(probe).slice(0, 120)}`);
2222
+ // What came back, not `{}`: `200 {"hello":"world"}` is the shape that used to be reported as
2223
+ // "unreachable" and send the reader after a network that was working the whole time.
2224
+ need(`端点可达但响应不是 MCP(${lastResponsePreview || JSON.stringify(probe).slice(0, 160)})`
2225
+ + ' → 地址指错了,或中间有网关/门户在应答;读课程会静默失败'
2226
+ + (lastRequestError ? `;最近一次失败原因:${lastRequestError}` : ''));
2227
+ } else if (outcome.state === 'timeout') {
2228
+ // Timeout is "could not verify", full stop. It is never folded into the proxy story (that is
2229
+ // a different doubt) and never into "unreachable" (that is a claim we cannot make): the exit
2230
+ // code for it is 2, and 2 is not a machine verdict (#3778).
2231
+ allOk = false;
2232
+ usable = false;
2233
+ probeState = 'unverified';
2234
+ need(`端点探测超时:${ENDPOINT}(${attempts} 次尝试,每次 ${timeoutMs}ms;`
2235
+ + `最近一次失败原因:${lastRequestError || `timeout after ${timeoutMs}ms`})`
2236
+ + ' → 这不说明机器坏了,是工具没能拿到答案;先确认网络/代理'
2237
+ + '(`curl -sS https://misakanet.org/mcp`),再重跑 --verify');
2238
+ } else if (outcome.state === 'inconclusive-proxy') {
2239
+ // Inconclusive rather than unreachable — and now a hard "could not verify" in every mode, not
2240
+ // only under `--strict`: the old interactive path exited 0 while printing "the probe is
2241
+ // untrustworthy", which is a READY a script cannot tell from a healthy machine (#3778).
2242
+ allOk = false;
2243
+ usable = false;
2244
+ probeState = 'unverified';
2245
+ need(`端点探测不可信(inconclusive-proxy —— 最近一次失败原因:`
2246
+ + `${lastRequestError || '没有收到响应'}):${proxyProbeAdvice(outcome.proxy)}`);
1720
2247
  } else {
1721
- const proxy = proxyEnvironment();
1722
- if (proxy && !proxy.enabled) {
1723
- // Inconclusive rather than unreachable. Interactive runs warn (a visible `!` that does not
1724
- // flip the verdict), because the install itself is fine and the user's agent may well be
1725
- // reaching the endpoint through that proxy. `--strict` / `--ci` keep a hard verdict: a gate
1726
- // that cannot tell must not pass silently, and CI machines are the ones actually configured
1727
- // to make the probe trustworthy.
1728
- lastProbe = { reachable: false, tools: 0, note: 'inconclusive-proxy' };
1729
- probeState = 'unknown';
1730
- if (STRICT) {
1731
- allOk = false;
1732
- usable = false;
1733
- need(`端点探测不可信,且 --strict 不接受"说不清":${proxyProbeAdvice(proxy)}`);
1734
- } else {
1735
- need(`端点探测不可信(不是"不可达"):${proxyProbeAdvice(proxy)}`);
1736
- }
1737
- } else {
1738
- allOk = false;
1739
- usable = false;
1740
- probeState = 'not-ready';
1741
- need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
1742
- + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1743
- }
2248
+ allOk = false;
2249
+ usable = false;
2250
+ probeState = 'not-ready';
2251
+ need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
2252
+ + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1744
2253
  }
1745
2254
  // The endpoint segment ends here. Everything below is about our own footprint: the files we
1746
2255
  // write, the entries we register, the token we keep.
1747
2256
  const segmentEnd = { done: done.length, manual: manual.length, skipped: skipped.length };
2257
+ // Zero targets (#3778). This is the honest answer to "is the install complete": there was
2258
+ // nothing to install into, so nothing was installed. The old code only ever called
2259
+ // `installFailed()` inside the per-agent checks, so with no agent at all not one line ran and
2260
+ // `install: complete` / `verify: READY` were reported for an untouched home — the exact
2261
+ // self-contradiction the issue was filed for (`detected-agents: []` next to
2262
+ // `install-complete: complete`). Judged on "no target in AGENTS is detected", not on one
2263
+ // hard-coded directory: any of the supported assistants in `AGENTS` counts.
2264
+ const detectedAgents = AGENTS.filter((agent) => detect(agent));
2265
+ if (!detectedAgents.length) {
2266
+ installFailed();
2267
+ need('没有检测到任何受支持的助手 —— 因此什么都没装'
2268
+ + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2269
+ }
1748
2270
  const tokenFile = join(stateDir(), 'token');
1749
2271
  let tokenPresent = '';
1750
2272
  try {
@@ -1808,7 +2330,18 @@ async function verify() {
1808
2330
  need(canCreateHere(cfg)
1809
2331
  ? `Claude Code:MCP 未注册(${cfg})`
1810
2332
  : `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
1811
- } else ok(`Claude Code:MCP 已注册(${entry.url})`);
2333
+ } else {
2334
+ const verdict = mcpEntryState(entry, 'url');
2335
+ if (verdict.state === 'wrong-url') {
2336
+ installFailed();
2337
+ need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2338
+ } else if (verdict.state === 'no-url-field') {
2339
+ installFailed();
2340
+ need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2341
+ } else {
2342
+ ok(`Claude Code:MCP 已注册(${verdict.url})`);
2343
+ }
2344
+ }
1812
2345
  }
1813
2346
  }
1814
2347
  // OpenClaw is only reported when the user actually has it: telling a machine without
@@ -1868,6 +2401,76 @@ async function verify() {
1868
2401
  skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
1869
2402
  }
1870
2403
  }
2404
+ // Codex and codewhale: reported only when the user has them, like every other target. They were
2405
+ // the two gaps in the integrity check (measured 2026-10-23): `--verify` had no row for them at
2406
+ // all, so a machine whose Codex entry pointed at `https://BROKEN.invalid/mcp` and whose rules
2407
+ // block had lost its markers still reported READY with exit code 0 — while `--report`'s
2408
+ // `detected-agents` listed codex and codewhale as present, and the gate let it through.
2409
+ if (detect('codex')) {
2410
+ const rules = join(HOME, '.codex', 'AGENTS.md');
2411
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
2412
+ installFailed();
2413
+ need(canCreateHere(rules)
2414
+ ? `Codex:规则块没装(${rules})→ 重跑安装命令`
2415
+ : `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2416
+ } else {
2417
+ ok('Codex:规则块已装');
2418
+ }
2419
+ const cfg = join(HOME, '.codex', 'config.toml');
2420
+ const toml = readText(cfg);
2421
+ const table = codexMisakanetTable(toml);
2422
+ const verdict = mcpEntryState(table, 'url');
2423
+ if (verdict.state === 'missing') {
2424
+ installFailed();
2425
+ need(canCreateHere(cfg)
2426
+ ? `Codex:MCP 未注册(${cfg})→ 重跑安装命令`
2427
+ : `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2428
+ } else if (verdict.state === 'wrong-url') {
2429
+ installFailed();
2430
+ need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2431
+ } else if (verdict.state === 'no-url-field') {
2432
+ installFailed();
2433
+ need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2434
+ } else {
2435
+ ok(`Codex:MCP 已注册(${verdict.url})`);
2436
+ }
2437
+ // The top-level key is what makes Codex speak streamable-http at all: without it the entry
2438
+ // above is present and still unusable.
2439
+ if (!hasTopLevelKey(toml, 'experimental_use_rmcp_client')) {
2440
+ installFailed();
2441
+ need(`Codex:config.toml 里没有顶级键 experimental_use_rmcp_client(${cfg})`
2442
+ + '→ Codex 连不上 streamable-http,重跑安装命令即可补上');
2443
+ }
2444
+ }
2445
+ if (detect('codewhale')) {
2446
+ const cfg = join(HOME, '.codewhale', 'mcp.json');
2447
+ const entry = (readJson(cfg, null) || {}).servers?.misakanet;
2448
+ const verdict = mcpEntryState(entry, 'url');
2449
+ if (verdict.state === 'missing') {
2450
+ installFailed();
2451
+ need(canCreateHere(cfg)
2452
+ ? `codewhale:MCP 未注册(${cfg})→ 重跑安装命令`
2453
+ : `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2454
+ } else if (verdict.state === 'wrong-url') {
2455
+ installFailed();
2456
+ need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2457
+ } else if (verdict.state === 'no-url-field') {
2458
+ installFailed();
2459
+ need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2460
+ } else {
2461
+ ok(`codewhale:MCP 已注册(${verdict.url})`);
2462
+ }
2463
+ // Its rules blocks live in the projects it trusts, so that is where they are checked.
2464
+ for (const project of codewhaleProjects()) {
2465
+ const rules = join(project, 'AGENTS.md');
2466
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
2467
+ installFailed();
2468
+ need(`codewhale:规则块没装(${rules})→ 重跑安装命令`);
2469
+ } else {
2470
+ ok(`codewhale:规则块已装(${rules})`);
2471
+ }
2472
+ }
2473
+ }
1871
2474
  // The MCP-only targets are reported only when the user actually has them, like OpenClaw and
1872
2475
  // Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
1873
2476
  // the client has *loaded* that file cannot be confirmed from here, and the line says so.
@@ -1880,13 +2483,60 @@ async function verify() {
1880
2483
  need(canCreateHere(cfg)
1881
2484
  ? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
1882
2485
  : `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
1883
- } else if (!entry[spec.urlField]) {
2486
+ } else {
2487
+ const verdict = mcpEntryState(entry, spec.urlField);
2488
+ if (verdict.state === 'no-url-field') {
2489
+ installFailed();
2490
+ need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
2491
+ + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
2492
+ } else if (verdict.state === 'wrong-url') {
2493
+ installFailed();
2494
+ need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2495
+ } else {
2496
+ ok(`${spec.label}:MCP 已注册(${verdict.url})`);
2497
+ skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
2498
+ }
2499
+ }
2500
+ }
2501
+ // MiniMax Code: a plugin directory + a rules file, so "installed" is two facts and both are
2502
+ // checked. The manifest has to be valid JSON with our entry at the right URL; a file that a
2503
+ // hand-edit turned into `{}` is exactly the state a re-run fixes. `mcode plugin add` refuses
2504
+ // local plugins on purpose (LOCAL_PLUGIN_INSTALL_UNSUPPORTED), so its exit code is not evidence
2505
+ // either way — the directory is, and that is what this reads.
2506
+ if (detect('mcode')) {
2507
+ const rules = join(HOME, MCODE_RULES_REL);
2508
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
2509
+ installFailed();
2510
+ need(canCreateHere(rules)
2511
+ ? `MiniMax Code:规则块没装(${rules})→ 重跑安装命令`
2512
+ : `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2513
+ } else {
2514
+ ok('MiniMax Code:规则块已装');
2515
+ }
2516
+ const plugin = mcodePluginPath(HOME);
2517
+ const doc = readJson(plugin, null);
2518
+ if (doc === null) {
1884
2519
  installFailed();
1885
- need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
1886
- + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
2520
+ need(canCreateHere(plugin)
2521
+ ? `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`
2522
+ : `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
1887
2523
  } else {
1888
- ok(`${spec.label}:MCP 已注册(${entry[spec.urlField]})`);
1889
- skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
2524
+ const verdict = mcpEntryState(doc.mcpServers?.misakanet, 'url');
2525
+ if (verdict.state === 'missing') {
2526
+ installFailed();
2527
+ need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`);
2528
+ } else if (verdict.state === 'no-url-field') {
2529
+ installFailed();
2530
+ need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`);
2531
+ } else if (verdict.state === 'wrong-url') {
2532
+ installFailed();
2533
+ need(`MiniMax Code:插件清单指向的端点不对(${verdict.url},应为 ${verdict.expected})(${plugin})`
2534
+ + '→ 重跑安装命令');
2535
+ } else {
2536
+ ok(`MiniMax Code:插件清单已注册(${verdict.url})`);
2537
+ skip('MiniMax Code:只能确认"插件目录在位",无法确认 mcode 是否已加载 → 跑 `mcode plugin list` 复核'
2538
+ + '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)');
2539
+ }
1890
2540
  }
1891
2541
  }
1892
2542
  // Version: what is installed, and whether the registry has moved on. Read-only here — the
@@ -1915,6 +2565,12 @@ async function verify() {
1915
2565
  allOk,
1916
2566
  usable,
1917
2567
  probeState,
2568
+ // `true` only for the probe answers that are not a verdict about the machine (timeout, and a
2569
+ // proxy this Node cannot use). It is what turns the exit code from 1 into 2 (#3778).
2570
+ unverified: probeState === 'unverified',
2571
+ // A home with none of the supported targets: distinct from "detected, but our files are missing",
2572
+ // and the only case where `install-scope` is forced to `none` in the report.
2573
+ zeroTargets: !detectedAgents.length,
1918
2574
  install: installOk ? 'complete' : 'incomplete',
1919
2575
  // The install segment's open items, counted on their own: the report's `open-items` is every
1920
2576
  // manual item, this is the ones that belong to our footprint.
@@ -1932,21 +2588,49 @@ async function verify() {
1932
2588
  * wrong thing.
1933
2589
  */
1934
2590
  function usabilityText(verdict) {
1935
- if (verdict.probeState === 'not-ready') return '不就绪 —— 端点不可达或握手异常';
1936
- if (verdict.probeState === 'unknown') {
1937
- return STRICT
1938
- ? '不就绪 —— 探测不可信,--strict 不接受"说不清"'
1939
- : '说不清 —— 探测不可信(不等于不可达)';
2591
+ if (verdict.probeState === 'unverified') {
2592
+ // The third answer (#3778): neither READY nor a machine verdict. Saying "NOT READY" here is
2593
+ // the false alarm the issue is about; saying "ready" is a lie a script cannot detect.
2594
+ return '没能验证 —— 探测超时或代理不可信(不等于端点不可达,也不等于机器不健康)';
2595
+ }
2596
+ if (verdict.probeState === 'not-ready') {
2597
+ // "unreachable" and "answered, but not MCP" are both not-ready and still different facts:
2598
+ // the second one means the address or something in front of it is wrong, and telling the
2599
+ // reader "端点不可达" about a host that just answered would send them to the wrong fix.
2600
+ return lastProbe.note === 'non-mcp'
2601
+ ? '不就绪 —— 端点有应答,但响应不是 MCP(地址指错,或中间有网关/门户)'
2602
+ : '不就绪 —— 端点不可达或握手异常';
1940
2603
  }
1941
2604
  if (!verdict.usable) return '不就绪 —— 端点可达,但只读工具没放行,第一次检索会被拦下';
1942
2605
  return '就绪 —— 端点可达,检索与读取可用';
1943
2606
  }
1944
2607
 
1945
2608
  function installText(verdict) {
2609
+ if (verdict.zeroTargets) {
2610
+ return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
2611
+ }
1946
2612
  if (verdict.install === 'complete') return '完整 —— 我们偏好的文件都写好了';
1947
2613
  return `不完整 —— ${verdict.openItems} 项需要手动处理(上面每条 ! 都给了成因和做法)`;
1948
2614
  }
1949
2615
 
2616
+ /**
2617
+ * The three-code contract, in one place so `--verify` and `--report --strict` cannot disagree.
2618
+ *
2619
+ * 0 READY
2620
+ * 1 NOT READY — a machine problem (zero targets, our files missing, the endpoint really
2621
+ * unreachable, or an address that does not speak MCP)
2622
+ * 2 could not verify — the tool could not produce a machine verdict (a probe timeout, a proxy
2623
+ * this Node cannot use, or the report/CLI crashing outright)
2624
+ *
2625
+ * `2` outranks `1` only when *nothing else* is wrong: if the install segment is incomplete, that
2626
+ * is a real machine verdict regardless of what the probe managed to do, and it stays `1`. That is
2627
+ * why the check is `install === 'complete'` rather than `!allOk`.
2628
+ */
2629
+ function exitCodeFor(verdict) {
2630
+ if (verdict.allOk && verdict.openItems === 0) return 0;
2631
+ return (verdict.unverified && verdict.install === 'complete') ? 2 : 1;
2632
+ }
2633
+
1950
2634
  /**
1951
2635
  * The verify output: two sections, because the two questions have two answers.
1952
2636
  *
@@ -2071,7 +2755,7 @@ function restoreEmptiedFiles() {
2071
2755
  }
2072
2756
 
2073
2757
  function uninstall() {
2074
- for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md']) {
2758
+ for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md', '.minimax/AGENTS.md']) {
2075
2759
  if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
2076
2760
  }
2077
2761
  // Both candidates: a block may have been written before the workspace was read from config.
@@ -2111,6 +2795,16 @@ function uninstall() {
2111
2795
  ok(`移除 MCP 注册 → ${cfg}`);
2112
2796
  }
2113
2797
  }
2798
+ // MiniMax Code's plugin directory is ours outright (the install creates it), so uninstall takes
2799
+ // the whole subtree back. Only *our* directory: `~/.minimax/plugins/` is the user's namespace and
2800
+ // stays even when it ends up empty — deleting it would remove a place their other plugins live.
2801
+ const mcodeDir = join(HOME, '.minimax', 'plugins', 'misakanet');
2802
+ if (existsSync(mcodeDir)) {
2803
+ try {
2804
+ if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
2805
+ ok(`移除插件目录 → ${mcodeDir}`);
2806
+ } catch { /* best effort */ }
2807
+ }
2114
2808
  const voiceDir = join(stateDir(), 'voice');
2115
2809
  if (existsSync(voiceDir)) {
2116
2810
  try {
@@ -2151,14 +2845,25 @@ function uninstall() {
2151
2845
  for (const [s, e] of [[START, END], [TOP_START, TOP_END]]) {
2152
2846
  text = text.replace(new RegExp(`^[ \\t]*#\\s*${s}\\s*$\\n?[\\s\\S]*?^[ \\t]*#\\s*${e}\\s*$\\n?`, 'm'), '');
2153
2847
  }
2154
- if (text && text !== before) {
2848
+ if (text !== before) {
2849
+ // `text &&` used to guard this write, and that guard is the bug: when our blocks were the
2850
+ // *only* thing in the file, removing them left an empty string, the guard said no, and the
2851
+ // file was left exactly as it was — so the MCP entry survived the uninstall while the output
2852
+ // said "已移除本安装器写入的内容(MCP 注册…)" (measured 2026-10-23). An empty file that is
2853
+ // ours to remove goes away with the blocks; the rule the rules-block path already follows.
2155
2854
  backup(toml);
2156
- writeText(toml, text);
2157
- ok(`移除 MCP 表 → ${toml}`);
2855
+ if (!text.trim()) {
2856
+ if (!DRY) rmSync(toml, { force: true });
2857
+ ok(`移除 MCP 表(文件里只剩我们的内容 → 一并删除)→ ${toml}`);
2858
+ } else {
2859
+ writeText(toml, text);
2860
+ ok(`移除 MCP 表 → ${toml}`);
2861
+ }
2158
2862
  }
2159
2863
  }
2160
2864
  if (readText(join(stateDir(), 'token')) || readText(join(stateDir(), 'hook.mjs'))
2161
- || readText(join(stateDir(), 'client_id')) || readText(join(stateDir(), STATE_VERSION_FILE))) {
2865
+ || readText(join(stateDir(), 'client_id')) || readText(join(stateDir(), STATE_VERSION_FILE))
2866
+ || readText(join(stateDir(), STATE_ENDPOINT_FILE))) {
2162
2867
  if (!DRY) rmSync(stateDir(), { recursive: true, force: true });
2163
2868
  ok(`删除状态目录 → ${stateDir()}`);
2164
2869
  }
@@ -2296,18 +3001,23 @@ function reportValues(verdict) {
2296
3001
  ['endpoint-usable', verdict.usable ? 'yes' : 'no'],
2297
3002
  ['endpoint-reachable', lastProbe.reachable],
2298
3003
  ['endpoint-tools', lastProbe.tools],
2299
- // `ok` = the probe ran and its answer is meaningful; `inconclusive-proxy` = the machine has a
2300
- // proxy this process cannot use, so the boolean above says nothing. Without this field the YAML
2301
- // a user pastes into a public issue cannot tell a real outage from a false negative.
3004
+ // The classification of the probe, and the field that says whether the boolean above means
3005
+ // anything (#3778). `ok` = handshake succeeded; `timeout` / `inconclusive-proxy` = the tool
3006
+ // could not answer (exit 2 — network or proxy, not a verdict about this machine);
3007
+ // `unreachable` / `non-mcp` = it did answer, and the answer is "this machine cannot read".
2302
3008
  ['endpoint-probe', lastProbe.note || 'ok'],
2303
3009
  ['token', token ? 'present' : 'absent'],
2304
3010
  ['permissions', permissions],
2305
3011
  ['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
2306
3012
  // `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
2307
- // from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
2308
- ['install-scope', hookWired()
2309
- ? 'full'
2310
- : (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
3013
+ // from "nothing installed" and must not be counted as one (#1753's T1-lite rows). Zero
3014
+ // targets is always `none`, even if a stale state directory still holds a hook: with no agent
3015
+ // to consume it, nothing is installed (#3778).
3016
+ ['install-scope', agents.length === 0
3017
+ ? 'none'
3018
+ : (hookWired()
3019
+ ? 'full'
3020
+ : (agents.some((agent) => mcpEntryPresent(agent)) ? 'mcp-only' : 'none'))],
2311
3021
  ['voice', voiceStatus()],
2312
3022
  ['open-items', manual.length],
2313
3023
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
@@ -2449,7 +3159,12 @@ if (mode === 'report') {
2449
3159
  // follow the verdict** — that breaks every existing use, and the
2450
3160
  // gated form below already exists for CI.
2451
3161
  // --report --strict (--ci) → 0 READY · 1 NOT READY (including `open-items > 0`) ·
2452
- // 2 the report could not be produced at all.
3162
+ // 2 could not verify (a probe timeout / a proxy this Node cannot
3163
+ // use) or the report could not be produced at all.
3164
+ //
3165
+ // The two meanings of 2 are one sentence: "the tool could not answer". The report still prints
3166
+ // first in the probe case — `endpoint-probe` says `timeout` / `inconclusive-proxy` — so CI has
3167
+ // the evidence and the code together (#3778). `--verify` uses the same three codes.
2453
3168
  //
2454
3169
  // `--report-json` is the same mode, same contracts, same codes, other encoding: the JSON is
2455
3170
  // printed even when the machine is NOT READY — that is precisely the state an MDM is collecting
@@ -2476,12 +3191,17 @@ if (mode === 'report') {
2476
3191
  process.exit(2);
2477
3192
  }
2478
3193
  if (!STRICT) process.exit(0);
2479
- const ready = verdict.allOk && verdict.openItems === 0;
2480
- if (!ready) {
3194
+ const code = exitCodeFor(verdict);
3195
+ if (code === 2) {
3196
+ // The report is already on stdout. This line says what the code means, because "2" here is
3197
+ // not "unhealthy" and not "the tool crashed": it is "the probe could not answer".
3198
+ console.error(`--strict:没能验证(endpoint-probe: ${lastProbe.note})→ 退出码 2;`
3199
+ + `上面的 ${REPORT_JSON ? 'JSON' : 'YAML'} 已经照常打印,这个码说的是网络/代理,不是机器坏了。`);
3200
+ } else if (code === 1) {
2481
3201
  console.error(`--strict:NOT READY(verify: ${verdict.allOk ? 'READY' : 'NOT READY'},`
2482
3202
  + `open-items: ${verdict.openItems})→ 退出码 1;上面的 ${REPORT_JSON ? 'JSON' : 'YAML'} 就是证据。`);
2483
3203
  }
2484
- process.exit(ready ? 0 : 1);
3204
+ process.exit(code);
2485
3205
  }
2486
3206
 
2487
3207
  if (mode === 'verify') {
@@ -2490,8 +3210,19 @@ if (mode === 'verify') {
2490
3210
  // Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
2491
3211
  // silent `--verify` prints when the machine is healthy, which is what makes it usable in a
2492
3212
  // deployment log.)
2493
- console.log(`\n结论:${verdict.allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了成因和做法'}`);
2494
- process.exit(verdict.allOk ? 0 : 1);
3213
+ //
3214
+ // Three conclusions for three answers (#3778): READY, NOT READY (a machine problem), and
3215
+ // "could not verify" — which used to exit 0 while saying the probe was untrustworthy, so a
3216
+ // script could not tell it from a healthy machine.
3217
+ const code = exitCodeFor(verdict);
3218
+ const conclusion = code === 0
3219
+ ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段`
3220
+ : code === 2
3221
+ ? '没能验证 —— 端点探测超时或代理不可信(看 endpoint-probe);这不是"机器不健康",'
3222
+ + '先解决网络/代理再重跑 --verify'
3223
+ : 'NOT READY —— 上面每条 ! 都给了成因和做法';
3224
+ console.log(`\n结论:${conclusion}`);
3225
+ process.exit(code);
2495
3226
  }
2496
3227
 
2497
3228
  const targets = (only.length ? only : AGENTS).filter((a) => {
@@ -2542,7 +3273,11 @@ if (LIST_WRITES) {
2542
3273
  // The rows come from the shared manifest builder, the same list the install preview prints: one
2543
3274
  // source of truth, so the manifest an operator approves and the preview a user reads cannot
2544
3275
  // disagree about what this command would write.
2545
- const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
3276
+ // Tier ③ is not always a hook: `hook.mjs`, the endpoint record and the version stamp live in the
3277
+ // same state directory, and a target with no hook mechanism (Cursor, mcode, …) still gets that
3278
+ // directory written. Calling the whole tier "装钩子" told those users they were approving a
3279
+ // behaviour change they were not (#3777 follow-up).
3280
+ const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 钩子/状态目录' };
2546
3281
  const list = buildWriteManifest(targets);
2547
3282
  for (const row of list) {
2548
3283
  console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
@@ -2552,8 +3287,12 @@ if (LIST_WRITES) {
2552
3287
  console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
2553
3288
  + '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
2554
3289
  } else {
2555
- console.log(`# 本次申请 ${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')});`
2556
- + '只想批 ① 就加 --mcp-only。');
3290
+ // With no target detected the manifest is empty, and the old line printed `0 级权限()` — an
3291
+ // empty parenthesis where the answer should be (#3778). Say "none" and why.
3292
+ const asked = shown.size
3293
+ ? `${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')})`
3294
+ : '0 级权限(无 —— 没有检测到任何受支持的助手,这次不会写任何文件)';
3295
+ console.log(`# 本次申请 ${asked};只想批 ① 就加 --mcp-only。`);
2557
3296
  }
2558
3297
  console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
2559
3298
  process.exit(0);
@@ -2573,14 +3312,24 @@ if (!SILENT && targets.length) {
2573
3312
 
2574
3313
  try {
2575
3314
  if (!targets.length) {
2576
- need('没检测到任何受支持助手的配置目录(Claude Code / Codex / Hermes / OpenClaw / Codewhale / '
2577
- + 'Cursor)→ 请先打开一次你要用的那个助手,再回来运行本命令');
3315
+ // The list comes from `AGENTS` (#3778): it used to name six assistants by hand while ten were
3316
+ // supported, so a Gemini/Copilot/OpenCode/Kiro user was told about a smaller product than the
3317
+ // one they had just run — and the `--verify` path said nothing at all.
3318
+ need('没有检测到任何受支持的助手 —— 因此什么都没装'
3319
+ + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2578
3320
  } else {
2579
3321
  // Only install the hook when a target can actually consume it. Writing it for a Cursor-only or
2580
3322
  // Codex-only machine produced a file nothing would ever run, and made `--report` claim
2581
3323
  // `install-scope: full` for an install that registered an endpoint and nothing else.
2582
3324
  const hookPath = (MCP_ONLY || !targets.includes('claude')) ? null : await installHook();
2583
- if (!MCP_ONLY) stampVersion();
3325
+ // Tier ③ only: the record lives in the state directory, and `--mcp-only` must not create it.
3326
+ // Without a record, `--verify` falls back to the env override and then to the canonical
3327
+ // endpoint — which is the older behaviour, and the only place a mirrored install can be
3328
+ // reported as broken (verify with the same MISAKANET_ENDPOINT it was installed with).
3329
+ if (!MCP_ONLY) {
3330
+ stampVersion();
3331
+ stampEndpoint();
3332
+ }
2584
3333
  const bearer = has('--no-register') ? '' : await ensureIdentity();
2585
3334
  for (const agent of targets) {
2586
3335
  // One target failing must not skip the rest: an uncaught throw used to abort the loop, so
@@ -2600,6 +3349,7 @@ try {
2600
3349
  else if (agent === 'hermes') await installHermes(hookPath, bearer);
2601
3350
  else if (agent === 'openclaw') await installOpenclaw(bearer);
2602
3351
  else if (agent === 'codewhale') await installCodewhale(bearer);
3352
+ else if (agent === 'mcode') await installMcode(bearer);
2603
3353
  else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
2604
3354
  installed += 1;
2605
3355
  } catch (err) {
@@ -2619,6 +3369,15 @@ try {
2619
3369
  // the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
2620
3370
  // of the same run is `--report-json`, not this text.
2621
3371
  say(render());
3372
+ if (!SILENT && !DRY) {
3373
+ // The number the reader actually wants: not "how many files are in scope" but "how many did
3374
+ // this run really change". Printed even when it is 0, which is the honest answer on an
3375
+ // already-installed machine and the one #3775 was filed about: the line used to appear only
3376
+ // when something was written, so "already installed" and "the installer never got this far"
3377
+ // looked identical. `--dry-run` keeps its own contract — it already says it would write
3378
+ // nothing, so a "0 files written" line under it would only restate the mode.
3379
+ console.log(`本次实际写入 ${writtenFiles.size} 个文件(上面的清单是"涉及哪些",这一行是"真的动了哪些")。`);
3380
+ }
2622
3381
  if (MCP_ONLY && !SILENT) {
2623
3382
  // The honest failure mode of tier ①, said where the user will read it: they are not installing a
2624
3383
  // behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook