@misaka-net/misakanet-setup 0.7.1 → 0.8.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.
@@ -25,7 +25,7 @@
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,
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, copyFileSync, rmSync,
29
29
  chmodSync, statSync, renameSync, accessSync, constants,
30
30
  openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
31
31
  // No child_process import on purpose: this installer must never hand a file-derived value to
@@ -77,6 +77,24 @@ const valueOf = (flag, dflt) => {
77
77
  return i >= 0 && args[i + 1] ? args[i + 1] : dflt;
78
78
  };
79
79
  const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
80
+
81
+ /**
82
+ * `--client <id>`: judge **one** client only (issue #3699).
83
+ *
84
+ * The checks in `verify()` are per client — each block reads one assistant's own files — but the
85
+ * verdict was per machine: on a machine with three assistants, one broken assistant condemned all
86
+ * three, and a healthy one was indistinguishable from a broken one (#3696). The question a
87
+ * multi-client machine actually asks is "how is *the one I use*", so this flag answers exactly
88
+ * that: the other clients are not checked, not printed, and not counted.
89
+ *
90
+ * Not to be confused with `--client-id`, which pins this machine's pseudonym and has nothing to do
91
+ * with the assistants.
92
+ */
93
+ const CLIENT = valueOf('--client', '').trim().toLowerCase();
94
+
95
+ /** Should this client's checks run? A `--client` filter narrows the whole run down to one. */
96
+ const wantClient = (id) => !CLIENT || CLIENT === id;
97
+
80
98
  const DRY = has('--dry-run');
81
99
  /**
82
100
  * Permission tiers (2026-09-18).
@@ -116,12 +134,34 @@ const HOME = resolve(valueOf('--home', homedir()));
116
134
  const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
117
135
  'cursor', 'gemini', 'copilot', 'opencode', 'kiro', 'mcode'];
118
136
 
137
+ /**
138
+ * The three line lists, and what every line knows about itself.
139
+ *
140
+ * A line knows the **client** it is about, and a failure knows its **kind** — what is wrong, which
141
+ * is what decides the fix (issue #3699). The checks in `verify()` are per client (each block reads
142
+ * one assistant's own files), but the output could only say "this machine is ready": a machine with
143
+ * three assistants was condemned because of the one the user never touches, and a healthy assistant
144
+ * was indistinguishable from a broken one (#3696, #3699).
145
+ *
146
+ * The kinds:
147
+ * 'blocked' — we cannot write there. A re-run writes exactly as much as the last one did, so
148
+ * "re-run the install" is not the advice (measured 2026-10-22 on an OpenClaw machine
149
+ * whose workspace was root-owned and read-only — see `unwritableAdvice`).
150
+ * 'absent' — our footprint is not there at all. A re-run puts it there.
151
+ * 'broken' — our footprint is there but wrong (wrong endpoint, missing field). A re-run fixes it.
152
+ *
153
+ * The client id is '' for a line that is about the machine rather than about one client: the
154
+ * endpoint probe, the token, the version stamp.
155
+ */
119
156
  const done = [];
120
157
  const manual = [];
121
158
  const skipped = [];
122
- const ok = (m) => done.push(m);
123
- const need = (m) => manual.push(m);
124
- const skip = (m) => skipped.push(m);
159
+ const ok = (m, client = '') => done.push({ text: m, client });
160
+ const need = (m, client = '', kind = '') => manual.push({ text: m, client, kind });
161
+ const skip = (m, client = '') => skipped.push({ text: m, client });
162
+
163
+ /** The display name of a client id — `AGENT_LABELS`, with the id as the fallback. */
164
+ const clientLabel = (id) => AGENT_LABELS[id] || id;
125
165
 
126
166
  function readJson(path, fallback) {
127
167
  try {
@@ -157,7 +197,7 @@ function readText(path) {
157
197
  * except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
158
198
  * failed 9 e2e legs on exactly that).
159
199
  */
160
- const VERSION = '0.7.1' // x-release-please-version
200
+ const VERSION = '0.8.1' // x-release-please-version
161
201
 
162
202
  /**
163
203
  * The shape of a client id, in one place.
@@ -181,6 +221,7 @@ const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
181
221
  const FLAGS = [
182
222
  ['--home <dir>', '把配置写到这个目录,而不是真实家目录(不是沙箱:--uninstall 也认这个目录)'],
183
223
  ['--only <a,b>', `只处理列出的助手(${AGENTS.join(' / ')})`],
224
+ ['--client <id>', `只检查这一个客户端(${AGENTS.join(' / ')})——只判它,不判整台机器`],
184
225
  ['--dry-run', '只说不做:打印会改哪些文件,不写任何东西'],
185
226
  ['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
186
227
  ['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
@@ -198,7 +239,7 @@ const FLAGS = [
198
239
  ['--help, -h', '打印这份帮助并退出'],
199
240
  ['--version', '打印版本并退出'],
200
241
  ];
201
- const VALUE_FLAGS = ['--home', '--only', '--client-id'];
242
+ const VALUE_FLAGS = ['--home', '--only', '--client-id', '--client'];
202
243
 
203
244
  function printHelp() {
204
245
  console.log(`MisakaNet 安装程序 ${VERSION}
@@ -574,16 +615,27 @@ function buildWriteManifest(agents) {
574
615
  // install: with no target detected the run writes nothing at all.
575
616
  if (agents.length) {
576
617
  // `--no-register` registers no anonymous node, so no token is written; listing it anyway would
577
- // make the manifest — and the preview's file count — promise a file that stays absent.
578
- if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
618
+ // make the manifest — and the preview's file count — promise a file that stays absent. Even on
619
+ // a registering run it is only written *if the network lets the registration through*, so the
620
+ // row says so instead of presenting it as a file this run will certainly create (#3833).
621
+ if (!has('--no-register')) {
622
+ add(join(stateDir(), 'token'), 'create (0600,取决于注册是否成功)', 1, 'token');
623
+ }
579
624
  if (!MCP_ONLY) {
625
+ // `hook.mjs` is the *Claude Code* hook's body: `installHook()` runs only when `claude` is one
626
+ // of the targets. Listing it for mcode/codex/… promised a file those installs never write —
627
+ // in the same run that said "没有 Claude Code 那种钩子" (measured 2026-10-25, #3833).
628
+ if (agents.includes('claude')) add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
580
629
  // The endpoint record lives in the state directory, so it is tier ③ like everything else in
581
630
  // there: `--mcp-only` must not create that directory at all (the e2e checks the directory
582
631
  // itself, not just the files). The record is therefore written only when tier ③ is in scope.
583
632
  add(join(stateDir(), STATE_ENDPOINT_FILE), 'create', 3, 'endpoint');
584
- add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
585
633
  add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
586
634
  }
635
+ // The pseudonym is persisted only on a run that has to mint one (#3832) — so the manifest
636
+ // names that file only then, keeping the "every path exists afterwards" rule. Tier ① like the
637
+ // token: `ensureIdentity()` writes both, and `--mcp-only` still registers.
638
+ if (willMintClientId()) add(join(stateDir(), CLIENT_ID_FILE), 'create', 1, 'clientId');
587
639
  }
588
640
  return [...rows.values()]
589
641
  .filter((row) => (MCP_ONLY ? row.tier === 1 : true))
@@ -616,10 +668,13 @@ function writePreview(rows) {
616
668
  grants: '只读工具放行',
617
669
  rules: '规则块(会改变 agent 的行为)',
618
670
  hook: '钩子',
619
- token: 'MCP 凭据',
671
+ // The token is written only when the anonymous registration succeeds, so the preview says so
672
+ // rather than counting it as a file this run will certainly create (#3833).
673
+ token: 'MCP 凭据(取决于注册是否成功)',
620
674
  endpoint: '端点记录',
621
675
  hookScript: '钩子脚本',
622
676
  version: '版本戳',
677
+ clientId: '身份编号',
623
678
  state: '本机状态',
624
679
  };
625
680
  const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
@@ -827,7 +882,11 @@ function stripBlock(path, block = PROMPT_BLOCK) {
827
882
  return true;
828
883
  }
829
884
 
830
- const stateDir = () => join(HOME, '.misakanet-agent');
885
+ // The hook already reads MISAKANET_AGENT_DIR (checkpoint_reminder.mjs), so honouring it here is
886
+ // what lets an install work on a machine whose HOME is not writable for the runtime user (#3701):
887
+ // writes and --verify then agree on the same directory instead of failing to mkdir under HOME.
888
+ // Kept unresolved on purpose — the hook does not resolve it either, so both ends read one path.
889
+ const stateDir = () => process.env.MISAKANET_AGENT_DIR || join(HOME, '.misakanet-agent');
831
890
 
832
891
  /**
833
892
  * The headers every MCP entry we write carries.
@@ -923,6 +982,91 @@ function adoptExistingClientId(text) {
923
982
  clientIdHint = found;
924
983
  }
925
984
 
985
+ /** The state file that pins this machine's pseudonym, so `--verify`-able identity survives a run. */
986
+ const CLIENT_ID_FILE = 'client_id';
987
+
988
+ /** The id `--client-id` / `MISAKANET_CLIENT_ID` asks for, shape-checked, or `''`. */
989
+ function explicitClientId() {
990
+ const value = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
991
+ return CLIENT_ID_SHAPE.test(value) ? value : '';
992
+ }
993
+
994
+ /** The id `<stateDir>/client_id` pins, or `''` — shape-checked, because a file wrote it. */
995
+ function storedClientId() {
996
+ const found = readText(join(stateDir(), CLIENT_ID_FILE)).trim();
997
+ return CLIENT_ID_SHAPE.test(found) ? found : '';
998
+ }
999
+
1000
+ /**
1001
+ * The pseudonym *any* config on this machine already declares, or `''`.
1002
+ *
1003
+ * "Which config" cannot be "the one this run is about to rewrite". A machine that installed one
1004
+ * target and then installed a second wrote the second config with no `X-MisakaNet-Client` at all:
1005
+ * `ensureIdentity()` returned early (the token existed), it never set `knownClientId`, and the
1006
+ * per-target adopters only look at their own file (issue #3832, hole 1). Every surface this
1007
+ * installer writes is a candidate, walked in `AGENTS` order so the answer is deterministic.
1008
+ */
1009
+ function clientIdOnMachine() {
1010
+ for (const agent of AGENTS) {
1011
+ for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
1012
+ const found = readText(path).match(CLIENT_ID_IN_CONFIG)?.[1] || '';
1013
+ if (CLIENT_ID_SHAPE.test(found)) return found;
1014
+ }
1015
+ }
1016
+ return '';
1017
+ }
1018
+
1019
+ /**
1020
+ * Will this run have to mint the pseudonym, and therefore persist it?
1021
+ *
1022
+ * Asked by the write manifest so the preview names `<stateDir>/client_id` only when the run is
1023
+ * really going to create it (#3832) — the same "every path must exist afterwards" rule the rest of
1024
+ * the manifest follows. Mirrors `clientIdForThisRun()`: nothing explicit, nothing pinned, nothing
1025
+ * to adopt → a mint (and a write).
1026
+ */
1027
+ function willMintClientId() {
1028
+ if (has('--no-register')) return false;
1029
+ return !explicitClientId() && !storedClientId() && !clientIdOnMachine();
1030
+ }
1031
+
1032
+ /** Remember the id and show it to the user: it is a number worth keeping for a reinstall. */
1033
+ function rememberClientId(id) {
1034
+ knownClientId = id;
1035
+ clientIdHint = id;
1036
+ return id;
1037
+ }
1038
+
1039
+ /**
1040
+ * Resolve `X-MisakaNet-Client` for a run that is *not* given an explicit id (#3832).
1041
+ *
1042
+ * Three sources, in order of how much the machine means them:
1043
+ * 1. `<stateDir>/client_id` — what a previous run pinned;
1044
+ * 2. any config that already declares one — the file *is* the record (#3772's rule, widened from
1045
+ * "the file I am about to rewrite" to "the machine");
1046
+ * 3. nothing — mint `setup-<uuid>` and **persist it**, so the next run has something to reuse.
1047
+ *
1048
+ * Persisting is the part #1688 deliberately removed, and the tradeoff is worth stating: #1688
1049
+ * dropped it because "read a local file, put the value in an outbound request" is the shape CodeQL
1050
+ * js/file-access-to-http (#268) flags, and the value is sent to a server. This file holds nothing
1051
+ * secret — it is a pseudonym the installer itself minted, shape-checked on read (`CLIENT_ID_SHAPE`),
1052
+ * and the server treats it as a hint, never as identity (AGENTS.md §3.3). Against that, *not*
1053
+ * persisting it means every failed registration mints a new id, rewrites the user's agent config
1054
+ * with it and leaves a backup behind — measurably wrong on the re-run path the installer tells the
1055
+ * user to walk ("稍后重跑本命令就能拿到 token"). One pinned pseudonym beats five unregistered
1056
+ * ones, so #3832 records the tradeoff here and keeps the file.
1057
+ */
1058
+ function clientIdForThisRun() {
1059
+ if (knownClientId) return knownClientId;
1060
+ const stored = storedClientId();
1061
+ if (stored) return rememberClientId(stored);
1062
+ const adopted = clientIdOnMachine();
1063
+ if (adopted) return rememberClientId(adopted);
1064
+ const minted = `setup-${randomUUID()}`;
1065
+ rememberClientId(minted);
1066
+ writeText(join(stateDir(), CLIENT_ID_FILE), `${minted}\n`);
1067
+ return minted;
1068
+ }
1069
+
926
1070
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
927
1071
 
928
1072
  /**
@@ -954,6 +1098,35 @@ function expectedEndpoint() {
954
1098
  return CANONICAL_ENDPOINT;
955
1099
  }
956
1100
 
1101
+ /**
1102
+ * The endpoint URL in a Hermes config's `mcp_servers.misakanet` entry.
1103
+ *
1104
+ * Hermes' config is YAML and this installer has no YAML parser, so the URL is read the way it is
1105
+ * written: the `url:` line of the `misakanet:` block. Both shapes put it there — the block this
1106
+ * installer writes (marked with the comment pair) and the bare entry a user added through the
1107
+ * Hermes CLI.
1108
+ */
1109
+ function hermesMcpUrl(text) {
1110
+ const lines = text.split('\n');
1111
+ for (let i = 0; i < lines.length; i += 1) {
1112
+ const head = lines[i].match(/^([ \t]*)misakanet:/);
1113
+ if (!head) continue;
1114
+ const indent = head[1].length;
1115
+ // The entry's own keys are indented deeper than the `misakanet:` line; the first line that is
1116
+ // not ends the entry, and the next entry's `url:` must not be read as this one's.
1117
+ for (let j = i + 1; j < lines.length; j += 1) {
1118
+ const line = lines[j];
1119
+ if (!line.trim()) continue;
1120
+ const lineIndent = line.match(/^[ \t]*/)[0].length;
1121
+ if (lineIndent <= indent) break;
1122
+ const url = line.match(/^[ \t]*url:[ \t]*(.+?)[ \t]*$/);
1123
+ // The quotes are optional in YAML, and a user-added entry may well have them.
1124
+ if (url) return url[1].replace(/^["']|["']$/g, '');
1125
+ }
1126
+ }
1127
+ return '';
1128
+ }
1129
+
957
1130
  /**
958
1131
  * One verdict for every MCP entry this installer writes.
959
1132
  *
@@ -1072,6 +1245,40 @@ let lastResponseOk = false;
1072
1245
  // `--verify` can print is what actually came back (`{}` is not a diagnosis).
1073
1246
  let lastResponsePreview = '';
1074
1247
 
1248
+ // The server's retry horizon on the last response, in seconds, when it sent one: the `Retry-After`
1249
+ // response header (seconds or an HTTP-date), the standard shape `docs/index.html` already reads on
1250
+ // 429s. Per-request, so a horizon from an earlier call can never leak into a later failure's words.
1251
+ let lastRetryAfterSeconds = null;
1252
+
1253
+ /** `Retry-After` → seconds. Both forms the header allows; anything else is "no horizon", not 0. */
1254
+ function retryAfterSeconds(headerValue) {
1255
+ const raw = String(headerValue || '').trim();
1256
+ if (!raw) return null;
1257
+ if (/^\d+$/.test(raw)) return Number(raw);
1258
+ const at = Date.parse(raw);
1259
+ if (Number.isNaN(at)) return null;
1260
+ return Math.max(0, Math.round((at - Date.now()) / 1000));
1261
+ }
1262
+
1263
+ /**
1264
+ * How long the server says to wait before re-running, when it says. The failure body's `retry_after`
1265
+ * (seconds, HTTP `Retry-After` semantics) wins over the header, and neither is guessed: a server that
1266
+ * stays silent gets no horizon sentence at all.
1267
+ */
1268
+ function retryHorizonOf(result) {
1269
+ const fromBody = result && (result.retry_after ?? result.retry_after_seconds);
1270
+ const n = Number(fromBody);
1271
+ if (Number.isFinite(n) && n > 0) return Math.round(n);
1272
+ return lastRetryAfterSeconds;
1273
+ }
1274
+
1275
+ /** "45 秒" / "15 分钟" / "2 小时" — the horizon in the words a person reads a clock in. */
1276
+ function humanWait(seconds) {
1277
+ if (seconds < 60) return `${seconds} 秒`;
1278
+ if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`;
1279
+ return `${Math.round(seconds / 3600)} 小时`;
1280
+ }
1281
+
1075
1282
  async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
1076
1283
  const headers = {
1077
1284
  'Content-Type': 'application/json',
@@ -1087,6 +1294,7 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
1087
1294
  lastRequestKind = '';
1088
1295
  lastResponseOk = false;
1089
1296
  lastResponsePreview = '';
1297
+ lastRetryAfterSeconds = null;
1090
1298
  try {
1091
1299
  const response = await fetch(urlOverride || ENDPOINT, {
1092
1300
  method: 'POST',
@@ -1098,6 +1306,9 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
1098
1306
  // below, not by this line — but "it answered with something that is not a handshake" and
1099
1307
  // "nothing answered" are different facts (see probeOutcome()).
1100
1308
  lastResponseOk = true;
1309
+ lastRetryAfterSeconds = retryAfterSeconds(
1310
+ response.headers && typeof response.headers.get === 'function'
1311
+ ? response.headers.get('Retry-After') : null);
1101
1312
  const payload = await response.json();
1102
1313
  lastResponsePreview = JSON.stringify(payload).slice(0, 160);
1103
1314
  if (payload && payload.error) {
@@ -1371,7 +1582,9 @@ async function installHook() {
1371
1582
  if (!DRY) {
1372
1583
  mkdirSync(stateDir(), { recursive: true });
1373
1584
  if (existing !== null) backup(hookPath);
1374
- writeFileSync(hookPath, bundled);
1585
+ // `writeText`, not `writeFileSync`: the hook is a file this run wrote, so the closing count has
1586
+ // to include it — and it must be replaced atomically like every other file here (#3833).
1587
+ writeText(hookPath, bundled);
1375
1588
  }
1376
1589
  ok(isOurs
1377
1590
  ? `自动沉淀的钩子已更新(旧版备份为 ${hookPath}.misakanet.bak)→ ${hookPath}`
@@ -1399,22 +1612,23 @@ async function ensureIdentity() {
1399
1612
  }
1400
1613
  if (existing) {
1401
1614
  ok('已有匿名身份(token 已存在)');
1615
+ // #3832 hole 1: this early return used to leave `knownClientId` empty, and the npm channel
1616
+ // persisted nothing, so the *next* target's config was written with no `X-MisakaNet-Client` at
1617
+ // all. Resolve the identity here too — nothing is registered this run, but the header still has
1618
+ // to say who this machine is.
1619
+ clientIdForThisRun();
1402
1620
  return existing;
1403
1621
  }
1404
1622
  if (DRY) {
1405
1623
  ok(`会注册匿名身份并把 token 写到 ${file}`);
1406
1624
  return '';
1407
1625
  }
1408
- // The client id is what makes a re-registration return the *same* node, so it has to come
1409
- // from somewhere the user owns rather than from a file this process found on disk: CodeQL
1410
- // js/file-access-to-http (#268) reads "read a local file, put it in an outbound request" as
1411
- // what it looks like, and it is right — the value may be ours, but the shape is the bug.
1412
- // An exported MISAKANET_CLIENT_ID is explicit intent (the same standard the hook applies to
1413
- // its token); otherwise this run mints one and prints it so the user can keep it.
1414
- // `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
1415
- // the closing guidance told users to keep an identity and the only way to supply one was a shell
1416
- // export — an instruction a non-technical user cannot follow, and on Windows a different one.
1417
- const clientId = usable ? explicit : `setup-${randomUUID()}`;
1626
+ // The client id is what makes a re-registration return the *same* node. It used to be minted
1627
+ // fresh on every attempt that failed — held only in memory, so the re-run the installer asks for
1628
+ // minted another, rewrote the user's config and left a backup (issue #3832, hole 2).
1629
+ // `clientIdForThisRun()` pins the value in `<stateDir>/client_id`; its docstring records the
1630
+ // #1688 vs CodeQL tradeoff that makes reading that file back acceptable.
1631
+ const clientId = usable ? explicit : clientIdForThisRun();
1418
1632
  knownClientId = clientId;
1419
1633
  const referral = declaredReferral();
1420
1634
  const result = await mcpCall('misakanet_register', {
@@ -1442,8 +1656,15 @@ async function ensureIdentity() {
1442
1656
  : serverCode
1443
1657
  ? `服务端拒绝了这次注册(${serverCode})`
1444
1658
  : `多半是网络(${lastRequestError || '没有收到响应'})`;
1659
+ // …and when the server says *how long* to wait, say that too instead of "稍后". The horizon is
1660
+ // never guessed and never rounded into a promise: a server that sent none keeps the vague
1661
+ // wording, because a made-up wait is the same lie as the network guess this replaced (issue
1662
+ // #3511 — the retry-horizon half; the `code` half landed with #3512).
1663
+ const horizon = retryHorizonOf(result);
1445
1664
  need(`匿名注册没成功:${cause} → 读课程不受影响,但写入类工具(write_lesson)现在用不了;`
1446
- + '稍后重跑本命令就能拿到 token'
1665
+ + (horizon
1666
+ ? `约 ${humanWait(horizon)}后重跑本命令就能拿到 token(服务端给出的重试窗口)`
1667
+ : '稍后重跑本命令就能拿到 token')
1447
1668
  + (serverHint ? `(服务端提示:${serverHint})` : ''));
1448
1669
  return '';
1449
1670
  }
@@ -1586,10 +1807,14 @@ async function installClaude(hookPath, bearer) {
1586
1807
  const destDir = join(stateDir(), 'voice');
1587
1808
  if (!DRY) {
1588
1809
  mkdirSync(destDir, { recursive: true });
1810
+ // `copyFileSync` writes a file too, so these join `writtenFiles`: without it a `--voice`
1811
+ // install under-reports its count the same way the version stamp did (#3833).
1589
1812
  copyFileSync(voice.player, join(destDir, 'voice-hook.mjs'));
1813
+ writtenFiles.add(join(destDir, 'voice-hook.mjs'));
1590
1814
  const cues = existsSync(voice.cues) ? readdirSync(voice.cues).filter((f) => f.endsWith('.mp3')) : [];
1591
1815
  for (const cue of cues) {
1592
1816
  copyFileSync(join(voice.cues, cue), join(destDir, cue));
1817
+ writtenFiles.add(join(destDir, cue));
1593
1818
  }
1594
1819
  if (!cues.length) {
1595
1820
  need('语音钩子:找到了播放器但没有找到音频文件 → 重装一次 npx 包即可(其它功能不受影响)');
@@ -1779,7 +2004,11 @@ function stampVersion() {
1779
2004
  installed_at: new Date().toISOString(),
1780
2005
  };
1781
2006
  mkdirSync(stateDir(), { recursive: true });
1782
- writeFileSync(file, `${JSON.stringify(stamp, null, 2)}\n`);
2007
+ // `writeFileSync` here bypassed `writeText()`'s atomic replace *and* its `writtenFiles`
2008
+ // bookkeeping, so every fresh install reported one file fewer than it had written (#3833) — and
2009
+ // this was the one write path left without the truncation protection the rest of the installer
2010
+ // went through.
2011
+ writeText(file, `${JSON.stringify(stamp, null, 2)}\n`);
1783
2012
  ok(`版本戳 → ${file}(${VERSION}${existing?.version ? `,原 ${existing.version}` : ''})`);
1784
2013
  }
1785
2014
 
@@ -2169,6 +2398,11 @@ function unwritableAdvice(manual = '') {
2169
2398
  : `只读或权限不足,重跑也写不进去 → ${fix}`;
2170
2399
  }
2171
2400
 
2401
+ // Claude Code is in use when its user-level config file exists — a stale `~/.claude/` directory is
2402
+ // not evidence (#3699). The install still detects on either (it can offer to set Claude up); the
2403
+ // verification and the report must not blame a machine that merely left the directory behind.
2404
+ const claudeCodeInUse = () => existsSync(join(HOME, '.claude.json'));
2405
+
2172
2406
  async function verify() {
2173
2407
  let allOk = true;
2174
2408
  // The report answers two different questions, and one of them failing must not condemn the
@@ -2185,6 +2419,21 @@ async function verify() {
2185
2419
  // footprint, and it says nothing about the endpoint.
2186
2420
  let installOk = true;
2187
2421
  const installFailed = () => { installOk = false; allOk = false; };
2422
+ /**
2423
+ * A check whose answer is "we cannot write there" or "our footprint is not there".
2424
+ *
2425
+ * The two answers have different fixes, and the per-client summary has to name which one it is:
2426
+ * a read-only target is **not** fixed by re-running the installer — a re-run writes exactly as
2427
+ * much as the last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
2428
+ * root-owned and read-only; see `unwritableAdvice`). So the kind is decided **here**, where the
2429
+ * writability is measured, and never by reading the wording of the message: a message that
2430
+ * changes its phrasing must not silently change the verdict.
2431
+ */
2432
+ const needWrite = (client, path, absentMsg, blockedMsg) => {
2433
+ installFailed();
2434
+ if (canCreateHere(path)) need(absentMsg, client, 'absent');
2435
+ else need(blockedMsg, client, 'blocked');
2436
+ };
2188
2437
  // Where each segment starts, in the three line lists. The output prints the two segments as
2189
2438
  // two sections, and this boundary is the only thing that has to be right about them.
2190
2439
  const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
@@ -2206,13 +2455,16 @@ async function verify() {
2206
2455
  try {
2207
2456
  const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
2208
2457
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
2209
- if (detect('claude') && missing.length) {
2458
+ if (wantClient('claude') && claudeCodeInUse() && missing.length) {
2210
2459
  allOk = false;
2211
2460
  // Not an install fact: the endpoint answers, and the search still fails. It belongs to
2212
2461
  // the usability segment, so it belongs there in the output too.
2213
2462
  usable = false;
2463
+ // It is a Claude Code fact all the same — the permission list is Claude Code's own
2464
+ // config — so it is judged under Claude Code and not under the machine (#3699). The fix
2465
+ // is the same re-run, which is what 'absent' means: the grant is not in the list yet.
2214
2466
  need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
2215
- + '重跑安装命令即可放行');
2467
+ + '重跑安装命令即可放行', 'claude', 'absent');
2216
2468
  }
2217
2469
  } catch { /* a settings file we cannot read is reported by the Claude checks below */ }
2218
2470
  } else if (outcome.state === 'non-mcp') {
@@ -2261,11 +2513,20 @@ async function verify() {
2261
2513
  // self-contradiction the issue was filed for (`detected-agents: []` next to
2262
2514
  // `install-complete: complete`). Judged on "no target in AGENTS is detected", not on one
2263
2515
  // hard-coded directory: any of the supported assistants in `AGENTS` counts.
2264
- const detectedAgents = AGENTS.filter((agent) => detect(agent));
2516
+ // `detected-agents` counts only the assistants in scope: with `--client <id>` the question is
2517
+ // about that one, so another assistant's absence is not this machine's problem (#3699).
2518
+ const detectedAgents = AGENTS.filter((agent) => wantClient(agent) && detect(agent));
2265
2519
  if (!detectedAgents.length) {
2266
2520
  installFailed();
2267
- need('没有检测到任何受支持的助手 —— 因此什么都没装'
2268
- + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2521
+ if (CLIENT) {
2522
+ // The user named a client this machine does not have. That is an answer, not a silence:
2523
+ // "OpenClaw:没装过(这台机器上没检测到它)" says what to do about it.
2524
+ need(`${clientLabel(CLIENT)}:没装过(这台机器上没检测到它)`
2525
+ + '→ 先打开一次那个助手,再回来运行本命令', CLIENT, 'absent');
2526
+ } else {
2527
+ need('没有检测到任何受支持的助手 —— 因此什么都没装'
2528
+ + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2529
+ }
2269
2530
  }
2270
2531
  const tokenFile = join(stateDir(), 'token');
2271
2532
  let tokenPresent = '';
@@ -2289,7 +2550,7 @@ async function verify() {
2289
2550
  // Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
2290
2551
  // never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
2291
2552
  // (found while adding Cursor, which is mcp-only by construction).
2292
- if (detect('claude')) {
2553
+ if (wantClient('claude') && claudeCodeInUse()) {
2293
2554
  const hookPath = join(stateDir(), 'hook.mjs');
2294
2555
  let hookPresent = false;
2295
2556
  try {
@@ -2298,10 +2559,9 @@ async function verify() {
2298
2559
  hookPresent = false;
2299
2560
  }
2300
2561
  if (!hookPresent) {
2301
- installFailed();
2302
- need(canCreateHere(hookPath)
2303
- ? '自动沉淀钩子:缺失 → 重跑安装命令'
2304
- : `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
2562
+ needWrite('claude', hookPath,
2563
+ '自动沉淀钩子:缺失 → 重跑安装命令',
2564
+ `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
2305
2565
  } else {
2306
2566
  const settingsPath = join(HOME, '.claude', 'settings.json');
2307
2567
  const settings = readJson(settingsPath, {}) || {};
@@ -2312,34 +2572,35 @@ async function verify() {
2312
2572
  .filter((c) => typeof c === 'string');
2313
2573
  if (!commands.length) {
2314
2574
  installFailed();
2315
- need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
2575
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)', 'claude', 'absent');
2316
2576
  } else {
2317
2577
  const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
2318
2578
  if (!existsSync(exe)) {
2319
2579
  installFailed();
2320
- need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
2580
+ need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`,
2581
+ 'claude', 'broken');
2321
2582
  } else {
2322
- ok('Claude Code:钩子已装且解释器存在');
2583
+ ok('Claude Code:钩子已装且解释器存在', 'claude');
2323
2584
  }
2324
2585
  }
2325
2586
  const cfg = join(HOME, '.claude.json');
2326
2587
  const data = readJson(cfg, {}) || {};
2327
2588
  const entry = data.mcpServers?.misakanet;
2328
2589
  if (!entry) {
2329
- installFailed();
2330
- need(canCreateHere(cfg)
2331
- ? `Claude Code:MCP 未注册(${cfg})`
2332
- : `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2590
+ needWrite('claude', cfg,
2591
+ `Claude Code:MCP 未注册(${cfg})`,
2592
+ `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2333
2593
  } else {
2334
2594
  const verdict = mcpEntryState(entry, 'url');
2335
2595
  if (verdict.state === 'wrong-url') {
2336
2596
  installFailed();
2337
- need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2597
+ need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2598
+ 'claude', 'broken');
2338
2599
  } else if (verdict.state === 'no-url-field') {
2339
2600
  installFailed();
2340
- need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2601
+ need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'claude', 'broken');
2341
2602
  } else {
2342
- ok(`Claude Code:MCP 已注册(${verdict.url})`);
2603
+ ok(`Claude Code:MCP 已注册(${verdict.url})`, 'claude');
2343
2604
  }
2344
2605
  }
2345
2606
  }
@@ -2348,57 +2609,84 @@ async function verify() {
2348
2609
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
2349
2610
  // The state is read from the same file `openclaw mcp list` reports from, which keeps this
2350
2611
  // check working where the CLI itself cannot start (its database may be unwritable).
2351
- if (detect('openclaw')) {
2612
+ if (wantClient('openclaw') && detect('openclaw')) {
2352
2613
  const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
2353
2614
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2354
- installFailed();
2355
2615
  // "没装" and "装不了" are different facts with different fixes, and the difference is the
2356
2616
  // whole point (issue #3696): a read-only workspace is not something a re-run installs.
2357
- need(canCreateHere(rules)
2358
- ? `OpenClaw:规则块没装(${rules})→ 重跑安装命令`
2359
- : `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2617
+ needWrite('openclaw', rules,
2618
+ `OpenClaw:规则块没装(${rules})→ 重跑安装命令`,
2619
+ `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2360
2620
  } else {
2361
- ok('OpenClaw:规则块已装');
2621
+ ok('OpenClaw:规则块已装', 'openclaw');
2362
2622
  }
2363
2623
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
2364
2624
  const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
2365
2625
  if (!entry) {
2366
- installFailed();
2367
2626
  const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
2368
- need(canCreateHere(cfg)
2369
- ? `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`
2370
- : `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
2627
+ needWrite('openclaw', cfg,
2628
+ `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`,
2629
+ `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
2371
2630
  } else {
2372
- ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
2631
+ // The URL is checked here, and it was not: this block only ever asked "is there an entry",
2632
+ // so an OpenClaw pointed at `https://BROKEN.invalid/mcp` was reported as "已注册" with the
2633
+ // unreachable URL printed next to the tick. `mcpEntryState` exists for exactly this and was
2634
+ // simply never applied here (it is applied to Claude, Codex, codewhale, the MCP-only
2635
+ // targets and mcode). With a per-client verdict on screen, "就绪" next to an entry nobody
2636
+ // can reach would be a false green (#1984).
2637
+ const verdict = mcpEntryState(entry, 'url');
2638
+ if (verdict.state === 'wrong-url') {
2639
+ installFailed();
2640
+ need(`OpenClaw:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2641
+ 'openclaw', 'broken');
2642
+ } else if (verdict.state === 'no-url-field') {
2643
+ installFailed();
2644
+ need(`OpenClaw:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'openclaw', 'broken');
2645
+ } else {
2646
+ ok(`OpenClaw:MCP 已注册(${verdict.url})`, 'openclaw');
2647
+ }
2373
2648
  }
2374
2649
  }
2375
2650
  // Hermes: same rule — reported only when the user has it. Its config file *is* the registry,
2376
2651
  // so the state is readable without running anything. What cannot be confirmed from here is
2377
2652
  // whether Hermes has loaded that file, and the output says so rather than implying it.
2378
- if (readText(join(HOME, '.hermes', 'config.yaml'))) {
2653
+ if (wantClient('hermes') && readText(join(HOME, '.hermes', 'config.yaml'))) {
2379
2654
  const rules = join(HOME, '.hermes', 'SOUL.md');
2380
2655
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2381
- installFailed();
2382
- need(canCreateHere(rules)
2383
- ? `Hermes:规则块没装(${rules})→ 重跑安装命令`
2384
- : `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2656
+ needWrite('hermes', rules,
2657
+ `Hermes:规则块没装(${rules})→ 重跑安装命令`,
2658
+ `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2385
2659
  } else {
2386
- ok('Hermes:规则块已装');
2660
+ ok('Hermes:规则块已装', 'hermes');
2387
2661
  }
2388
2662
  const cfg = join(HOME, '.hermes', 'config.yaml');
2389
2663
  const text = readText(cfg);
2390
2664
  if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
2391
- installFailed();
2392
2665
  const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
2393
- need(canCreateHere(cfg)
2394
- ? `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`
2395
- : `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
2396
- } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
2397
- ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
2398
- skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
2666
+ needWrite('hermes', cfg,
2667
+ `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`,
2668
+ `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
2399
2669
  } else {
2400
- ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
2401
- skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
2670
+ // The entry is there — but is it pointed at our endpoint? Same gap as OpenClaw had: the
2671
+ // check asked "is there an entry" and never "does it point at the right place", so a Hermes
2672
+ // on `https://BROKEN.invalid/mcp` was reported as registered (#1984: a green run is not
2673
+ // evidence unless something in it can differ).
2674
+ const url = hermesMcpUrl(text);
2675
+ const expected = expectedEndpoint();
2676
+ if (url && url !== expected) {
2677
+ installFailed();
2678
+ need(`Hermes:MCP 条目指向的端点不对(${url},应为 ${expected})(${cfg})→ 重跑安装命令`,
2679
+ 'hermes', 'broken');
2680
+ } else if (!url) {
2681
+ installFailed();
2682
+ need(`Hermes:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'hermes', 'broken');
2683
+ } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
2684
+ ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)', 'hermes');
2685
+ skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`, 'hermes');
2686
+ } else {
2687
+ ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`, 'hermes');
2688
+ skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核', 'hermes');
2689
+ }
2402
2690
  }
2403
2691
  }
2404
2692
  // Codex and codewhale: reported only when the user has them, like every other target. They were
@@ -2406,68 +2694,67 @@ async function verify() {
2406
2694
  // all, so a machine whose Codex entry pointed at `https://BROKEN.invalid/mcp` and whose rules
2407
2695
  // block had lost its markers still reported READY with exit code 0 — while `--report`'s
2408
2696
  // `detected-agents` listed codex and codewhale as present, and the gate let it through.
2409
- if (detect('codex')) {
2697
+ if (wantClient('codex') && detect('codex')) {
2410
2698
  const rules = join(HOME, '.codex', 'AGENTS.md');
2411
2699
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2412
- installFailed();
2413
- need(canCreateHere(rules)
2414
- ? `Codex:规则块没装(${rules})→ 重跑安装命令`
2415
- : `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2700
+ needWrite('codex', rules,
2701
+ `Codex:规则块没装(${rules})→ 重跑安装命令`,
2702
+ `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2416
2703
  } else {
2417
- ok('Codex:规则块已装');
2704
+ ok('Codex:规则块已装', 'codex');
2418
2705
  }
2419
2706
  const cfg = join(HOME, '.codex', 'config.toml');
2420
2707
  const toml = readText(cfg);
2421
2708
  const table = codexMisakanetTable(toml);
2422
2709
  const verdict = mcpEntryState(table, 'url');
2423
2710
  if (verdict.state === 'missing') {
2424
- installFailed();
2425
- need(canCreateHere(cfg)
2426
- ? `Codex:MCP 未注册(${cfg})→ 重跑安装命令`
2427
- : `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2711
+ needWrite('codex', cfg,
2712
+ `Codex:MCP 未注册(${cfg})→ 重跑安装命令`,
2713
+ `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2428
2714
  } else if (verdict.state === 'wrong-url') {
2429
2715
  installFailed();
2430
- need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2716
+ need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2717
+ 'codex', 'broken');
2431
2718
  } else if (verdict.state === 'no-url-field') {
2432
2719
  installFailed();
2433
- need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2720
+ need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codex', 'broken');
2434
2721
  } else {
2435
- ok(`Codex:MCP 已注册(${verdict.url})`);
2722
+ ok(`Codex:MCP 已注册(${verdict.url})`, 'codex');
2436
2723
  }
2437
2724
  // The top-level key is what makes Codex speak streamable-http at all: without it the entry
2438
2725
  // above is present and still unusable.
2439
2726
  if (!hasTopLevelKey(toml, 'experimental_use_rmcp_client')) {
2440
2727
  installFailed();
2441
2728
  need(`Codex:config.toml 里没有顶级键 experimental_use_rmcp_client(${cfg})`
2442
- + '→ Codex 连不上 streamable-http,重跑安装命令即可补上');
2729
+ + '→ Codex 连不上 streamable-http,重跑安装命令即可补上', 'codex', 'broken');
2443
2730
  }
2444
2731
  }
2445
- if (detect('codewhale')) {
2732
+ if (wantClient('codewhale') && detect('codewhale')) {
2446
2733
  const cfg = join(HOME, '.codewhale', 'mcp.json');
2447
2734
  const entry = (readJson(cfg, null) || {}).servers?.misakanet;
2448
2735
  const verdict = mcpEntryState(entry, 'url');
2449
2736
  if (verdict.state === 'missing') {
2450
- installFailed();
2451
- need(canCreateHere(cfg)
2452
- ? `codewhale:MCP 未注册(${cfg})→ 重跑安装命令`
2453
- : `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2737
+ needWrite('codewhale', cfg,
2738
+ `codewhale:MCP 未注册(${cfg})→ 重跑安装命令`,
2739
+ `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2454
2740
  } else if (verdict.state === 'wrong-url') {
2455
2741
  installFailed();
2456
- need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2742
+ need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2743
+ 'codewhale', 'broken');
2457
2744
  } else if (verdict.state === 'no-url-field') {
2458
2745
  installFailed();
2459
- need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2746
+ need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codewhale', 'broken');
2460
2747
  } else {
2461
- ok(`codewhale:MCP 已注册(${verdict.url})`);
2748
+ ok(`codewhale:MCP 已注册(${verdict.url})`, 'codewhale');
2462
2749
  }
2463
2750
  // Its rules blocks live in the projects it trusts, so that is where they are checked.
2464
2751
  for (const project of codewhaleProjects()) {
2465
2752
  const rules = join(project, 'AGENTS.md');
2466
2753
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2467
2754
  installFailed();
2468
- need(`codewhale:规则块没装(${rules})→ 重跑安装命令`);
2755
+ need(`codewhale:规则块没装(${rules})→ 重跑安装命令`, 'codewhale', 'absent');
2469
2756
  } else {
2470
- ok(`codewhale:规则块已装(${rules})`);
2757
+ ok(`codewhale:规则块已装(${rules})`, 'codewhale');
2471
2758
  }
2472
2759
  }
2473
2760
  }
@@ -2475,26 +2762,27 @@ async function verify() {
2475
2762
  // Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
2476
2763
  // the client has *loaded* that file cannot be confirmed from here, and the line says so.
2477
2764
  for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
2478
- if (!detect(agent)) continue;
2765
+ if (!wantClient(agent) || !detect(agent)) continue;
2479
2766
  const cfg = spec.config(HOME);
2480
2767
  const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
2481
2768
  if (!entry) {
2482
- installFailed();
2483
- need(canCreateHere(cfg)
2484
- ? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
2485
- : `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2769
+ needWrite(agent, cfg,
2770
+ `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`,
2771
+ `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2486
2772
  } else {
2487
2773
  const verdict = mcpEntryState(entry, spec.urlField);
2488
2774
  if (verdict.state === 'no-url-field') {
2489
2775
  installFailed();
2490
2776
  need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
2491
- + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
2777
+ + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`,
2778
+ agent, 'broken');
2492
2779
  } else if (verdict.state === 'wrong-url') {
2493
2780
  installFailed();
2494
- need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2781
+ need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2782
+ agent, 'broken');
2495
2783
  } else {
2496
- ok(`${spec.label}:MCP 已注册(${verdict.url})`);
2497
- skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
2784
+ ok(`${spec.label}:MCP 已注册(${verdict.url})`, agent);
2785
+ skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`, agent);
2498
2786
  }
2499
2787
  }
2500
2788
  }
@@ -2503,39 +2791,38 @@ async function verify() {
2503
2791
  // hand-edit turned into `{}` is exactly the state a re-run fixes. `mcode plugin add` refuses
2504
2792
  // local plugins on purpose (LOCAL_PLUGIN_INSTALL_UNSUPPORTED), so its exit code is not evidence
2505
2793
  // either way — the directory is, and that is what this reads.
2506
- if (detect('mcode')) {
2794
+ if (wantClient('mcode') && detect('mcode')) {
2507
2795
  const rules = join(HOME, MCODE_RULES_REL);
2508
2796
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2509
- installFailed();
2510
- need(canCreateHere(rules)
2511
- ? `MiniMax Code:规则块没装(${rules})→ 重跑安装命令`
2512
- : `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2797
+ needWrite('mcode', rules,
2798
+ `MiniMax Code:规则块没装(${rules})→ 重跑安装命令`,
2799
+ `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2513
2800
  } else {
2514
- ok('MiniMax Code:规则块已装');
2801
+ ok('MiniMax Code:规则块已装', 'mcode');
2515
2802
  }
2516
2803
  const plugin = mcodePluginPath(HOME);
2517
2804
  const doc = readJson(plugin, null);
2518
2805
  if (doc === null) {
2519
- installFailed();
2520
- need(canCreateHere(plugin)
2521
- ? `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`
2522
- : `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
2806
+ needWrite('mcode', plugin,
2807
+ `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`,
2808
+ `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
2523
2809
  } else {
2524
2810
  const verdict = mcpEntryState(doc.mcpServers?.misakanet, 'url');
2525
2811
  if (verdict.state === 'missing') {
2526
2812
  installFailed();
2527
- need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`);
2813
+ need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`,
2814
+ 'mcode', 'absent');
2528
2815
  } else if (verdict.state === 'no-url-field') {
2529
2816
  installFailed();
2530
- need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`);
2817
+ need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`, 'mcode', 'broken');
2531
2818
  } else if (verdict.state === 'wrong-url') {
2532
2819
  installFailed();
2533
2820
  need(`MiniMax Code:插件清单指向的端点不对(${verdict.url},应为 ${verdict.expected})(${plugin})`
2534
- + '→ 重跑安装命令');
2821
+ + '→ 重跑安装命令', 'mcode', 'broken');
2535
2822
  } else {
2536
- ok(`MiniMax Code:插件清单已注册(${verdict.url})`);
2823
+ ok(`MiniMax Code:插件清单已注册(${verdict.url})`, 'mcode');
2537
2824
  skip('MiniMax Code:只能确认"插件目录在位",无法确认 mcode 是否已加载 → 跑 `mcode plugin list` 复核'
2538
- + '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)');
2825
+ + '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)', 'mcode');
2539
2826
  }
2540
2827
  }
2541
2828
  }
@@ -2575,6 +2862,10 @@ async function verify() {
2575
2862
  // The install segment's open items, counted on their own: the report's `open-items` is every
2576
2863
  // manual item, this is the ones that belong to our footprint.
2577
2864
  openItems: manual.length - segmentEnd.manual,
2865
+ // One verdict per client, so the conclusion can be read per assistant instead of per machine
2866
+ // (issue #3699). The machine-level verdict below is derived from these, not the other way
2867
+ // round: a client that was not checked cannot be the reason this machine is not ready.
2868
+ clients: clientVerdicts(),
2578
2869
  segments: { start: segmentStart, end: segmentEnd },
2579
2870
  };
2580
2871
  }
@@ -2606,6 +2897,14 @@ function usabilityText(verdict) {
2606
2897
  }
2607
2898
 
2608
2899
  function installText(verdict) {
2900
+ // The clients that are not ready, **by name** (issue #3699). "3 项需要手动处理" said how many;
2901
+ // it never said which assistant the three belonged to, so on a multi-client machine the reader
2902
+ // had to go and match the count against the lines above.
2903
+ const notReady = (verdict.clients || []).filter((c) => c.verdict !== '就绪');
2904
+ if (notReady.length) {
2905
+ return `不完整 —— ${notReady.map((c) => `${c.label}:${c.verdict}`).join(';')}`
2906
+ + '(上面每条 ! 都给了成因和做法)';
2907
+ }
2609
2908
  if (verdict.zeroTargets) {
2610
2909
  return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
2611
2910
  }
@@ -2631,32 +2930,118 @@ function exitCodeFor(verdict) {
2631
2930
  return (verdict.unverified && verdict.install === 'complete') ? 2 : 1;
2632
2931
  }
2633
2932
 
2933
+ /**
2934
+ * One verdict per client, from the lines that client produced (issue #3699).
2935
+ *
2936
+ * The machine-level verdict was a single boolean over every check, but the checks are per client:
2937
+ * a machine with three assistants was condemned because of the one the user never touches, and a
2938
+ * healthy assistant was indistinguishable from a broken one (#3696, #3699). So the conclusion is
2939
+ * per client — and each client gets one of four words, because the four fixes are four different
2940
+ * things:
2941
+ *
2942
+ * 就绪 — everything we checked for this client passed; there is nothing to do.
2943
+ * 装不了 — we cannot write there. Fix the permissions: a re-run writes exactly as much as the
2944
+ * last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
2945
+ * root-owned and read-only — see `unwritableAdvice`).
2946
+ * 没装过 — our footprint is not there: the file, the entry, the permission grant. A re-run puts
2947
+ * it there.
2948
+ * 装坏了 — our footprint is there but points at the wrong place: the wrong endpoint, a missing
2949
+ * field, a missing interpreter. A re-run fixes it.
2950
+ *
2951
+ * Issue #3699 names the first three. The fourth exists because "our entry is there and points at
2952
+ * the wrong endpoint" is none of the three, and calling it 没装过 would be a lie about the
2953
+ * machine.
2954
+ *
2955
+ * `blocked` wins over `broken` wins over `absent`: a client we cannot write to is not merely "not
2956
+ * installed", and saying so would send the reader after the wrong fix.
2957
+ */
2958
+ const CLIENT_VERDICT_WORDS = { blocked: '装不了', broken: '装坏了', absent: '没装过' };
2959
+ /** What to do about each verdict, in the order the fix differs. */
2960
+ const CLIENT_VERDICT_HINTS = {
2961
+ 就绪: '',
2962
+ 装不了: '(改权限后重跑安装命令)',
2963
+ 没装过: '(重跑安装命令)',
2964
+ 装坏了: '(重跑安装命令)',
2965
+ };
2966
+
2967
+ function clientVerdicts() {
2968
+ const buckets = new Map();
2969
+ const bucket = (id) => {
2970
+ if (!buckets.has(id)) {
2971
+ buckets.set(id, { id, done: [], manual: [], skipped: [], kinds: new Set() });
2972
+ }
2973
+ return buckets.get(id);
2974
+ };
2975
+ for (const line of done) if (line.client) bucket(line.client).done.push(line);
2976
+ for (const line of manual) {
2977
+ if (!line.client) continue;
2978
+ const b = bucket(line.client);
2979
+ b.manual.push(line);
2980
+ if (line.kind) b.kinds.add(line.kind);
2981
+ }
2982
+ for (const line of skipped) if (line.client) bucket(line.client).skipped.push(line);
2983
+ // `AGENTS` is the order, so the output reads the same way on every machine.
2984
+ return AGENTS.filter((id) => buckets.has(id)).map((id) => {
2985
+ const b = buckets.get(id);
2986
+ const verdict = !b.kinds.size ? '就绪'
2987
+ : b.kinds.has('blocked') ? CLIENT_VERDICT_WORDS.blocked
2988
+ : b.kinds.has('broken') ? CLIENT_VERDICT_WORDS.broken
2989
+ : CLIENT_VERDICT_WORDS.absent;
2990
+ return {
2991
+ id,
2992
+ label: clientLabel(id),
2993
+ verdict,
2994
+ hint: CLIENT_VERDICT_HINTS[verdict],
2995
+ done: b.done,
2996
+ manual: b.manual,
2997
+ skipped: b.skipped,
2998
+ };
2999
+ });
3000
+ }
3001
+
2634
3002
  /**
2635
3003
  * The verify output: two sections, because the two questions have two answers.
2636
3004
  *
2637
3005
  * The old output was one list, and one list cannot say "the endpoint answered, the files did not
2638
3006
  * get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
2639
3007
  * "MisakaNet does not work here" (issue #3696).
3008
+ *
3009
+ * The install section is grouped **per client** (issue #3699). The checks were per client all
3010
+ * along — each block reads one assistant's own files — but the output could only say "this
3011
+ * machine", so a machine with three assistants was condemned because of the one the user never
3012
+ * touches, and a healthy assistant was indistinguishable from a broken one. Now each client gets
3013
+ * one verdict line and its own details under it.
2640
3014
  */
2641
3015
  function renderVerify(verdict) {
2642
3016
  const { start, end } = verdict.segments;
2643
3017
  const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
2644
- const section = (label, from, to) => {
3018
+ // A line is either about the machine (the endpoint probe, the token, the version stamp) or
3019
+ // about one client. The two never share a line, so the sections can be split by that.
3020
+ const machineLines = (list, from, to) => list.slice(from, to).filter((l) => !l.client);
3021
+ const section = (label, from, to, clients) => {
2645
3022
  const out = [];
2646
- const finished = done.slice(from.done, to.done);
2647
- const open = manual.slice(from.manual, to.manual);
2648
- const ignored = skipped.slice(from.skipped, to.skipped);
3023
+ const finished = machineLines(done, from.done, to.done);
3024
+ const open = machineLines(manual, from.manual, to.manual);
3025
+ const ignored = machineLines(skipped, from.skipped, to.skipped);
2649
3026
  // `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
2650
3027
  // `!` lines are what a human has to act on, the `✓` and `·` lines are progress.
2651
- if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l}`));
2652
- if (open.length) out.push(...open.map((l) => ` ! ${l}`));
2653
- if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l}`));
3028
+ if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l.text}`));
3029
+ if (open.length) out.push(...open.map((l) => ` ! ${l.text}`));
3030
+ if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l.text}`));
3031
+ for (const client of clients || []) {
3032
+ // The verdict line is the conclusion, not narration: it survives `--silent`, because a
3033
+ // silent `--verify` that prints nothing about the client cannot be read at all.
3034
+ out.push(` ${client.label}:${client.verdict}${client.hint}`);
3035
+ if (client.done.length && !SILENT) out.push(...client.done.map((l) => ` ✓ ${l.text}`));
3036
+ if (client.manual.length) out.push(...client.manual.map((l) => ` ! ${l.text}`));
3037
+ if (client.skipped.length && !SILENT) out.push(...client.skipped.map((l) => ` · ${l.text}`));
3038
+ }
2654
3039
  if (!out.length) return out;
2655
3040
  return [`\n── ${label} ──`, ...out];
2656
3041
  };
2657
3042
  return [
2658
- ...section('端点/可用性', start, end),
2659
- ...section('安装完整性', end, tail),
3043
+ ...section('端点/可用性', start, end, []),
3044
+ ...section('安装完整性', end, tail, verdict.clients),
2660
3045
  '',
2661
3046
  `可用性:${usabilityText(verdict)}`,
2662
3047
  `安装完整性:${installText(verdict)}`,
@@ -2754,9 +3139,49 @@ function restoreEmptiedFiles() {
2754
3139
  }
2755
3140
  }
2756
3141
 
3142
+ /**
3143
+ * Is `~/.minimax/plugins/misakanet/` ours to delete?
3144
+ *
3145
+ * The install side answers this by reading the manifest and refusing to touch a directory whose
3146
+ * `name` is not `misakanet` (`installMcode`), reporting "已原样保留". Uninstall used to `rmSync`
3147
+ * the subtree with no question at all, so on a machine where the install had just promised to keep
3148
+ * the user's plugin, the next `--uninstall` deleted it (issue #3831, measured 2026-10-25) — the
3149
+ * installer's own wording turned into a lie by the command it recommends for undoing.
3150
+ *
3151
+ * A manifest that is absent or unparseable is not proof of ownership either: a plugin directory is
3152
+ * a user-owned space, and `misakanet` is a name another plugin can legitimately have. Being wrong
3153
+ * here costs irreversible data, so only a manifest that says `name: "misakanet"` answers "yes".
3154
+ */
3155
+ function mcodePluginDirectoryOwnership() {
3156
+ const plugin = mcodePluginPath(HOME);
3157
+ const text = readText(plugin);
3158
+ if (!text) {
3159
+ return { ours: false, why: '里没有 .claude-plugin/plugin.json(无法确认归属)' };
3160
+ }
3161
+ let doc;
3162
+ try {
3163
+ doc = JSON.parse(text);
3164
+ } catch {
3165
+ return { ours: false, why: '的清单不是合法 JSON(无法确认归属)' };
3166
+ }
3167
+ if (doc?.name !== 'misakanet') {
3168
+ return {
3169
+ ours: false,
3170
+ why: `的清单不是本安装器写的(name=${JSON.stringify(doc?.name ?? null)})`,
3171
+ };
3172
+ }
3173
+ return { ours: true, why: '' };
3174
+ }
3175
+
2757
3176
  function uninstall() {
3177
+ let hermesRulesRemoved = false;
2758
3178
  for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md', '.minimax/AGENTS.md']) {
2759
- if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
3179
+ if (stripBlock(join(HOME, rel))) {
3180
+ ok(`移除规则块 → ${rel}`);
3181
+ // Recorded here because the block is already gone by the time the Hermes summary is built —
3182
+ // "已移除" must describe what this run did, not what a file says now (#3834).
3183
+ if (rel === '.hermes/SOUL.md') hermesRulesRemoved = true;
3184
+ }
2760
3185
  }
2761
3186
  // Both candidates: a block may have been written before the workspace was read from config.
2762
3187
  for (const workspace of openclawWorkspaces()) {
@@ -2796,14 +3221,29 @@ function uninstall() {
2796
3221
  }
2797
3222
  }
2798
3223
  // 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.
3224
+ // the whole subtree back. Only *our* directory, and only once its manifest says it is ours: see
3225
+ // `mcodePluginDirectoryOwnership` for what asking the same question as the install costs (#3831).
3226
+ // `~/.minimax/plugins/` is the user's namespace and stays even when it ends up empty — deleting
3227
+ // it would remove a place their other plugins live.
2801
3228
  const mcodeDir = join(HOME, '.minimax', 'plugins', 'misakanet');
2802
3229
  if (existsSync(mcodeDir)) {
2803
- try {
2804
- if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
2805
- ok(`移除插件目录 → ${mcodeDir}`);
2806
- } catch { /* best effort */ }
3230
+ const ownership = mcodePluginDirectoryOwnership();
3231
+ if (!ownership.ours) {
3232
+ // Deliberately a `need`, never an `ok`: the directory is still there, and only a human can
3233
+ // decide whether it is theirs to remove. Putting it under "已完成" was the second half of the
3234
+ // bug — the run claimed a removal it had skipped.
3235
+ need(`MiniMax Code:${mcodeDir} ${ownership.why} → 已保留(不删);`
3236
+ + '确认它是你的插件后手动删掉这个目录,本安装器不碰它');
3237
+ } else {
3238
+ try {
3239
+ if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
3240
+ ok(`移除插件目录 → ${mcodeDir}`);
3241
+ } catch (err) {
3242
+ // A failed delete used to be swallowed by an empty `catch`, so the run said nothing while
3243
+ // the directory survived. Now it is a manual step, which is what it is.
3244
+ need(`MiniMax Code:删不掉 ${mcodeDir}(${redact((err && err.message) || err)})→ 手动删除它`);
3245
+ }
3246
+ }
2807
3247
  }
2808
3248
  const voiceDir = join(stateDir(), 'voice');
2809
3249
  if (existsSync(voiceDir)) {
@@ -2890,8 +3330,18 @@ function uninstall() {
2890
3330
  ok(`移除 token 行 → ${hermesEnv}(${HERMES_ENV_KEY})`);
2891
3331
  }
2892
3332
  }
2893
- need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
2894
- + `规则块已在 ~/.hermes/SOUL.md 移除`);
3333
+ // Hermes' advice, only on a machine that has Hermes. It was unconditional, so a machine that had
3334
+ // never installed Hermes was told "规则块已在 ~/.hermes/SOUL.md 移除" and sent to a
3335
+ // `hermes hooks doctor` that does not exist there (#3834, measured 2026-10-25). The `!` list is
3336
+ // the user's only action list — an item that does not apply dilutes it.
3337
+ if (existsSync(join(HOME, '.hermes'))) {
3338
+ // And say what actually happened to the rules block: "已移除" only when this run removed one,
3339
+ // "本来就没装" when the file was there without our markers.
3340
+ need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
3341
+ + (hermesRulesRemoved
3342
+ ? '规则块已在 ~/.hermes/SOUL.md 移除'
3343
+ : '规则块本来就没装(~/.hermes/SOUL.md 里没有本安装器的标记块)'));
3344
+ }
2895
3345
 
2896
3346
  // OpenClaw's MCP entry is one we wrote ourselves, so we can take it back ourselves.
2897
3347
  const ocCfg = join(HOME, '.openclaw', 'openclaw.json');
@@ -2978,7 +3428,7 @@ function reportValues(verdict) {
2978
3428
  const permissions = (() => {
2979
3429
  // `n/a` when Claude Code is not a target on this machine: an absent settings file is not a
2980
3430
  // gap, and reporting one would send a Codex-only user chasing a fix they do not need.
2981
- if (!detect('claude')) return 'n/a';
3431
+ if (!claudeCodeInUse()) return 'n/a';
2982
3432
  const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
2983
3433
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
2984
3434
  return missing.length ? 'incomplete' : 'ok';
@@ -3021,7 +3471,7 @@ function reportValues(verdict) {
3021
3471
  ['voice', voiceStatus()],
3022
3472
  ['open-items', manual.length],
3023
3473
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
3024
- ['open-items-detail', manual.map((m) => redact(m))],
3474
+ ['open-items-detail', manual.map((m) => redact(m.text))],
3025
3475
  ...HUMAN_REPORT_FIELDS.map(([key, value, note]) => [key, value, note]),
3026
3476
  ];
3027
3477
  }
@@ -3104,9 +3554,9 @@ function render() {
3104
3554
  // `--silent` drops the narration, never the errors: `done` is progress, `manual` is what a human
3105
3555
  // (or a GPO log reader) has to act on, and a run that failed silently would be worse than a loud
3106
3556
  // one. See SILENT at the top for the full contract.
3107
- if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l}`));
3108
- if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l}`));
3109
- if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l}`));
3557
+ if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l.text}`));
3558
+ if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l.text}`));
3559
+ if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l.text}`));
3110
3560
  return out.join('\n');
3111
3561
  }
3112
3562
 
@@ -3123,6 +3573,22 @@ const STRICT = has('--strict') || has('--ci');
3123
3573
  const mode = has('--uninstall') ? 'uninstall'
3124
3574
  : ((has('--report') || REPORT_JSON || (has('--ci') && !has('--verify'))) ? 'report'
3125
3575
  : (has('--verify') ? 'verify' : 'install'));
3576
+ // `--client` is a question about *judging*, so it belongs to `--verify` and nowhere else: in
3577
+ // install mode `--only` already says which assistants to write to, and in report mode the report
3578
+ // has its own shape. A flag that silently does nothing is worse than one that refuses: `--client`
3579
+ // next to `--client-id` is exactly the kind of pair a user mixes up, so it says what it means.
3580
+ // Exit 2 is this tool's "could not answer" code (same as a probe timeout, #3778) — the machine
3581
+ // is not broken, the request was.
3582
+ if (CLIENT && mode !== 'verify') {
3583
+ console.error(`--client 只对 --verify 有意义(它决定"只判哪一个客户端");`
3584
+ + `当前模式是 ${mode},请用 --only <助手> 指定要处理的助手。`);
3585
+ process.exit(2);
3586
+ }
3587
+ if (CLIENT && !AGENTS.includes(CLIENT)) {
3588
+ console.error(`未知的客户端:${CLIENT}`);
3589
+ console.error(`支持的客户端:${supportedAgentLabels()}`);
3590
+ process.exit(2);
3591
+ }
3126
3592
  if (mode !== 'report' && !SILENT) {
3127
3593
  // The banner names the home directory, which is exactly the kind of thing that gets pasted
3128
3594
  // along with a report — so `--report` prints only the report. `--silent` drops it for the other
@@ -3377,6 +3843,25 @@ if (!SILENT && !DRY) {
3377
3843
  // looked identical. `--dry-run` keeps its own contract — it already says it would write
3378
3844
  // nothing, so a "0 files written" line under it would only restate the mode.
3379
3845
  console.log(`本次实际写入 ${writtenFiles.size} 个文件(上面的清单是"涉及哪些",这一行是"真的动了哪些")。`);
3846
+
3847
+ if (installed > 0) {
3848
+ // The tail is what a user reads, and until now it held only next steps: a real Windows run on
3849
+ // 2026-10-29 ended on "5) 想关掉…" and the identity line, so the sole evidence that anything had
3850
+ // worked was the absence of errors. Verified here rather than assumed: the version is this build's,
3851
+ // and whether it is the published one is asked of the registry — the same two primitives `--verify`
3852
+ // uses, so the closing line and the self-check cannot disagree about what "latest" means.
3853
+ const latest = await latestPublishedVersion();
3854
+ const delta = latest ? compareVersions(VERSION, latest) : null;
3855
+ let versionNote = ';查不到 npm 上的最新版本(离线或 registry 不可达)';
3856
+ if (delta === 0) versionNote = '(已是最新)';
3857
+ else if (delta !== null && delta < 0) {
3858
+ versionNote = `;npm 上已有 ${latest} → 重跑 npx @misaka-net/misakanet-setup@latest 可更新`;
3859
+ } else if (delta !== null) {
3860
+ versionNote = `;比 npm 上的 ${latest} 新(本地构建或预发布)`;
3861
+ }
3862
+ console.log(`\n✓ 安装成功 —— misakanet-setup ${VERSION}${versionNote}`
3863
+ + `\n 已配置 ${installed} 个助手;撤销:npx @misaka-net/misakanet-setup --uninstall`);
3864
+ }
3380
3865
  }
3381
3866
  if (MCP_ONLY && !SILENT) {
3382
3867
  // The honest failure mode of tier ①, said where the user will read it: they are not installing a
@@ -3388,13 +3873,14 @@ if (MCP_ONLY && !SILENT) {
3388
3873
  if (!SILENT) {
3389
3874
  console.log(`
3390
3875
  接下来:
3391
- 1) **把这个助手窗口关掉再打开一次**(新功能要重开会话才生效)
3392
- 2) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
3393
- 3) 想确认状态:npx @misaka-net/misakanet-setup --verify
3394
- 4) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
3876
+ 1) 启动时加载 MCP 配置的客户端(Claude Code / Codex 等)重开一次会话;dsh 下一条消息就生效,mcporter 这类按次调用的通道不需要重启
3877
+ 2) 确认工具装上了:跑客户端的 MCP 列表命令(如 mcporter list misakanet,或 Claude Code 的 /mcp)——应看到 8 个工具
3878
+ 3) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
3879
+ 4) 想确认状态:npx @misaka-net/misakanet-setup --verify
3880
+ 5) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
3395
3881
  + (clientIdHint
3396
3882
  ? `
3397
- 5) 记不记都行:这台机器的身份编号是 ${clientIdHint}
3883
+ 6) 记不记都行:这台机器的身份编号是 ${clientIdHint}
3398
3884
  想让它以后重装/换机还算同一个身份,再跑安装时加:--client-id ${clientIdHint}`
3399
3885
  : ''));
3400
3886
  }