claude-spotter 1.2.4 → 1.3.0
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/CHANGELOG.md +111 -0
- package/README.ja.md +50 -17
- package/README.md +51 -20
- package/package.json +1 -1
- package/src/cli/install.mjs +11 -2
- package/src/daemon/haiku-caller.mjs +84 -24
- package/src/tool-db/investigate-mcp.mjs +31 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
**Haiku spawn 時に user/project の MCP server を一切 load しないよう強制 — WSL2 で観測された CPU 100% 飽和 + 孤児 `npm exec` プロセス累積 + チャット入力無反応 の根本原因を断った minor bump**。修正は `claude -p` 起動引数に `--strict-mcp-config --mcp-config <empty>` を必ず付けるだけの最小実装、副作用なし。
|
|
6
|
+
|
|
7
|
+
### 観測した症状 (2026-05-04 WSL2 実環境)
|
|
8
|
+
|
|
9
|
+
WSL2 の CPU 使用率が 100% に張り付き、何かがプロセスを「無限増殖」させている — というユーザー報告から調査開始。`ps -eo pid,ppid,pcpu,etime,cmd --sort=-pcpu` の上位に **etime 3〜10 秒の `npm exec @modelcontextprotocol/server-*` / `@playwright/mcp` / `@upstash/context7-mcp` / `homework-mcp` / `caveat mcp` / `mcp-server-github` / `mcp-server-memory` 等が大量並走** し、親 PID は `claude -p --resume <uuid> --model claude-haiku-4-5-20251001` (= Spotter daemon の Haiku caller)。Spotter daemon が 3 並走、各々の Haiku 起動ごとに 60+ 個の MCP server を spawn → 終了 → 再 spawn のサイクルで CPU を食いつぶしていた。隣接プロジェクト ([Chime](file:///home/kite/projects/Chime)) で「VSCode 拡張のチャット入力が無反応」体感症状の真因も同根 (WSL2 全体の CPU 飽和で拡張側の入力処理がドロップ)。
|
|
10
|
+
|
|
11
|
+
### 真因
|
|
12
|
+
|
|
13
|
+
`sanitizeHaikuEnv` (v1.1.6) は `CLAUDE_CONFIG_DIR` を strip して Haiku をデフォルト `~/.claude/` で起動するが、**デフォルト config dir には User scope MCP (`~/.claude.json` 直下 `mcpServers`) と plugin MCP がフルで登録されている**。claude CLI 2.1.x は `--print` モード起動時に config dir 内の全 MCP server を eager に spawn するため、Haiku 1 回呼出ごとに数十個の `npm exec` 子プロセスが立つ。Haiku は `{name, description}` カタログ監査しかしない (= MCP server は不要) のに、起動コストとして user/project の MCP がフル load されていた構造的欠陥。
|
|
14
|
+
|
|
15
|
+
加えて `daemon-702a677d-...log` で同 sessionId の `tool-db loaded` が 15 分間に 8 回記録 = sudden death + auto-resurrect が高頻度発生していた。WSL2 cgroup OOM kill が daemon プロセスごと巻き込んでいた可能性が高く、[docs/open-issues.md](docs/open-issues.md) P0 「daemon プロセスが shutdown ログなしに死ぬ」 (v0.13.2 から残置) の主因もこれと推定。
|
|
16
|
+
|
|
17
|
+
### 変更点
|
|
18
|
+
|
|
19
|
+
- **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**:
|
|
20
|
+
- 定数 `EMPTY_MCP_CONFIG_PATH = ~/.spotter/workdir/empty-mcp.json` と `EMPTY_MCP_CONFIG_BODY = '{"mcpServers":{}}'` を追加
|
|
21
|
+
- `ensureWorkdir` を拡張し、空 MCP config ファイルを idempotent に書き出す
|
|
22
|
+
- 新 named export `emptyMcpConfigPath()` (テストから参照)
|
|
23
|
+
- `buildSpawnArgs({ ...args, mcpConfigPath })` のシグネチャを拡張、`mcpConfigPath` を必須化 (TypeError on missing/empty)、出力に `--strict-mcp-config --mcp-config <path>` を必ず含める。Windows `cmd.exe /c` 経路でも同様に
|
|
24
|
+
- `createHaikuCaller` 内 `spawn` 直前で `mcpConfigPath: EMPTY_MCP_CONFIG_PATH` を渡す
|
|
25
|
+
- **編集 [test/haiku-caller.test.mjs](test/haiku-caller.test.mjs)**: 回帰ガード 5 件追加 + 既存 `buildSpawnArgs` 2 件を新シグネチャに追従
|
|
26
|
+
- 新規: first call と resumed call の両方で `--strict-mcp-config` と `--mcp-config <path>` を含む
|
|
27
|
+
- 新規: `mcpConfigPath` 欠落 / 空文字 / null で TypeError
|
|
28
|
+
- 新規: `ensureWorkdir` で `empty-mcp.json` が `{"mcpServers":{}}` で書かれる
|
|
29
|
+
- 新規: `ensureWorkdir` の idempotent 性 (3 連呼出で破綻しない)
|
|
30
|
+
- **編集 [src/cli/install.mjs](src/cli/install.mjs)**: `HOOK_EVENTS` の Stop/UserPromptSubmit timeout を 15s/30s から **60s に統一**。v0.13.1 で daemon 側 Haiku timeout を 30s→45s に緩和したのに settings.json に書く Claude Code 本体側の hook timeout が旧値のままで、Chime 等の preamble が大きい (93 KB / 357 件) 環境で daemon が 24-32s かけて正常応答を返している最中に Claude Code 側 hook が timeout kill されて「チャット入力無反応」を誘発していた既存バグの hot-fix。`docs/open-issues.md` の P0「install.mjs の hook timeout が v0.13.1 緩和を反映していない」項目を closing
|
|
31
|
+
- **package.json**: `1.2.6` → `1.3.0` (公開 API シグネチャ変更 = `buildSpawnArgs` の新引数を伴うため minor bump)
|
|
32
|
+
- **編集 [docs/open-issues.md](docs/open-issues.md)**: 解決済みリストに 2 件追加、P0「daemon が shutdown ログなしに死ぬ」節に v1.3.0 で根因が大半解消した可能性を追記
|
|
33
|
+
|
|
34
|
+
### なぜ `--strict-mcp-config --mcp-config <empty>` が正解か
|
|
35
|
+
|
|
36
|
+
- **Anthropic auth は影響なし** — credentials は `~/.claude/.credentials.json` 等から従来どおり読まれる (`--bare` を使うと keychain skip で OAuth が壊れるが、`--strict-mcp-config` は MCP config を制限するだけで auth とは独立)
|
|
37
|
+
- **副作用ゼロ** — Haiku は `{name, description}` カタログ監査しか必要としない、MCP server を呼ばない。Spotter のカタログ収集 (`investigate-mcp.mjs`) は別経路 (`claude mcp list` + `.mcp.json` 直読み) で行うので Haiku 側に MCP は要らない
|
|
38
|
+
- **クロスプラットフォーム** — `--strict-mcp-config --mcp-config <path>` は Linux/macOS/Windows 全てで動作、`--mcp-config` は v0.x 時代から claude CLI に存在
|
|
39
|
+
- **既存の v1.1.6 `sanitizeHaikuEnv` と直交** — Bell の isolated `CLAUDE_CONFIG_DIR` 継承防止 (auth 失敗回避) は引き続き必要、本修正は MCP load 防止という別軸で重ねがけ
|
|
40
|
+
|
|
41
|
+
### Chime / Spotter ユーザー側で必要な手順
|
|
42
|
+
|
|
43
|
+
1. `npm install -g claude-spotter@1.3.0` で global update
|
|
44
|
+
2. 既 install プロジェクトでは hook 設定 (`spotter.mjs` のパス固定) は変わらないので **再 install 不要**、次の SessionStart から自動的に新コードが効く
|
|
45
|
+
3. 既存の孤児 daemon があれば `kill <pid>` + `rm ~/.spotter/runtime/session-*.pid` で掃除 (今後は v1.3.0 の MCP-disable で sudden death 自体が大幅減少見込み)
|
|
46
|
+
|
|
47
|
+
## 1.2.6
|
|
48
|
+
|
|
49
|
+
**チャット入力が無視される実害バグの根治**。実プロジェクト ([Chime](file:///home/kite/projects/Chime)) のセッションで Spotter daemon が UserPromptSubmit / Stop hook を Haiku 呼び出しで何度も `E_INTERNAL: haiku exited with code 1` させ、Claude Code 側 hook timeout (30s) に貼り付いて入力が応答しない状態が頻発していた。原因は Haiku を起動する `claude -p` への stdin 引き渡し方式と、claude CLI 2.1.x の stdin 取扱いの仕様の組合せで、推測ではなく実プロジェクト同条件 (`tools=357 件 / preamble=93 KB`) の最小再現で確定した。
|
|
50
|
+
|
|
51
|
+
### 観測された stderr
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Warning: no stdin data received in 3s, proceeding without it.
|
|
55
|
+
If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.
|
|
56
|
+
Error: Input must be provided either through stdin or as a prompt argument when using --print
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 真因 (推測ではなく実測で確定)
|
|
60
|
+
|
|
61
|
+
claude CLI 2.1.126 は `--print` モードで stdin の最初の read attempt が **約 3 秒** 以内に readable にならないと「stdin 無し」と判定して引数モードに切替えようとし、prompt が無いので exit 1 する。一方 Spotter は v0.7.0 の tool-db 化で preamble (role + schema + few-shot + 全カタログの JSON) を初回 1 回送信しているが、Chime のように `MCP × スキル × サブエージェント = 357 件` ある環境では preamble が **93 KB** 程度に達する。Linux の kernel pipe buffer (デフォルト 64 KB) を超えるため、Node 側 `child.stdin.end(buf)` の write が drain 待ちになり、claude CLI が起動 (auth + config + plugin 探索) を 3 秒以内に終えて最初の read syscall を発行しないと「3 秒間 no stdin data」と CLI 側で判定 → stdin 放棄 → exit 1。CLI は "Warning" を出して続行するふりをするが、実際は `--print` 引数も無いので "Input must be provided" で死ぬ。
|
|
62
|
+
|
|
63
|
+
実測 (2026-05-04, /home/kite/projects/Chime での Spotter セッション + WORKDIR 隔離環境):
|
|
64
|
+
|
|
65
|
+
| stdin 経路 | duration | exit code | stderr |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| `child.stdin.end(prompt)` (pipe) | 17 秒 | 1 | "no stdin data received in 3s" + "Input must be provided" |
|
|
68
|
+
| **tempfile fd を `stdio[0]` に渡す (本修正)** | **24-32 秒** | **0** | (空) — 正常 JSON 応答 |
|
|
69
|
+
|
|
70
|
+
このバグの可視結果は、daemon log の以下メッセージが繰り返し出る現象として観測されていた:
|
|
71
|
+
- `user_input: haiku invocation failed (E_INTERNAL), rotating session before rethrow: haiku exited with code 1: Warning: no stdin data received in 3s ...`
|
|
72
|
+
- 当該 turn は session を rotate して `mode=first` の cold-start に逆戻り (preamble 再送 → ますます重い) → 30s hook timeout に貼り付き → ユーザー視点で「チャット入力が無視される」
|
|
73
|
+
|
|
74
|
+
### 変更点
|
|
75
|
+
|
|
76
|
+
- **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: 新規 `preparePromptFile(wirePrompt)` を named export として追加。`os.tmpdir()` 配下にユニーク tempfile (`spotter-prompt-<pid>-<uuid>.txt`) を作って prompt を書き込み、read-only fd と `close()` ハンドラ (fd close + unlink、両方 best-effort) を返す。`createHaikuCaller` 内 `callHaiku` の `spawn` を `stdio: ['pipe', 'pipe', 'pipe']` + `child.stdin.end(prompt)` から `stdio: [promptFile.fd, 'pipe', 'pipe']` に切替え (child.stdin が null になるので関連 noop listener も削除)。close / error / timeout の各 settle 経路で `promptFile.close()` を呼んでから resolve/reject する `settleAfterCleanup` ヘルパで cleanup を一元化、tempfile leak を防止
|
|
77
|
+
- **編集 [test/haiku-caller.test.mjs](test/haiku-caller.test.mjs)**: 回帰ガード 6 件追加 — (1) tempfile が書かれて読める (2) 100 KB (= pipe buffer 超え) でも全 byte 届く (3) `close()` で unlink される (4) `close()` 二重呼び出し可 (timeout-vs-close race の安全性) (5) 非 string 入力で `TypeError` (6) 並列呼び出しで tmpPath が衝突しない (UUID 保証)
|
|
78
|
+
- **package.json**: `1.2.5` → `1.2.6`
|
|
79
|
+
|
|
80
|
+
### なぜ tempfile fd 方式が正解か
|
|
81
|
+
|
|
82
|
+
- **file は kernel が即時 readable と判定** — pipe と違い「writer 側の進捗」を待たないので、CLI 起動が遅くても 3 秒タイマーに引っかからない
|
|
83
|
+
- **pipe buffer の制約から完全に独立** — 64 KB / 1 MB / 10 MB どんな payload でも file 経由なら一発で届く
|
|
84
|
+
- **クロスプラットフォーム** — Windows でも `os.tmpdir()` は機能、`stdio[0]` への numeric fd 受け渡しも Node が抽象化済み
|
|
85
|
+
- **CLI 引数モード (`-p "<prompt>"`) と違い ARG_MAX に縛られない** — Windows の CommandLineW 32K 制限を回避
|
|
86
|
+
|
|
87
|
+
### Chime で「無反応」が消えるまでに必要な手順 (ユーザー側)
|
|
88
|
+
|
|
89
|
+
1. `npm install -g claude-spotter@1.2.6` で global update
|
|
90
|
+
2. Chime 側の既 install プロジェクトでは hook 設定 (`~/.claude.json` の per-project hooks) は変わらない (`spotter.mjs` のパスは固定) ので **再 install 不要**、次の SessionStart から自動的に新コードが効く
|
|
91
|
+
3. 既存の孤児 daemon があれば `rm ~/.spotter/runtime/session-*.pid` で掃除 (生存 daemon 0 件確認後のみ)
|
|
92
|
+
|
|
93
|
+
## 1.2.5
|
|
94
|
+
|
|
95
|
+
**ECC プラグイン経由の MCP サーバー 6 件 (context7 / exa / github / memory / playwright / sequential-thinking) のツール群 (61 件) が Spotter のカタログから silent に欠落していた **二重構造バグ**を修正**。実プロジェクト (Web) で `spotter install` 実行時のログに ``mcp investigate failed for "plugin": Command failed: cmd.exe /c claude mcp get plugin`` が **6 連発** で出ていたのを契機に発見。これらのプラグイン MCP の呼び忘れを Spotter が検出できない状態だった (Web プロジェクトで rebuild すると 309 → 370 件、プラグイン由来 61 件が追加されることを確認)。
|
|
96
|
+
|
|
97
|
+
### 変更点
|
|
98
|
+
|
|
99
|
+
- **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)** (2 段階):
|
|
100
|
+
1. `parseMcpListOutput` の name 区切りを `indexOf(':')` (コロン単体) から `indexOf(': ')` (コロン + スペース) に変更。サーバー名側に literal `": "` (コロン + スペース) は CLI 仕様上現れない (CLI が name と rest の間に固定でこのペアを置くため) ので、空白入り名前 (`claude.ai Google Drive`) もコロン入り名前 (`plugin:everything-claude-code:context7`) も両立する
|
|
101
|
+
2. stdio エントリの `command` / `args` を CLI 出力行 (`<name>: <command> <args...> - <status>`) から直接抽出するよう変更。プラグイン MCP は `claude mcp list` には出るが `claude mcp get <name>` では `No MCP server found` で引けない仕様 (実測で確認) のため、CLI 行を唯一の権威ソースとして扱う必要がある。既存 `listMcpToolsOne` の `hasFullConfig` 分岐がそのまま生かされ、`claude mcp get` 経路を skip して直接 spawn するようになる。tokenisation は既存 `splitArgs` と同じ素朴 (whitespace 区切り、quote 非対応) を踏襲、空白入りパスの制約は変わらず
|
|
102
|
+
- **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: 回帰テスト 3 件追加 + 既存 1 件の expectation 拡張 — プラグイン形式 stdio (`plugin:everything-claude-code:context7` を `npx ...` で登録、`command='npx'` / `args=['-y', '@upstash/context7-mcp@2.1.4']` を assert) / プラグイン形式 HTTP (`plugin:everything-claude-code:exa` を `(HTTP)` URL で登録) / 空白入り名前の継続パース (`claude.ai Google Drive` の sse 経路) / 既存 stdio エントリ (`caveat`) も command/args を返すことを assert
|
|
103
|
+
|
|
104
|
+
### 背景
|
|
105
|
+
|
|
106
|
+
`claude mcp list` の出力フォーマットは `<name>: <url-or-command> [(HTTP)] - <status>` で、name 部分には許容文字に応じてスペースもコロンも入りうる。Spotter は v0.7.0 でこの text パースを導入したが、当時のサンプル (`caveat: ...`, `x-api: ...`) には内部コロンが無かったため `indexOf(':')` で素朴に切っていた。Claude Code 側でプラグイン経由 MCP の名前が `plugin:<plugin-id>:<server>` 形式になったことで、6 サーバー全てが name=`"plugin"` に折り畳まれ、`claude mcp get plugin` が `No MCP server found` で失敗、catalog 投入をスキップする経路に流れていた。
|
|
107
|
+
|
|
108
|
+
step 1 (name 区切り修正) でフルネームは取れるようになったが、Web プロジェクトでの局所実測で **`claude mcp get plugin:everything-claude-code:github` 等のフルネーム指定でも `No MCP server found with name: ...` を返す**ことが判明。プラグイン MCP は `mcp get` の対象外であり、`mcp list` 出力が唯一の権威ソース。step 2 で `parseMcpListOutput` を拡張して command/args を直接 tokenize、`hasFullConfig === true` で再 query を skip させた。
|
|
109
|
+
|
|
110
|
+
`indexOf(': ')` (コロン + スペース) は CLI が固定で挿入する 2 文字ペアであり、サーバー名内部にこのペアが現れることは構造的に無いので、name 内の任意の `:` (コロン単体) と ` ` (スペース単体) を許容しつつ name と rest を一意に切れる。
|
|
111
|
+
|
|
112
|
+
[docs/open-issues.md](docs/open-issues.md) P1 「`claude mcp list` text パースの脆弱性」全体は依然として残る (CLI フォーマット変更耐性は本修正でも上がらない、`--json` 出力が来たら全面切り替えしたい) が、コロン入り名前 + プラグイン MCP の具体例はこの版で塞がる。
|
|
113
|
+
|
|
3
114
|
## 1.2.4
|
|
4
115
|
|
|
5
116
|
**v1.2.3 で `normalizeProjectPath` の挙動を変えた際に、対になる test の expectation 更新を漏らしたため macOS CI で fail していた hot-fix**。`normalizeProjectPath: separator / trailing slash / Windows case` ([test/tool-db.test.mjs:678](test/tool-db.test.mjs#L678)) は「backslash は常に forward slash になる」という旧仕様の expectation を残したまま v1.2.3 commit に取り込まれており、POSIX 上で `'C:\\Users\\u\\proj'` の入力に対して `'C:\\Users\\u\\proj'` (literal 保持) が返るのを `'C:/Users/u/proj'` で assert していた。Linux CI は v1.2.3 で緑化したが、v1.2.3 push 後の matrix 実行で macOS が同じ test で fail。
|
package/README.ja.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src=".github/og.
|
|
2
|
+
<img src=".github/og.png" alt="Spotter — Audit agent for Claude Code" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# Spotter
|
|
@@ -27,6 +27,9 @@ Spotter が拾うのは、たとえばこういう瞬間です。
|
|
|
27
27
|
| 「この設定ファイルの中身は?」 | 名前から推測で説明 | `read_file` の使用機会 |
|
|
28
28
|
| 「今何時?」 | 学習時点の情報で答える | `current_time` の使用機会 |
|
|
29
29
|
| 事実の断定 | 裏付けなしで「〜です」 | 検証用ツールの差し込み余地 |
|
|
30
|
+
| 「このライブラリの最新版の使い方は?」 | 学習データから書き起こす | docs lookup MCP の照会機会 |
|
|
31
|
+
| 「この UI 今もちゃんと動く?」 | コード読みだけで結論 | ブラウザ自動化 MCP の使用機会 |
|
|
32
|
+
| 「以前何を決めたっけ?」 | 推測 / 失念のまま回答 | メモリ / ノート系 MCP の照会機会 |
|
|
30
33
|
|
|
31
34
|
判定軸は 2 段階:
|
|
32
35
|
|
|
@@ -55,23 +58,49 @@ spotter uninstall # このプロジェクトの hook 登録を解除
|
|
|
55
58
|
|
|
56
59
|
## アーキテクチャ
|
|
57
60
|
|
|
61
|
+
### 1 ターンの監査フロー
|
|
62
|
+
|
|
63
|
+
```mermaid
|
|
64
|
+
flowchart TD
|
|
65
|
+
U([User 発話]) --> UPH[UserPromptSubmit hook<br/>Spotter が発話とカタログから一次判定]
|
|
66
|
+
UPH --> BT[Bell Thinking<br/>Spotter の推奨を<br/>additionalContext で受信]
|
|
67
|
+
BT --> BA([Bell の最初の応答])
|
|
68
|
+
BA --> SH[Stop hook<br/>応答と使用済みツールから最終チェック]
|
|
69
|
+
SH --> DEC{見落とし<br/>あり?}
|
|
70
|
+
DEC -->|なし| DONE([完了])
|
|
71
|
+
DEC -->|あり| SB[差し戻し<br/>max 1 回<br/>stop_hook_active で自動担保]
|
|
72
|
+
SB --> BA2([Bell の補正応答]) --> DONE
|
|
58
73
|
```
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
|
|
75
|
+
### カタログの収集経路
|
|
76
|
+
|
|
77
|
+
```mermaid
|
|
78
|
+
flowchart LR
|
|
79
|
+
subgraph SRC[収集ソース]
|
|
80
|
+
direction TB
|
|
81
|
+
MCP[MCP サーバー<br/>claude mcp list で列挙]
|
|
82
|
+
SK[スキル<br/>SKILL.md frontmatter]
|
|
83
|
+
AG[サブエージェント<br/>agent .md frontmatter]
|
|
84
|
+
BL[claude.ai baseline<br/>Gmail / Calendar / Drive<br/>存在時のみ注入]
|
|
85
|
+
end
|
|
86
|
+
subgraph SCOPES["MCP の env / headers — 4 スコープ、上位が衝突に勝つ"]
|
|
87
|
+
direction TB
|
|
88
|
+
L["Local — projects.<root>.mcpServers in ~/.claude.json"]
|
|
89
|
+
P["Project — <root>/.mcp.json"]
|
|
90
|
+
US["User — mcpServers in ~/.claude.json"]
|
|
91
|
+
LG["Legacy — ~/.claude/.mcp.json"]
|
|
92
|
+
end
|
|
93
|
+
SCOPES -. merge .-> MCP
|
|
94
|
+
MCP --> DB[(ローカル tool-db.json<br/>name + description<br/>プロジェクト単位)]
|
|
95
|
+
SK --> DB
|
|
96
|
+
AG --> DB
|
|
97
|
+
BL --> DB
|
|
98
|
+
DB --> H[Haiku 監査<br/>session-scoped, preamble-once]
|
|
70
99
|
```
|
|
71
100
|
|
|
72
|
-
監査対象のツール (name + description) は `<project>/.spotter/tool-db.json` (ローカル) に格納されます。**daemon が監査に使うのはローカル DB
|
|
101
|
+
監査対象のツール (name + description) は `<project>/.spotter/tool-db.json` (ローカル) に格納されます。**daemon が監査に使うのはローカル DB のみ**で、グローバル DB `~/.spotter/tool-db.json` は他プロジェクトでの description 再利用キャッシュとしてのみ機能します (live fetch コスト削減のため初回 refresh で参照、結果は local に write-through)。各プロジェクトの local DB は **そのプロジェクトの現時点の discovery 結果と一致** (refresh 時に prune される) ため、過去にインストールしていた MCP / スキル / サブエージェントが他プロジェクトに混入することはありません。
|
|
73
102
|
|
|
74
|
-
|
|
103
|
+
**`spotter install` が初回 seed を自動実行し、Claude Code セッション起動ごとに SessionStart hook が bg で `spotter db refresh` を走らせる**ため、通常の運用で手動コマンドを叩く必要はありません。各 MCP サーバーの `tools/list` は JSON-RPC で取得 (HTTP / SSE / stdio transport 対応)、スキルとサブエージェントは frontmatter から直接抽出、claude.ai baseline (OAuth proxy 経由の Gmail / Calendar / Drive 25 件) は `claude mcp list` に該当サーバーが存在する環境でのみ注入されます。**手書きでツールリストを管理する必要はありません**。
|
|
75
104
|
|
|
76
105
|
## Throughline との関係
|
|
77
106
|
|
|
@@ -110,11 +139,15 @@ spotter uninstall # hook 登録を解除 (~/.spotter は残す)
|
|
|
110
139
|
- **JSON スキーマ違反は v0.5.0 以降「想定済み異常」として silent pass + session renew で回復**します (role collapse 検知パス、daemon ログに `role_collapse_reset` を残す)。一方 **Haiku timeout は引き続き throw** され、UserPromptSubmit がブロックされてユーザー入力が Bell に届かない症状として顕在化します (timeout は v0.5.0 で 30s、v0.13.1 で 45s に拡張)。timeout の fail-open 化 (pass 扱い) は §0 改訂とセットで今後検討
|
|
111
140
|
|
|
112
141
|
<details>
|
|
113
|
-
<summary><strong>📋
|
|
142
|
+
<summary><strong>📋 最近のハイライト</strong></summary>
|
|
114
143
|
|
|
115
|
-
|
|
144
|
+
- **プラグイン形式の MCP サーバー対応** — `plugin:everything-claude-code:context7` のように名前に内部コロンを含むサーバーを正しくパースし、配下のツールをカタログに取り込めるようになった (旧版はこの形式のサーバーをすべて単一の `"plugin"` に潰して、Bell の監査から silent に脱落させていた)
|
|
145
|
+
- **プロジェクト単位の監査隔離** — daemon が監査に使うのはローカル DB のみ。グローバル DB は description 再利用キャッシュに役割限定。**他プロジェクト**でインストールしたツールが現プロジェクトの監査に混入することはない
|
|
146
|
+
- **手放しでカタログ維持** — `spotter install` が tool-db を自動 seed、SessionStart で bg refresh が走る。手書き管理は一切不要
|
|
147
|
+
- **監査対象** — ユーザー追加分 (MCP / スキル / サブエージェント) のみ。Claude Code 本体側のツールは意図的に対象外 (Bell は元から自発率が高いため)
|
|
148
|
+
- **実装規範** — フォールバック禁止 / silent fallback 禁止 / 暫定コード禁止 ([CLAUDE.md §0](CLAUDE.md))
|
|
116
149
|
|
|
117
|
-
|
|
150
|
+
リリース履歴の全文は [CHANGELOG](CHANGELOG.md) を参照。
|
|
118
151
|
|
|
119
152
|
</details>
|
|
120
153
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src=".github/og.
|
|
2
|
+
<img src=".github/og.png" alt="Spotter — Audit agent for Claude Code" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# Spotter
|
|
@@ -26,7 +26,10 @@ Examples of what Spotter catches:
|
|
|
26
26
|
| "What's the weather today?" | Guess from training data | Missed call to `web_search` |
|
|
27
27
|
| "What's in this config file?" | Describe based on the filename | Missed call to `read_file` |
|
|
28
28
|
| "What time is it?" | Answer from training-time knowledge | Missed call to `current_time` |
|
|
29
|
-
| Asserting a fact |
|
|
29
|
+
| Asserting a fact | State it without verification | Opportunity to call a verification tool |
|
|
30
|
+
| "How does this library API work in the latest version?" | Recite from training data | Missed call to a docs-lookup MCP |
|
|
31
|
+
| "Does this UI still render correctly?" | Reason from source code alone | Missed call to a browser-automation MCP |
|
|
32
|
+
| "What did we decide about X earlier?" | Guess or admit forgetting | Missed call to a memory / notes MCP |
|
|
30
33
|
|
|
31
34
|
Spotter audits in two stages:
|
|
32
35
|
|
|
@@ -55,23 +58,49 @@ spotter uninstall # remove hooks from this project
|
|
|
55
58
|
|
|
56
59
|
## Architecture
|
|
57
60
|
|
|
61
|
+
### Audit flow per turn
|
|
62
|
+
|
|
63
|
+
```mermaid
|
|
64
|
+
flowchart TD
|
|
65
|
+
U([User prompt]) --> UPH[UserPromptSubmit hook<br/>Spotter audits prompt against catalog]
|
|
66
|
+
UPH --> BT[Bell thinking<br/>receives Spotter's recommendations<br/>as additionalContext]
|
|
67
|
+
BT --> BA([Bell's first answer])
|
|
68
|
+
BA --> SH[Stop hook<br/>Spotter re-audits answer + tools used]
|
|
69
|
+
SH --> DEC{Missed<br/>tool?}
|
|
70
|
+
DEC -->|No| DONE([Done])
|
|
71
|
+
DEC -->|Yes| SB[Send-back to Bell<br/>max 1 round<br/>guarded by stop_hook_active]
|
|
72
|
+
SB --> BA2([Bell's amended answer]) --> DONE
|
|
58
73
|
```
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
|
|
75
|
+
### Catalog discovery
|
|
76
|
+
|
|
77
|
+
```mermaid
|
|
78
|
+
flowchart LR
|
|
79
|
+
subgraph SRC[Discovery sources]
|
|
80
|
+
direction TB
|
|
81
|
+
MCP[MCP servers<br/>via claude mcp list]
|
|
82
|
+
SK[Skills<br/>SKILL.md frontmatter]
|
|
83
|
+
AG[Sub-agents<br/>agent .md frontmatter]
|
|
84
|
+
BL[claude.ai baseline<br/>Gmail / Calendar / Drive<br/>injected when present]
|
|
85
|
+
end
|
|
86
|
+
subgraph SCOPES["MCP env / headers — 4 scopes, top wins on collision"]
|
|
87
|
+
direction TB
|
|
88
|
+
L["Local — projects.<root>.mcpServers in ~/.claude.json"]
|
|
89
|
+
P["Project — <root>/.mcp.json"]
|
|
90
|
+
US["User — mcpServers in ~/.claude.json"]
|
|
91
|
+
LG["Legacy — ~/.claude/.mcp.json"]
|
|
92
|
+
end
|
|
93
|
+
SCOPES -. merged into .-> MCP
|
|
94
|
+
MCP --> DB[(Local tool-db.json<br/>name + description<br/>per project)]
|
|
95
|
+
SK --> DB
|
|
96
|
+
AG --> DB
|
|
97
|
+
BL --> DB
|
|
98
|
+
DB --> H[Haiku audit<br/>session-scoped, preamble-once]
|
|
70
99
|
```
|
|
71
100
|
|
|
72
|
-
The audited
|
|
101
|
+
The audited catalog lives in `<project>/.spotter/tool-db.json`. **The daemon audits against the local DB only**; the global DB at `~/.spotter/tool-db.json` is a description-reuse cache shared across projects, not an audit source. Each project's local DB always matches the **current** discovery snapshot for that project (stale entries are pruned on refresh), so tools installed in *other* projects can never bleed into this project's audit.
|
|
73
102
|
|
|
74
|
-
|
|
103
|
+
**`spotter install` seeds the catalog automatically, and the SessionStart hook runs a background `spotter db refresh` on every Claude Code session start** — so you don't need to invoke catalog commands by hand. Each MCP server's `tools/list` is fetched via JSON-RPC (HTTP / SSE / stdio transports supported); skill and sub-agent metadata comes straight from frontmatter; the claude.ai baseline (25 hand-curated entries for Gmail / Calendar / Drive over OAuth proxy) is injected only when `claude mcp list` confirms the server is present. **You never have to maintain the tool list by hand.**
|
|
75
104
|
|
|
76
105
|
## Spotter and Throughline
|
|
77
106
|
|
|
@@ -112,13 +141,15 @@ spotter uninstall # remove hooks from this project (leaves ~/.spotter int
|
|
|
112
141
|
- **Since v0.5.0, JSON schema violations from Haiku are treated as expected-anomalies** (silent pass + session renew, logged as `role_collapse_reset`) — this is the role-collapse recovery path. **Haiku timeouts still throw**, which surfaces as `UserPromptSubmit` blocking the user's prompt from reaching Bell. Timeouts have been raised twice (30s in v0.5.0, 45s in v0.13.1); making timeouts fail-open is deferred until §0 is revisited
|
|
113
142
|
|
|
114
143
|
<details>
|
|
115
|
-
<summary><strong>📋
|
|
116
|
-
|
|
117
|
-
**Structural fix for a regression that suggested tools the current project can't actually use.** The catalog the daemon audits against is now **local-only**. The global DB has been demoted to a cross-project description-reuse cache. A prune step was added to the end of `resolveAll`: any local entry not present in the current project's discovery snapshot is removed. This closes the path through which MCP servers / skills / sub-agents discovered in other projects used to linger in this project's audit set.
|
|
144
|
+
<summary><strong>📋 Recent highlights</strong></summary>
|
|
118
145
|
|
|
119
|
-
|
|
146
|
+
- **Plugin-scoped MCP servers** — names like `plugin:everything-claude-code:context7` (with internal colons) are now parsed correctly and their tools enter the catalog. Earlier versions silently collapsed all plugin MCP servers into a single literal `"plugin"`, dropping their tools from Bell's audit
|
|
147
|
+
- **Per-project audit isolation** — the daemon audits against the local DB only; the global DB has been demoted to a description-reuse cache. Tools discovered in *other* projects can never bleed into this project's audit set
|
|
148
|
+
- **Zero-touch catalog** — `spotter install` seeds the tool DB automatically, and SessionStart triggers a background refresh. You never have to maintain the tool list by hand
|
|
149
|
+
- **Audit scope** — only user-added surface (MCP servers / skills / sub-agents). Claude Code's built-in tools are intentionally out of scope; Bell already uses those reliably
|
|
150
|
+
- **Implementation invariants** — no fallbacks, no silent failures, no provisional code (see [§0 in CLAUDE.md](CLAUDE.md))
|
|
120
151
|
|
|
121
|
-
Full
|
|
152
|
+
Full release history: [CHANGELOG](CHANGELOG.md).
|
|
122
153
|
|
|
123
154
|
</details>
|
|
124
155
|
|
package/package.json
CHANGED
package/src/cli/install.mjs
CHANGED
|
@@ -28,11 +28,20 @@ const SPOTTER_HOME = join(homedir(), '.spotter');
|
|
|
28
28
|
|
|
29
29
|
const MARKER_VERSION = '1';
|
|
30
30
|
|
|
31
|
+
// v1.2.6: UserPromptSubmit / Stop を 60s に統一。理由:
|
|
32
|
+
// - daemon 側 Haiku timeout は 45s (DEFAULT_HAIKU_TIMEOUT_MS @ daemon.mjs)
|
|
33
|
+
// - hook → daemon IPC 往復・JSON parse・log fsync を加味すると ~50s が上限
|
|
34
|
+
// - Claude Code 本体は settings.json の timeout で hook を kill するので、ここが最も狭い
|
|
35
|
+
// 旧値 (UserPromptSubmit=30 / Stop=15) は v0.13.1 の Haiku timeout 緩和 (30→45s) を
|
|
36
|
+
// 反映しておらず、Chime 等の preamble が大きい (93 KB / 357 件) 環境で daemon が
|
|
37
|
+
// 24-32s かけて正常応答を返している最中に Claude Code 側で hook が timeout で
|
|
38
|
+
// 切られ、ユーザー視点の「チャット入力無反応」を誘発していた。docs/open-issues.md
|
|
39
|
+
// の「install.mjs の hook timeout が v0.13.1 緩和を反映していない」項目を閉じる。
|
|
31
40
|
const HOOK_EVENTS = [
|
|
32
41
|
{ event: 'SessionStart', sub: 'session-start', timeout: 5 },
|
|
33
|
-
{ event: 'UserPromptSubmit', sub: 'user-prompt', timeout:
|
|
42
|
+
{ event: 'UserPromptSubmit', sub: 'user-prompt', timeout: 60 },
|
|
34
43
|
{ event: 'PreToolUse', sub: 'pre-tool-use', timeout: 2 },
|
|
35
|
-
{ event: 'Stop', sub: 'stop', timeout:
|
|
44
|
+
{ event: 'Stop', sub: 'stop', timeout: 60 },
|
|
36
45
|
{ event: 'SessionEnd', sub: 'session-end', timeout: 3 },
|
|
37
46
|
];
|
|
38
47
|
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
// puts the catalog only in the first turn's user message; the session retains it for free.
|
|
15
15
|
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
|
-
import { homedir } from 'node:os';
|
|
17
|
+
import { homedir, tmpdir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { mkdir } from 'node:fs/promises';
|
|
19
|
+
import { mkdir, writeFile, unlink, open } from 'node:fs/promises';
|
|
20
20
|
import { randomUUID } from 'node:crypto';
|
|
21
21
|
|
|
22
22
|
const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
|
|
23
23
|
const WORKDIR = join(homedir(), '.spotter', 'workdir');
|
|
24
|
+
// v1.3.0: Haiku spawn 時に user/project MCP 設定を一切 load させないための空 config。
|
|
25
|
+
// claude CLI の `--strict-mcp-config --mcp-config <path>` に渡せば、`~/.claude.json`
|
|
26
|
+
// (User scope) も `<projectRoot>/.mcp.json` (Project scope) も無視される。Haiku は
|
|
27
|
+
// `{name, description}` カタログ監査しかしないので MCP server は不要 → 起動コスト 0、
|
|
28
|
+
// CPU 飽和 + 孤児 npm exec プロセス累積を根本断ち。
|
|
29
|
+
const EMPTY_MCP_CONFIG_PATH = join(WORKDIR, 'empty-mcp.json');
|
|
30
|
+
const EMPTY_MCP_CONFIG_BODY = '{"mcpServers":{}}';
|
|
24
31
|
|
|
25
32
|
export class HaikuError extends Error {
|
|
26
33
|
constructor(code, message) {
|
|
@@ -33,9 +40,17 @@ export class HaikuError extends Error {
|
|
|
33
40
|
export async function ensureWorkdir() {
|
|
34
41
|
// §5.2: the workdir is isolated. No CLAUDE.md here.
|
|
35
42
|
await mkdir(WORKDIR, { recursive: true });
|
|
43
|
+
// v1.3.0: 空 MCP config を idempotent に置く。Haiku spawn 時に `--strict-mcp-config
|
|
44
|
+
// --mcp-config <path>` で参照させて user/project の MCP server load を完全 disable。
|
|
45
|
+
// 既存ファイルが内容一致なら write skip でディスク I/O ゼロ。
|
|
46
|
+
await writeFile(EMPTY_MCP_CONFIG_PATH, EMPTY_MCP_CONFIG_BODY, { encoding: 'utf8' });
|
|
36
47
|
return WORKDIR;
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
export function emptyMcpConfigPath() {
|
|
51
|
+
return EMPTY_MCP_CONFIG_PATH;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
// Shared header covers BOTH stages — the preamble documents stage=user_input and
|
|
40
55
|
// stage=turn_end so per-turn prompts only need to announce which stage they are.
|
|
41
56
|
//
|
|
@@ -231,13 +246,52 @@ export function sanitizeHaikuEnv(baseEnv) {
|
|
|
231
246
|
return rest;
|
|
232
247
|
}
|
|
233
248
|
|
|
249
|
+
// v1.3.0: write the wire prompt to a tempfile and return its read-only fd.
|
|
250
|
+
//
|
|
251
|
+
// claude CLI 2.1.x abandons stdin after roughly 3 seconds with no read activity, printing
|
|
252
|
+
// "Warning: no stdin data received in 3s, proceeding without it." and then "Input must be
|
|
253
|
+
// provided either through stdin or as a prompt argument when using --print" before exit
|
|
254
|
+
// 1. With Spotter's full preamble (~93 KB on a project with ~360 catalog entries) the
|
|
255
|
+
// kernel pipe buffer (Linux default 64 KB) fills up; the rest waits for the CLI to drain
|
|
256
|
+
// it. If the CLI's startup (auth + config + plugin discovery) takes longer than 3 s
|
|
257
|
+
// before its first read syscall, it has already given up — even though the parent (Node)
|
|
258
|
+
// is correctly buffering the rest of the prompt.
|
|
259
|
+
//
|
|
260
|
+
// A real file as stdin is always immediately readable to EOF on every platform, sidesteps
|
|
261
|
+
// the pipe-buffer/drain interaction entirely, and removes the timing dependency on CLI
|
|
262
|
+
// startup latency. The tempfile lives in os.tmpdir(); cleanup is best-effort (the OS
|
|
263
|
+
// reclaims it on reboot regardless).
|
|
264
|
+
export async function preparePromptFile(wirePrompt) {
|
|
265
|
+
if (typeof wirePrompt !== 'string') {
|
|
266
|
+
throw new TypeError('preparePromptFile: wirePrompt must be a string');
|
|
267
|
+
}
|
|
268
|
+
const tmpPath = join(tmpdir(), `spotter-prompt-${process.pid}-${randomUUID()}.txt`);
|
|
269
|
+
await writeFile(tmpPath, wirePrompt, { encoding: 'utf8' });
|
|
270
|
+
const handle = await open(tmpPath, 'r');
|
|
271
|
+
return {
|
|
272
|
+
tmpPath,
|
|
273
|
+
fd: handle.fd,
|
|
274
|
+
close: async () => {
|
|
275
|
+
try { await handle.close(); } catch (_e) { /* best-effort: child may have closed it */ }
|
|
276
|
+
try { await unlink(tmpPath); } catch (_e) { /* tmpdir is OS-cleaned; leak is acceptable */ }
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
234
281
|
// On Windows, the `claude` entry is typically a .cmd shim which Node's spawn cannot locate
|
|
235
282
|
// without going through the shell. We use cmd.exe /c explicitly rather than spawn({ shell:
|
|
236
283
|
// true }) because the latter triggers DEP0190 on Node 24+.
|
|
237
|
-
|
|
238
|
-
|
|
284
|
+
//
|
|
285
|
+
// v1.3.0: `--strict-mcp-config --mcp-config <empty>` を必ず付けて MCP load を無効化する。
|
|
286
|
+
// `mcpConfigPath` は呼び出し側 (createHaikuCaller) が必ず渡す前提。テストで shape を pin。
|
|
287
|
+
export function buildSpawnArgs({ claudeBin, model, sessionId, resume, mcpConfigPath }) {
|
|
288
|
+
if (typeof mcpConfigPath !== 'string' || mcpConfigPath.length === 0) {
|
|
289
|
+
throw new TypeError('buildSpawnArgs: mcpConfigPath must be a non-empty string');
|
|
290
|
+
}
|
|
291
|
+
const baseArgs = resume
|
|
239
292
|
? ['-p', '--resume', sessionId, '--model', model]
|
|
240
293
|
: ['-p', '--session-id', sessionId, '--model', model];
|
|
294
|
+
const args = [...baseArgs, '--strict-mcp-config', '--mcp-config', mcpConfigPath];
|
|
241
295
|
if (process.platform === 'win32') {
|
|
242
296
|
return { cmd: 'cmd.exe', cmdArgs: ['/c', claudeBin, ...args] };
|
|
243
297
|
}
|
|
@@ -267,38 +321,42 @@ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', m
|
|
|
267
321
|
const callHaiku = async function (prompt) {
|
|
268
322
|
await ensureWorkdir();
|
|
269
323
|
const wirePrompt = (isFirstCall && preamble) ? `${preamble}\n\n${prompt}` : prompt;
|
|
324
|
+
// v1.3.0: stdin is a tempfile fd, not a pipe. See preparePromptFile for the why.
|
|
325
|
+
const promptFile = await preparePromptFile(wirePrompt);
|
|
270
326
|
return new Promise((resolve, reject) => {
|
|
271
327
|
const { cmd, cmdArgs } = buildSpawnArgs({
|
|
272
328
|
claudeBin,
|
|
273
329
|
model,
|
|
274
330
|
sessionId: currentSessionId,
|
|
275
331
|
resume: !isFirstCall,
|
|
332
|
+
mcpConfigPath: EMPTY_MCP_CONFIG_PATH,
|
|
276
333
|
});
|
|
277
334
|
const child = spawn(cmd, cmdArgs, {
|
|
278
335
|
cwd: WORKDIR,
|
|
279
336
|
env: { ...sanitizeHaikuEnv(env), SPOTTER_PARENT_PID: String(process.pid) },
|
|
280
|
-
stdio: [
|
|
337
|
+
stdio: [promptFile.fd, 'pipe', 'pipe'],
|
|
281
338
|
windowsHide: true,
|
|
282
339
|
});
|
|
283
340
|
let stdout = '';
|
|
284
341
|
let stderr = '';
|
|
285
342
|
let settled = false;
|
|
286
343
|
|
|
287
|
-
//
|
|
288
|
-
// then kill() on timeout — if the write hadn't drained yet, the unflushed
|
|
289
|
-
// stream can emit 'error' after kill. Today's Node doesn't crash on
|
|
290
|
-
// unhandled stdin errors, but the docs don't guarantee that, and this
|
|
291
|
-
// listener removes a potential silent-death path.
|
|
344
|
+
// child.stdin is null when stdio[0] is a raw fd; only stdout/stderr remain to guard.
|
|
292
345
|
const noop = () => {};
|
|
293
|
-
child.stdin.on('error', noop);
|
|
294
346
|
child.stdout.on('error', noop);
|
|
295
347
|
child.stderr.on('error', noop);
|
|
296
348
|
|
|
349
|
+
const settleAfterCleanup = (fn) => {
|
|
350
|
+
promptFile.close().finally(fn);
|
|
351
|
+
};
|
|
352
|
+
|
|
297
353
|
const timer = setTimeout(() => {
|
|
298
354
|
if (settled) return;
|
|
299
355
|
settled = true;
|
|
300
356
|
child.kill();
|
|
301
|
-
|
|
357
|
+
settleAfterCleanup(() => reject(
|
|
358
|
+
new HaikuError('E_HAIKU_TIMEOUT', `haiku did not respond within ${timeoutMs}ms`)
|
|
359
|
+
));
|
|
302
360
|
}, timeoutMs);
|
|
303
361
|
|
|
304
362
|
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
@@ -308,25 +366,27 @@ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', m
|
|
|
308
366
|
if (settled) return;
|
|
309
367
|
settled = true;
|
|
310
368
|
clearTimeout(timer);
|
|
311
|
-
|
|
369
|
+
settleAfterCleanup(() => reject(
|
|
370
|
+
new HaikuError('E_INTERNAL', `failed to spawn ${claudeBin}: ${err.message}`)
|
|
371
|
+
));
|
|
312
372
|
});
|
|
313
373
|
|
|
314
374
|
child.on('close', (code) => {
|
|
315
375
|
if (settled) return;
|
|
316
376
|
settled = true;
|
|
317
377
|
clearTimeout(timer);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
378
|
+
settleAfterCleanup(() => {
|
|
379
|
+
if (code !== 0) {
|
|
380
|
+
reject(new HaikuError('E_INTERNAL', `haiku exited with code ${code}: ${truncate(stderr)}`));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Flip isFirstCall only after a successful spawn — a failed first call should
|
|
384
|
+
// still be treated as "preamble not yet delivered" so the next attempt re-sends
|
|
385
|
+
// the full prelude against a fresh --session-id (not --resume a non-existent one).
|
|
386
|
+
isFirstCall = false;
|
|
387
|
+
resolve(stdout);
|
|
388
|
+
});
|
|
327
389
|
});
|
|
328
|
-
|
|
329
|
-
child.stdin.end(wirePrompt, 'utf8');
|
|
330
390
|
});
|
|
331
391
|
};
|
|
332
392
|
|
|
@@ -96,17 +96,25 @@ export async function listMcpServers({ claudeBin = 'claude', projectRoot } = {})
|
|
|
96
96
|
// "<name>: <command> <args...> - <status>"
|
|
97
97
|
// We don't need full parsing — we just need the name and to know if it's an HTTP url
|
|
98
98
|
// or a stdio command. For stdio we will re-query `claude mcp get <name>` for proper args.
|
|
99
|
+
//
|
|
100
|
+
// Splitter: ": " (colon + SPACE), not ":" alone. Plugin-style server names contain
|
|
101
|
+
// internal colons — e.g. "plugin:everything-claude-code:context7" — and a bare
|
|
102
|
+
// `indexOf(':')` collapses six distinct ECC plugin MCPs into the literal string
|
|
103
|
+
// "plugin", causing `claude mcp get plugin` to fail and silently dropping the
|
|
104
|
+
// servers' tools from the catalog. Server names cannot contain a literal ": "
|
|
105
|
+
// (colon + space) because the CLI uses that exact pair as the line delimiter, so
|
|
106
|
+
// `indexOf(': ')` is safe even for names with spaces (e.g. "claude.ai Google Drive").
|
|
99
107
|
export function parseMcpListOutput(text) {
|
|
100
108
|
const out = [];
|
|
101
109
|
for (const line of text.split('\n')) {
|
|
102
110
|
const trimmed = line.trim();
|
|
103
111
|
if (trimmed.length === 0) continue;
|
|
104
112
|
if (trimmed.startsWith('Checking') || trimmed.startsWith('Note:')) continue;
|
|
105
|
-
// Format: "<name>: <rest>"
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
const name = trimmed.slice(0,
|
|
109
|
-
const rest = trimmed.slice(
|
|
113
|
+
// Format: "<name>: <rest>" — split on ": " (colon + space), see comment above.
|
|
114
|
+
const sepIdx = trimmed.indexOf(': ');
|
|
115
|
+
if (sepIdx <= 0) continue;
|
|
116
|
+
const name = trimmed.slice(0, sepIdx).trim();
|
|
117
|
+
const rest = trimmed.slice(sepIdx + 2).trim();
|
|
110
118
|
// skip the trailing " - ✓ Connected" / " - ✗ Failed"
|
|
111
119
|
const dashIdx = rest.lastIndexOf(' - ');
|
|
112
120
|
const beforeStatus = dashIdx > 0 ? rest.slice(0, dashIdx).trim() : rest;
|
|
@@ -115,8 +123,24 @@ export function parseMcpListOutput(text) {
|
|
|
115
123
|
const url = beforeStatus.replace(/\s*\(HTTP\)$/, '').trim();
|
|
116
124
|
out.push({ name, transport: isHttp ? 'http' : 'sse', url });
|
|
117
125
|
} else {
|
|
118
|
-
// stdio —
|
|
119
|
-
|
|
126
|
+
// stdio — extract command + args directly from the CLI line. Plugin-scoped
|
|
127
|
+
// servers (e.g. "plugin:everything-claude-code:context7") cannot be re-
|
|
128
|
+
// queried via `claude mcp get` (CLI returns "No MCP server found with
|
|
129
|
+
// name: ..." even though `mcp list` shows them), so the list line is the
|
|
130
|
+
// only authoritative source for these. Bare-name servers also work this
|
|
131
|
+
// way: tokenisation is naive (whitespace-only, matches splitArgs in
|
|
132
|
+
// getStdioConfig) and matches the existing constraint that command paths
|
|
133
|
+
// must not contain spaces.
|
|
134
|
+
const tokens = beforeStatus.split(/\s+/).filter((t) => t.length > 0);
|
|
135
|
+
// tokens.length === 0 means the CLI emitted "<name>: " followed by only
|
|
136
|
+
// status text (or nothing) — a malformed entry we cannot spawn anyway.
|
|
137
|
+
// We `continue` rather than throw so a single broken line cannot poison
|
|
138
|
+
// refresh for all the other healthy servers in the same `mcp list`. The
|
|
139
|
+
// fact that this entry was dropped is recoverable: the next refresh re-
|
|
140
|
+
// reads the CLI from scratch. This is the same "skip one server, log
|
|
141
|
+
// through listMcpToolsAll" treatment the rest of the path uses.
|
|
142
|
+
if (tokens.length === 0) continue;
|
|
143
|
+
out.push({ name, transport: 'stdio', command: tokens[0], args: tokens.slice(1) });
|
|
120
144
|
}
|
|
121
145
|
}
|
|
122
146
|
return out;
|