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