SLOPSHOPPER

probe

probe

newrowsguardpromptprocessagents
v0.0.1no licenseupdated 2026-10-08T-towa/fogcast/dev/probe
A shopper browsing a rack in a slop shop
README

Fogcast

Claude Code を何本ものターミナルで動かしているときに、すべての会話・道具の呼び出し・使用量を、ブラウザの 1 画面(http://127.0.0.1:4317)にまとめて映す Mod です。

詳しい使い方は取扱説明書(hub/guide.html。画面の歯車(設定)の「使い方」や http://127.0.0.1:4317/guide からも開けます)にまとめています。画面には説明の文をほとんど出さず、操作のしかたは取扱説明書にまとめています。

  • 全チャンネル:ターミナルごとの今の状態(作業中・承認待ち・入力待ち・終了)と、直近 60 分の番組表
  • 会話:メッセージアプリのように、あなたの依頼は右、Claude の返答は左の吹き出しに。道具の呼び出しはその間にまとまり、ターンごとに開け閉めできます。上に戻って読んでいるあいだは画面が動かず、右下の ↓ ボタン(届いたメッセージがあると「新着 N」)でいちばん下に戻れます
  • 履歴:そのチャンネルの会話を、ターンごとのカードで新しい順に。カードを押すとその会話の位置に飛び、畳まれていたターンは開きます。全チャンネルのときは、すべてのチャンネルの最近のターンが並びます
  • 操作:画面から送信(長い文は送信欄が広がり、その先はスクロール)、「@」でファイルを指定(中身がターミナルの @ と同じく Claude に渡ります)、「/」で組み込みコマンドやスキルを実行、停止、圧縮(そのターミナルで打ったのと同じ扱い。作業中なら終わってから始まります)。送った文は押した瞬間に会話に出て、始まるまで「順番待ち」などの状態が付きます。Claude Code の「次の入力の提案」も送信欄に出て、Tab か送信欄の右端の「提案を入れる」で入れられます
  • 前の会話・モデル・effort:/resume・/model・/effort は画面に選ぶ画面が開きます。/resume はそのフォルダの前の会話を一覧から選んで再開し(チャンネルは終わらずに続き、その会話の最後の 20 往復までが出ます)、モデルと effort はチャンネルの見出しからも切り替えられます。/context などの結果や、ターミナルで打ったコマンドの結果の 1 行も会話に出ます
  • 質問に答える:Claude が選択肢で質問してきたら(AskUserQuestion)、画面のボタンで答えられます。選択肢にない答えは欄に書けて、折り返して 5 行まで広がります(Shift+Enter で改行)。ターミナルの選ぶ画面と、先に答えた方が使われます
  • 片付け:終了したチャンネル(ターミナルを閉じた・報告が途絶えた)を、画面から消せます。セッションは残るので claude --resume で戻れます
  • 天気予報:5 時間枠と週の枠が、今のペースでいつ上限に届くか。週のカレンダーの日付を押すと、その日の記録(ターン数・トークン・料金、チャンネル別、よく使ったもの)
  • コミュ:ツール・スキル・エージェント・MCP の使用回数とランクをカードで。カードを開くと、右端の輪を回して切り替え、種類で絞り込み・並べ替えもできます。スキルとエージェントのカードには、それぞれの description も出ます(Claude Code の一覧から読むだけで、コンテキストは使いません)。プロジェクトの .claude にあるスキルとエージェントは読み込んだときに、~/.claude・プラグイン・組み込みのものは呼び出されたときに加わります
  • ステータス:使いはじめた日からの記録で、あなたの Lv と 5 つの能力(調査・構築・実行・段取り・拡張)が育ちます。EXP は量(ターンとトークン。1 日の上限なし)と習慣(作業した日・連続日数)と初めて使った道具から。FOGCAST の横の「Lv」から開き、数え方は上の「EXP と能力のしくみ」で見られます
  • スキル:使ったスキルと、読み込んだスキルをローカル(プロジェクトの .claude/skills)とグローバル(~/.claude/skills・プラグイン・組み込み)に分けてタブで
  • 承認:承認待ちになったチャンネルを知らせます。送信欄の右端の歯車(設定)でオンにして合言葉でつなぐと、画面から許可・拒否もできます
  • ステータスライン:ターミナルの下に Fogcast ch.1 · ctx 41% · 5h 44% → 予報 71% · 7d 68% · $1.23 のように出ます

必要なもの

  • Claude Code(Mod=function hooks が使える版。2.1.290 以降で確認しています)
  • Node.js 18 以上(受け皿のサーバーに使います)

入れ方

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>

Windows(WSL)で使うとき

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 で届きます)。

使い方

  1. いつも通り claude を起動します。最初のターミナルが受け皿(localhost:4317)を裏で起動し、以降のターミナルはそこにつながります。ステータスラインに Fogcast ch.1 … と出ればつながっています。
  2. どれかのターミナルで /fog を実行すると、ブラウザで画面が開きます(鍵つきの URL。2 回目からは http://127.0.0.1:4317 だけで開けます)。/fog の結果の下には鍵つきの URL も 1 行で出るので、開かなかったときはそれを押します(あなたの画面にだけ出て、Claude が読む会話には入りません)。
  3. チャンネルを増やすときは、VS Code のターミナルで新しいタブを開いて 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 での差分の表示や、選んでいる行の受け渡しが使えません)。

画面から承認する(初期設定はオフ)

  1. 画面の「設定」で「この画面から承認する」をオンにします。
  2. つながっているすべてのターミナルの入力欄のすぐ上に、黄色い枠で合言葉(1234-5678 の形、5 分間有効)が出ます。入力欄の下のステータスラインにも出ます。それを画面に入力すると、そのブラウザだけが承認できるようになります。心当たりのない合言葉は、枠の「取り消す」で無効にできます。
  3. 確認が必要なツール呼び出しがあると、そのチャンネルに「許可」「拒否」が出ます。ターミナルの入力欄の上には「Fogcast の画面で承認を待っています」と「ここで確認する」ボタンが出て、いつでもターミナルの確認ダイアログに戻せます。
  4. 画面で 2 分応答がないとき、画面を閉じているとき、組織の設定で確認が必須になっているツールは、最初からターミナルの確認ダイアログになります。

安全について

  • 受け皿は 127.0.0.1 だけで待ち受けます。ほかのホスト名やほかのサイトからの呼び出しは断ります(DNS リバインディング対策を含む)。
  • 画面と Mod は ~/.fogcast/token の鍵で受け皿とやりとりします(本人だけが読めるファイルです)。鍵は URL の # の後ろで画面に渡すので、サーバーのログや Referer には残りません。/fog の結果(モデルが読む 1 行)には鍵を含めません。鍵つきの URL は、その行の下に画面の表示としてだけ出し、会話の記録には残しません。
  • 画面からの承認は、オンにしたうえで、ターミナルにだけ出る合言葉を入れたブラウザしかできません。合言葉を 5 回間違えると無効になります。

コンテキストへの影響

Mod は会話や道具の結果を書き換えず、毎回のコンテキストにも何も足しません(claude plugin details の Always-on は ~0 tok)。増えるのは、コマンド一覧に /fog が 1 行加わることと、/fog を実行したときの 1 行の結果だけです。

環境変数

名前既定内容
FOGCAST_PORT4317受け皿のポート
FOGCAST_NODEnodeNode.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.tsMod 本体。会話・道具・使用量を受け皿へ送り、画面からの操作をターミナルで実行します
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 つのターミナルを流して画面を試す
Source 1 files
hooks/register.ts 39 lines
1// 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