probe

Claude Code を何本ものターミナルで動かしているときに、すべての会話・道具の呼び出し・使用量を、ブラウザの 1 画面(http://127.0.0.1:4317)にまとめて映す Mod です。
詳しい使い方は取扱説明書(hub/guide.html。画面の歯車(設定)の「使い方」や http://127.0.0.1:4317/guide からも開けます)にまとめています。画面には説明の文をほとんど出さず、操作のしかたは取扱説明書にまとめています。
/resume・/model・/effort は画面に選ぶ画面が開きます。/resume はそのフォルダの前の会話を一覧から選んで再開し(チャンネルは終わらずに続き、その会話の最後の 20 往復までが出ます)、モデルと effort はチャンネルの見出しからも切り替えられます。/context などの結果や、ターミナルで打ったコマンドの結果の 1 行も会話に出ますclaude --resume で戻れます.claude にあるスキルとエージェントは読み込んだときに、~/.claude・プラグイン・組み込みのものは呼び出されたときに加わります.claude/skills)とグローバル(~/.claude/skills・プラグイン・組み込み)に分けてタブでFogcast ch.1 · ctx 41% · 5h 44% → 予報 71% · 7d 68% · $1.23 のように出ますzip で受け取ったときは、先に解凍してホームフォルダに置きます(marketplace add はフォルダを指定します)。
unzip ~/Downloads/fogcast-0.5.6.zip -d ~ # ~/fogcast ができます
まず試す(そのセッションだけ):
claude --plugin-dir /path/to/fogcast
ずっと使う(このフォルダをマーケットプレイスとして追加):
claude plugin marketplace add /path/to/fogcast
claude plugin install fogcast@fogcast
入れたあとは、動いている claude を一度終了して起動し直します。~/fogcast は起動のたびに読み込まれるので、消したり移したりしないでください。
GitHub に置いた場合は、Claude Code のプロンプトで:
/plugin install fogcast --marketplace <owner>/<repo>
Claude Code を WSL の中で動かしているなら、すべて WSL のターミナルで行います。置き場所は WSL の中のホームフォルダ(/home/<WSLのユーザー名>/fogcast)で、Windows 側(C:\Users\…)には置きません。Node.js 18 以上も WSL の中に必要です。
unzip "/mnt/c/Users/<Windowsのユーザー名>/Downloads/fogcast-0.5.6.zip" -d ~
画面は Windows のブラウザで見ます。/fog は Windows の既定のブラウザを開きます(WSL2 は localhost を Windows に転送するので、http://127.0.0.1:4317 で届きます)。
claude を起動します。最初のターミナルが受け皿(localhost:4317)を裏で起動し、以降のターミナルはそこにつながります。ステータスラインに Fogcast ch.1 … と出ればつながっています。/fog を実行すると、ブラウザで画面が開きます(鍵つきの URL。2 回目からは http://127.0.0.1:4317 だけで開けます)。/fog の結果の下には鍵つきの URL も 1 行で出るので、開かなかったときはそれを押します(あなたの画面にだけ出て、Claude が読む会話には入りません)。claude を起動するだけです。同じリポジトリで並行して作業するなら、git worktree で作業フォルダを分けるとファイルの変更がぶつかりません。キー操作:0〜9 でチャンネル切り替え、/ でコマンドの一覧。カードの画面は ↑ ↓・ホイール・ドラッグで回し、Esc で戻ります。
VS Code のターミナルで起動した claude は VS Code とつながり、依頼を受けるたびに VS Code が(隠していても)ターミナルを表に出します。Claude Code の VS Code 連携の動きなので、Fogcast の側からは止められません。画面だけで使うときに気になるなら、CLAUDE_CODE_AUTO_CONNECT_IDE=false claude で起動します(そのあいだは VS Code での差分の表示や、選んでいる行の受け渡しが使えません)。
1234-5678 の形、5 分間有効)が出ます。入力欄の下のステータスラインにも出ます。それを画面に入力すると、そのブラウザだけが承認できるようになります。心当たりのない合言葉は、枠の「取り消す」で無効にできます。127.0.0.1 だけで待ち受けます。ほかのホスト名やほかのサイトからの呼び出しは断ります(DNS リバインディング対策を含む)。~/.fogcast/token の鍵で受け皿とやりとりします(本人だけが読めるファイルです)。鍵は URL の # の後ろで画面に渡すので、サーバーのログや Referer には残りません。/fog の結果(モデルが読む 1 行)には鍵を含めません。鍵つきの URL は、その行の下に画面の表示としてだけ出し、会話の記録には残しません。Mod は会話や道具の結果を書き換えず、毎回のコンテキストにも何も足しません(claude plugin details の Always-on は ~0 tok)。増えるのは、コマンド一覧に /fog が 1 行加わることと、/fog を実行したときの 1 行の結果だけです。
| 名前 | 既定 | 内容 |
|---|---|---|
FOGCAST_PORT | 4317 | 受け皿のポート |
FOGCAST_NODE | node | Node.js の場所(PATH にないとき) |
FOGCAST_HOME | ~/.fogcast | 鍵と記録の置き場所 |
FOGCAST_HEADLESS | なし | 1 にすると claude -p の実行も映します |
unzip -o ~/Downloads/fogcast-<版>.zip -d ~ # ~/fogcast を上書き
claude plugin update fogcast@fogcast
そのあと各ターミナルの claude を起動し直します。前の版の受け皿が動いていたら、新しい版のターミナルが止めて、新しい受け皿を起動します。
ターミナルも画面もつながっていない状態が 20 分続くと、自動で終了します。すぐ止めたいときは:
node /path/to/fogcast/hub/hub.mjs --stop
~/.fogcast/ に、鍵(token)、state.json(コミュの使用回数と最近の使用、使用枠の読み取り、日ごとの記録(ターン数・トークン・料金、チャンネル名ごとの内訳、使ったものの回数。35 日ぶん)、ステータス(EXP・能力・記録)、終わったセッションの再開 ID と最初の依頼の書き出し)、hub.log を置きます。会話の本文は受け皿のメモリにだけ置き、ファイルには書きません。Claude Code の会話のファイル(~/.claude/projects の中)は、/resume の一覧を開いたときと会話を再開したときにだけ、そのチャンネルのフォルダの分を読みます。
| ファイル | 役割 |
|---|---|
hooks/register.ts | Mod 本体。会話・道具・使用量を受け皿へ送り、画面からの操作をターミナルで実行します |
hooks/shape.ts | 道具の呼び出しや結果を 1 行にまとめる整形 |
hub/hub.mjs | 受け皿(localhost のサーバー)。Node.js だけで動きます |
hub/ui.html | 画面(ui-src/ から組み立てたもの) |
hub/guide.html | 取扱説明書(ui-src/guide.html から組み立てたもの。/guide で開けます) |
ui-src/ | 画面の部品と build.sh。demo.html は受け皿なしで動くデモです |
test/ | 受け皿のテスト、2 つのターミナルを流すシミュレーター |
claude plugin validate .
claude plugin test . # Mod のテスト
node --test test/hub.test.mjs # 受け皿のテスト
node test/simulate.mjs # 受け皿に 2 つのターミナルを流して画面を試すhooks/register.ts 39 lines1// Watches what the engine does around questions, suggestions and command listings, and writes it to a file.
2import type { Register } from 'claude-code'
3// eslint-disable-next-line @typescript-eslint/no-explicit-any
4type Any = any
5const LOG = '/tmp/claude-0/-home-claude/799e4518-7722-51e1-bd2f-c3fa3b975b03/scratchpad/term/t/probe.log'
6let MODE = ''
7async function log($: Any, ...a: unknown[]) {
8 const line = `${new Date().toISOString().slice(11, 23)} ${a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')}`
9 try { await $.process.run(['sh', '-c', 'printf "%s\\n" "$1" >> "$0"', LOG, line.slice(0, 3000)], { timeoutMs: 3000 }) } catch {}
10}
11let described = 0
12export const register: Register = (on) => {
13 on('session.start', async ($: Any, e: Any, next: Any) => { const r = await next(e); MODE = (await $.env.get('PROBE_MODE')) || ''; await log($, 'session.start', { interactive: e.isInteractive, MODE }); return r })
14 on('tool.call', async ($: Any, e: Any, next: Any) => {
15 if (e.tool !== 'AskUserQuestion') return next(e)
16 await log($, 'tool.call AskUserQuestion', { id: e.tool_use_id, keys: Object.keys(e), questions: e.questions })
17 const p = next(e)
18 p.then((r: Any) => log($, 'next resolved', r), (err: Any) => log($, 'next rejected', String(err?.message ?? err)))
19 if (MODE !== 'screen') return p
20 await $.clock.sleep(5000)
21 const q = e.questions[0]
22 const answers = { [q.question]: q.options[1].label }
23 await log($, 'answering from the probe', answers)
24 return { result: { questions: e.questions, answers } }
25 })
26 on('classic.PermissionRequest', async ($: Any, e: Any, next: Any) => { await log($, 'PermissionRequest', e.tool_name); const r = await next(e); await log($, 'PermissionRequest result', r); return r })
27 on('ui.render', { component: 'AskUserQuestion' }, async ($: Any, e: Any, next: Any) => { void log($, 'render AskUserQuestion', { n: e.props?.questions?.length }); return next(e) })
28 on('prompt.suggest', async ($: Any, e: Any, next: Any) => { const r = await next(e); await log($, 'prompt.suggest', { text: e.text, origin: e.origin, r }); return r })
29 on('command.describe', async ($: Any, e: Any, next: Any) => {
30 const r = await next(e)
31 described++
32 if (described <= 3 || e.argumentHint || r?.argumentHint || /check-rules|house|fix-issue/.test(e.command)) await log($, 'command.describe', described, { command: e.command, hint: e.argumentHint, rhint: r?.argumentHint, d: String(e.description).slice(0, 60), provider: e.provider })
33 return r
34 })
35 on('agent.offer', async ($: Any, e: Any, next: Any) => { const r = await next(e); await log($, 'agent.offer', { agent: e.agent, source: e.source, d: String(e.description).slice(0, 80), len: String(e.description).length, r }); return r })
36 on('turn.start', async ($: Any, e: Any, next: Any) => { await log($, 'turn.start', e.text); return next(e) })
37 on('turn.complete', async ($: Any, e: Any, next: Any) => { await log($, 'turn.complete', { aborted: e.isAborted, agent: e.agentId }); return next(e) })
38}
39