SLOPSHOPPER

fogcast

霧読み:すべてのターミナルの会話・道具・使用量を、localhost のブラウザ画面にまとめて映す

newbandspinnerrowsguardcommand
v0.5.6no licenseupdated 2026-10-09T-towa/fogcast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fogcast
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /fog ⎿ fogcast: 受け皿にまだ接続できていません。数秒待ってから、もう一度 /fog を実行してください。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ fogcast: Fogcast 未接続 · ctx 49% · 5h 31% · $0.42 · /fog で画面
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 2 files
hooks/register.ts 922 lines
1// Fogcast (霧読み): reports this terminal's conversation, tool calls and usage to the
2// localhost hub, and carries out what the Fogcast screen asks of this terminal.
3//
4// Nothing here adds to the model's context: the hooks watch and pass everything on as
5// it was. The one exception is /fog's one-line answer, which says where the screen is.
6import type { Register } from 'claude-code'
7import { linkFor, verb, summarize, outline, fileChange, todosOf, textOf, srcLabel, statusLine, older, isLocalSrc, midTurnText, clip, str, keepText, questionsOf, answersFor, commandRow, effortLevels, modelKey, settingOf, effortSet, mentionsOf, mentionCandidates, numbered, type Reading } from './shape'
8
9// eslint-disable-next-line @typescript-eslint/no-explicit-any
10type Any = any
11
12/** Keep in step with .claude-plugin/plugin.json and hub/hub.mjs (a test checks). */
13export const VERSION = '0.5.6'
14const DEFAULT_PORT = 4317
15/** Claude Code drops a toast asked to stay longer than this (0.1.0 lost its pairing notice that way). */
16const TOAST_MAX = 60000
17const SYNC_MS = 700
18const RELOADS = new Set(['reload-skills', 'reload-plugins', 'mcp', 'plugin', 'agents', 'skills', 'memory', 'add-dir'])
19const SKIP_CMDS = new Set(['compact', 'clear', 'exit', 'quit', 'fog'])
20
21function rid(): string {
22  try { const b = new Uint8Array(8); crypto.getRandomValues(b); return Array.from(b, x => x.toString(16).padStart(2, '0')).join('') }
23  catch { return (Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)).slice(0, 16) }
24}
25/** This terminal, for as long as the module is loaded: kept across /clear and resumes. */
26const CHAN = rid()
27
28/** A command run here: its row on the screen is told once, unless what it did is told instead (a turn, a resume, a model switch). */
29type CmdRun = { id: string; name: string; args: string; via?: 'screen'; text: string; t: number; told: boolean; settled: boolean; shown: boolean }
30
31const st = {
32  on: false,
33  port: DEFAULT_PORT, base: `http://127.0.0.1:${DEFAULT_PORT}`, dir: '', token: '',
34  ready: false, lastTry: 0, startedHubAt: 0, warned: new Set<string>(),
35  num: 0, approvals: false, forecast: null as Any,
36  measure: null as Any, limits: [] as Reading[], ctx: undefined as number | undefined, usd: undefined as number | undefined,
37  sid: '', cwd: '', model: '', branch: '', startedAt: 0,
38  busy: false, ticks: 0, lastLine: '', turnsDone: 0,
39  turnId: '', turnText: '', turnHasOutput: false,
40  midTyped: [] as string[],   // typed at the terminal while a turn ran, until it reaches the model
41  lastCmd: null as null | { name: string; args: string; t: number; screen?: boolean },
42  /** the last command run here, until its row is told: the line it prints joins it, and one that starts a turn is told by the turn */
43  cmdRun: null as null | CmdRun,
44  afterTurn: [] as Array<() => void>,
45  tools: new Map<string, { name: string; input: string; agent?: string; waiting?: boolean }>(),
46  ceil: new Set<string>(),
47  agents: new Map<string, string>(),
48  recentSkill: null as null | { name: string; t: number },
49  mode: '',
50  band: null as null | { id: string; tool: string; input: string; until: number },
51  /** the code a browser must type to approve from the screen; shown above the prompt and in the status line, never to the model */
52  pair: null as null | { code: string; until: number },
53  replacedAt: 0,
54  /** where each loaded skill and agent comes from, and what it costs each request: given to its コミュ card when it is first used */
55  srcOf: new Map<string, { src: string; dt?: number; desc?: string }>(),
56  /** the address with its key, drawn under /fog's row when no browser could be opened (the stored row, which the model reads, never has it) */
57  fogShow: '',
58  /** prompts the screen sent, until their turn starts: the hub's id rides along so the screen can swap its pending bubble for the turn */
59  submitted: [] as Array<{ text: string; cid: string; files?: Attached[] }>,
60  /** each command's argument hint (`[model]`), as the engine lists it for the typeahead */
61  hints: new Map<string, string>(),
62  /** what each agent type is for (its definition's description), as the engine offers it to the model */
63  agentDesc: new Map<string, string>(),
64  /** the prompt box's dim suggestion for the next prompt (Tab takes it), while it stands */
65  suggest: '',
66  /** questions Claude is asking (AskUserQuestion): an answer from the screen settles the one waiting here */
67  qWait: new Map<string, (sent: unknown) => void>(),
68  /** this conversation's file (the engine's transcript): its folder holds the project's other conversations, which /resume can return to */
69  tp: '',
70  /** the effort as it is set (/effort, or what the settings keep for the model); a request's own level until one is known */
71  effort: '',
72  /** the level the last request went with: what `auto` came to, for one */
73  effortUsed: '',
74  /** the model choices last told to the hub, with the one /config holds (the picker marks it) */
75  modelsSent: '',
76  /** the conversation a /resume in this terminal is leaving, until the one it returns to has started */
77  resumeFrom: '',
78  /** the command the conversation's last command row named: what its printed line belongs to */
79  appendCmd: '',
80  /** built-in commands that turned out to be skills (they start a turn) */
81  skillRan: new Set<string>(),
82  /** commands the typeahead leaves out (`isHidden`): the screen's list does too */
83  hiddenCmds: new Set<string>(),
84  /** when the screen last asked for a model: the switch that follows is told as the screen's */
85  modelAsked: 0,
86  /** where Claude Code keeps its conversations (CLAUDE_CONFIG_DIR, else ~/.claude): the resume list's fallback */
87  cfg: '',
88  /** a screen is open: what it asks for is picked up at every tick */
89  watched: false,
90}
91const outbox: Any[] = []
92const ring: Any[] = []     // what is replayed if the hub restarts
93
94function push(ev: Any) {
95  if (!st.on) return
96  ev.t = ev.t ?? Date.now()
97  outbox.push(ev)
98  if (outbox.length > 2000) outbox.splice(0, outbox.length - 2000)
99  if (ev.k !== 'measure' && ev.k !== 'mode' && ev.k !== 'suggest') { ring.push(ev); if (ring.length > 500) ring.splice(0, ring.length - 500) }
100}
101
102/* ---------------- the hub ---------------- */
103async function get($: Any, path: string): Promise<Any> {
104  try { const r = await $.http.fetch(st.base + path); return r.ok ? JSON.parse(r.text || 'null') : null } catch { return null }
105}
106async function post($: Any, path: string, body: unknown): Promise<Any> {
107  if (!st.token) return null
108  try {
109    const r = await $.http.fetch(st.base + path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-fogcast-token': st.token }, body: JSON.stringify(body) })
110    if (r.status === 401) { st.token = ''; st.ready = false; return null }
111    if (r.status >= 500) return null
112    return JSON.parse(r.text || 'null')
113  } catch { return null }
114}
115function warnOnce($: Any, key: string, text: string) {
116  if (st.warned.has(key)) return
117  st.warned.add(key)
118  try { $.ui.toast(text, { timeoutMs: 15000 }) } catch {}
119}
120async function paths($: Any) {
121  const port = Number(await $.env.get('FOGCAST_PORT')) || DEFAULT_PORT
122  st.port = port; st.base = `http://127.0.0.1:${port}`
123  const custom = await $.env.get('FOGCAST_HOME')
124  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
125  st.dir = custom || (home ? `${home}/.fogcast` : '')
126  st.cfg = (await $.env.get('CLAUDE_CONFIG_DIR')) || (home ? `${home}/.claude` : '')
127}
128async function readToken($: Any): Promise<string> {
129  if (!st.dir) return ''
130  try { return String(await $.fs.read(`${st.dir}/token`)).trim() } catch { return '' }
131}
132/** The hub this mod talks to: same port and home folder, whatever the host's environment says. */
133function hubEnv(): Record<string, string> {
134  return st.dir ? { FOGCAST_PORT: String(st.port), FOGCAST_HOME: st.dir } : { FOGCAST_PORT: String(st.port) }
135}
136async function startHub($: Any) {
137  st.startedHubAt = Date.now()
138  const node = (await $.env.get('FOGCAST_NODE')) || 'node'
139  try {
140    const r = await $.process.run([node, `${$.plugin.root}/hub/hub.mjs`, '--daemon'], { timeoutMs: 15000, env: hubEnv() })
141    if (r.exitCode !== 0) warnOnce($, 'start', `Fogcast: 受け皿を起動できませんでした(${clip(r.stderr || `終了コード ${r.exitCode}`, 120)})`)
142  } catch {
143    warnOnce($, 'node', 'Fogcast: Node.js が見つからないため、受け皿(localhost の画面)を起動できませんでした。Node.js 18 以上を入れるか、環境変数 FOGCAST_NODE に node のパスを指定してください。')
144  }
145}
146/** After an update, a hub of an older version may still be running: it is stopped once, and this mod starts its own. */
147async function stopHub($: Any) {
148  const node = (await $.env.get('FOGCAST_NODE')) || 'node'
149  try { await $.process.run([node, `${$.plugin.root}/hub/hub.mjs`, '--stop'], { timeoutMs: 10000, env: hubEnv() }) } catch {}
150}
151async function branch($: Any): Promise<string> {
152  try { const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 4000 }); return r.exitCode === 0 ? r.stdout.trim() : '' } catch { return '' }
153}
154/**
155 * Skills only the person can run (`disable-model-invocation: true`): Claude is never told of them, so they
156 * cost nothing each turn and /context leaves them out. The command list has them, and the project's own
157 * folders say which are its; the rest are everywhere's (~/.claude).
158 */
159async function manualSkills($: Any, shown: Set<string>, list: Any[]): Promise<Any[]> {
160  try {
161    const own = new Set<string>()
162    try { for (const e of await $.fs.list('.claude/skills')) if (e.kind !== 'file') own.add(e.name) } catch {}
163    try { for (const e of await $.fs.list('.claude/commands')) if (e.kind === 'file' && e.name.endsWith('.md')) own.add(e.name.slice(0, -3)) } catch {}
164    return list.filter((c: Any) => c.source === 'user' && !shown.has(c.name))
165      .map((c: Any) => ({ n: c.name, src: own.has(c.name) ? '.claude' : '~/.claude', dt: 0, manual: true }))
166  } catch { return [] }
167}
168/**
169 * Each skill's description (its SKILL.md `description`), from the command list: read here, never sent to the
170 * model by Fogcast. A name listed twice (a skill of the person's and a built-in) takes the one of its own source.
171 */
172function descOf(list: Any[]): (name: string, builtin?: boolean) => string {
173  const own = new Map<string, string>(), core = new Map<string, string>()
174  for (const c of list) if (c && typeof c.name === 'string' && typeof c.description === 'string' && c.description.trim()) (c.source === 'builtin' ? core : own).set(c.name, c.description)
175  return (name, builtin) => keepText((builtin ? core.get(name) ?? own.get(name) : own.get(name) ?? core.get(name)) ?? '', 1500)
176}
177async function commandList($: Any): Promise<Any[]> { try { const l = await $.command.list(); return Array.isArray(l) ? l : [] } catch { return [] } }
178/** What the session loaded at start, as /context counts it (estimated locally: no request is sent). */
179async function loaded($: Any, list: Any[]): Promise<Any> {
180  try {
181    const u = await $.session.usage({ breakdown: 'summary' })
182    st.startedAt = u.startedAt
183    const b = u.context.breakdown
184    if (!b) return {}
185    const desc = descOf(list)
186    const listed = (b.skills?.skillFrontmatter ?? []).map((s: Any) => ({ n: s.name, src: srcLabel(s.source, s.pluginName), dt: s.tokens, desc: desc(s.name, /built-?in|bundled/.test(String(s.source))) }))
187    const skills = [...listed, ...(await manualSkills($, new Set(listed.map((s: Any) => s.n)), list)).map((s: Any) => ({ ...s, desc: desc(s.n) }))]
188    const servers = new Map<string, number>()
189    for (const t of b.mcpTools ?? []) servers.set(t.serverName, (servers.get(t.serverName) ?? 0) + (t.isLoaded ? t.tokens : 0))
190    const agents = (b.agents ?? []).map((a: Any) => ({ n: a.agentType, src: srcLabel(a.source), dt: a.tokens, desc: st.agentDesc.get(a.agentType) ?? '' }))
191    const memory = (b.memoryFiles ?? []).map((m: Any) => ({ path: m.path, type: m.type, dt: m.tokens }))
192    st.srcOf = new Map([...skills.map((s: Any) => [`skill:${s.n}`, { src: s.src, dt: s.dt, desc: s.desc }]), ...agents.map((a: Any) => [`agent:${a.n}`, { src: a.src, dt: a.dt, desc: a.desc }])] as Any)
193    return {
194      loaded: {
195        md: memory.length > 0, memory, skills: skills.map(({ desc: _d, ...s }: Any) => s), agents: agents.map(({ desc: _d, ...a }: Any) => a), mcp: [...servers.keys()], mods: ['fogcast'],
196        totalSkills: b.skills?.totalSkills ?? skills.length, includedSkills: b.skills?.includedSkills ?? skills.length,
197        window: b.rawMaxTokens, autoCompactAt: b.autoCompactThreshold ?? null,
198      },
199      breakdown: (b.categories ?? []).filter((c: Any) => c.kind === 'used' && c.tokens > 0).map((c: Any) => ({ n: c.name, v: c.tokens })),
200      // コミュ: a project's own skills and agents join when loaded; everywhere's join when first called
201      links: [
202        ...skills.filter((s: Any) => isLocalSrc(s.src)).map((s: Any) => ({ id: `skill:${s.n}`, kind: 'skill', label: s.n, src: s.src, dt: s.dt, desc: s.desc || undefined })),
203        ...agents.filter((a: Any) => isLocalSrc(a.src)).map((a: Any) => ({ id: `agent:${a.n}`, kind: 'agent', label: a.n, src: a.src, dt: a.dt, desc: a.desc || undefined })),
204        ...[...servers].map(([n, dt]) => ({ id: `mcp:${n}`, kind: 'mcp', label: n, src: 'MCP', dt })),
205      ],
206    }
207  } catch { return {} }
208}
209/**
210 * The commands for the screen's "/" list: one line each, with the hint the terminal draws after the name.
211 * A skill is marked: run from the screen it starts a turn, even one Claude Code lists as built-in.
212 */
213function commands(list: Any[]): Any[] {
214  return list.map((c: Any) => ({
215    name: c.name, description: clip(c.description || '', 160), source: c.source, plugin: c.plugin, hint: st.hints.get(c.name) || undefined,
216    ...(st.srcOf.has(`skill:${c.name}`) || st.skillRan.has(c.name) ? { skill: true } : {}),
217    ...(st.hiddenCmds.has(c.name) ? { hidden: true } : {}),
218  }))
219}
220/** The models the /config Model row offers, and the one chosen there: what the screen's model picker lists. */
221async function modelChoices($: Any): Promise<Any> {
222  try {
223    const row = ((await $.config.list()) || []).find((x: Any) => x && x.key === 'model')
224    if (!row) return null
225    const options = (Array.isArray(row.options) ? row.options : []).filter((o: unknown) => typeof o === 'string').slice(0, 40)
226    return { options, value: settingOf(row.value, options) }
227  } catch { return null }
228}
229/** The model setting as /config holds it now, told to the hub when it changed: after the screen's pick, /model, /config, a switch. */
230async function syncModels($: Any) {
231  if (!st.on) return
232  const m = await modelChoices($); if (!m) return
233  const k = JSON.stringify(m); if (k === st.modelsSent) return
234  st.modelsSent = k; push({ k: 'models', ...m })
235}
236/** The effort the settings keep for a model (what `/effort` saved as its default): shown until a request says otherwise. */
237async function savedEffort($: Any, model: string): Promise<string> {
238  try { const v = (await $.settings.read())?.modelSettings?.[modelKey(model)]?.effortLevel; return typeof v === 'string' ? v : '' } catch { return '' }
239}
240/** The effort as now set, told to the hub when it changed. */
241function effortIs(level: string) { if (level && level !== st.effort) { st.effort = level; push({ k: 'info', effort: level }) } }
242const dirOf = (p: string) => p.replace(/[\\/][^\\/]*$/, '')
243async function hello($: Any) {
244  const list = await commandList($)
245  const [sid, cwd, model, version, info, br, models] = await Promise.all([
246    $.session.id().catch(() => ''), $.session.cwd().catch(() => st.cwd), $.session.model().catch(() => ''),
247    $.session.version().catch(() => null), loaded($, list), branch($), modelChoices($),
248  ])
249  const cmds = commands(list)
250  st.sid = sid; st.cwd = cwd; st.model = model; st.branch = br; st.modelsSent = models ? JSON.stringify(models) : ''
251  if (!st.effort) st.effort = await savedEffort($, model)
252  return {
253    chan: CHAN, mod: VERSION, sid, cwd, branch: br, model, version: version?.version ?? '', startedAt: st.startedAt || Date.now(), commands: cmds, ...info,
254    tp: st.tp, tdir: st.tp ? dirOf(st.tp) : '', cfg: st.cfg, effort: st.effort, effortUsed: st.effortUsed, models, efforts: effortLevels(st.hints.get('effort')), replay: ring,
255  }
256}
257async function connect($: Any): Promise<boolean> {
258  st.lastTry = Date.now()
259  const h = await get($, '/api/health')
260  if (!h) { if (Date.now() - st.startedHubAt > 20000) await startHub($); return false }
261  if (h.app !== 'fogcast') { warnOnce($, 'port', `Fogcast: ポート ${st.port} は別のプログラムが使っています。環境変数 FOGCAST_PORT で別の番号にできます。`); return false }
262  if (older(h.version, VERSION) && Date.now() - st.replacedAt > 60000) {
263    // an update left the older hub running: stop it, give it a moment to let go of the port, start this version's
264    st.replacedAt = Date.now()
265    await stopHub($)
266    await $.clock.sleep(800)
267    await startHub($)
268    return false
269  }
270  if (!st.token) st.token = await readToken($)
271  if (!st.token) return false
272  const r = await post($, '/api/mod/hello', await hello($))
273  if (!r || !r.ok) return false
274  st.ready = true; st.num = r.num; st.approvals = !!r.approvals?.on; st.forecast = r.forecast ?? null
275  outbox.length = 0                     // the replay carried them
276  if (st.measure) push({ ...st.measure, t: Date.now() })
277  applyPair($, r.pair)
278  line($)
279  return true
280}
281/** The hub hands every terminal the code while a browser is being paired, and null once it is used, taken back or out of time. */
282function applyPair($: Any, p: Any) {
283  const next = p && typeof p.code === 'string' && Number(p.exp) > Date.now() ? { code: p.code, until: Number(p.exp) } : null
284  if ((next?.code ?? null) === (st.pair?.code ?? null)) { if (next && st.pair) st.pair.until = next.until; return }
285  st.pair = next
286  // the notice only points at the band: a code in a notice could outlive the code itself
287  if (next) { try { $.ui.toast('Fogcast: ブラウザ承認の合言葉を、入力欄のすぐ上に出しました', { timeoutMs: 12000 }) } catch {} }
288  try { $.ui.invalidate('ui.render') } catch {}
289  line($)
290}
291function pairLeft(): string {
292  const s = st.pair ? Math.max(0, Math.round((st.pair.until - Date.now()) / 1000)) : 0
293  return s > 60 ? `あと約 ${Math.ceil(s / 60)} 分` : `あと ${s} 秒`
294}
295async function reload($: Any) {
296  const list = await commandList($)
297  push({ k: 'loaded', ...await loaded($, list), commands: commands(list) })
298}
299function line($: Any) {
300  if (!st.on) return
301  const pair = st.pair && st.pair.until > Date.now() ? st.pair.code : undefined
302  const text = statusLine({ num: st.num, ready: st.ready, ctx: st.ctx, limits: st.limits, usd: st.usd, forecast: st.forecast, pair })
303  if (text === st.lastLine) return
304  st.lastLine = text
305  try { $.ui.status(text) } catch {}
306}
307async function tick($: Any) {
308  if (st.busy) return
309  st.busy = true
310  try {
311    st.ticks++
312    if (st.pair && st.pair.until <= Date.now()) applyPair($, null)        // out of time, whether or not the hub said so yet
313    if (!st.ready) { if (Date.now() - st.lastTry > 2000) { await connect($); line($) } return }
314    if (st.ticks % 8 === 0) {
315      const [sid, model] = await Promise.all([$.session.id().catch(() => st.sid), $.session.model().catch(() => st.model)])
316      if (sid !== st.sid || model !== st.model) { st.sid = sid; st.model = model; push({ k: 'info', sid, model }) }
317      if (st.ticks % 24 === 0) await syncModels($)
318    }
319    if ((st.band || st.pair) && st.ticks % 2 === 0) $.ui.invalidate('ui.render')    // the band's countdown
320    if (!outbox.length && !st.turnId && !st.watched && st.ticks % 3 !== 0) return       // quiet: every ~2 s is enough, unless a screen is open
321    const events = outbox.splice(0, 400)
322    const r = await post($, '/api/mod/sync', { chan: CHAN, events })
323    if (!r) { outbox.unshift(...events); st.ready = false; line($); return }
324    if (r.ok === false) { st.ready = false; return }   // the hub restarted: say hello again, with the replay
325    st.num = r.num ?? st.num; st.approvals = !!r.approvals?.on; st.forecast = r.forecast ?? null; st.watched = !!r.watch
326    applyPair($, r.pair)
327    for (const c of r.commands ?? []) perform($, c)
328    line($)
329  } catch {} finally { st.busy = false }
330}
331
332/* ---------------- what the screen asks of this terminal ---------------- */
333function perform($: Any, c: Any) {
334  const fail = (err: unknown) => push({ k: 'did', cid: c.id, what: c.type, ok: false, error: clip(String((err as Any)?.message ?? err), 200) })
335  try {
336    if (c.type === 'prompt') {
337      const text = String(c.text), entry: { text: string; cid: string; files?: Attached[] } = { text, cid: String(c.id) }
338      st.submitted.push(entry); if (st.submitted.length > 8) st.submitted.shift()
339      if (!mentionsOf(text).length) void $.prompt.submit({ text, asUser: true }).catch(fail)        // starts once the session is idle
340      // with files named by @: while a turn runs, the files and the prompt wait for it together, so the row lands right before the prompt
341      else if (st.turnId) st.afterTurn.push(() => { void sendPrompt($, text, entry).catch(fail) })
342      else void sendPrompt($, text, entry).catch(fail)
343    } else if (c.type === 'answer') {
344      st.qWait.get(String(c.qid))?.(c.answers)                                          // a question still open here takes it
345    } else if (c.type === 'command') {
346      // like a command typed while Claude works, it waits for the turn to end (the screen says it is booked)
347      const go = () => { void runCommand($, String(c.name), String(c.args || '')).catch(fail) }
348      if (st.turnId) st.afterTurn.push(go); else go()
349    } else if (c.type === 'stop') {
350      if (st.turnId) void $.turn.abort({ turnId: st.turnId }).catch(fail)
351    } else if (c.type === 'compact') {
352      // this module's own compaction runs through every hook but its own (session.compact): it is told here.
353      // A hook that vetoed it says why in the terminal; the screen hears it did not happen
354      const go = () => {
355        void $.session.compact(c.instructions ? { instructions: String(c.instructions) } : undefined).then((r: Any) => {
356          if (r?.skip) push({ k: 'did', cid: c.id, what: 'compact', ok: false, error: clip(String(r.skip), 200) })
357          else push({ k: 'compact', trigger: 'manual', via: 'screen' })
358        }).catch(fail)
359      }
360      if (st.turnId) st.afterTurn.push(go); else go()
361    } else if (c.type === 'toast') {
362      $.ui.toast(String(c.text || ''), { timeoutMs: Math.min(TOAST_MAX, Math.max(1, Math.round(Number(c.timeoutMs) || 15000))) })
363    } else if (c.type === 'resume') {
364      // a conversation of this project, by its id: the terminal switches to it without its own list, once the turn is over
365      const go = () => { void runCommand($, 'resume', String(c.sid)).catch(fail) }
366      if (st.turnId) st.afterTurn.push(go); else go()
367    } else if (c.type === 'model') {
368      // through the /config Model row: /model would stop at the terminal's "switch model?" question mid-conversation.
369      // Like /model, it waits for a running turn to end
370      // the switch it brings is told as the screen's (PostModelSwitch); a pick of the model already in use brings none,
371      // so the mark is let go a moment after the write, and the setting itself is read back from /config
372      const go = () => {
373        st.modelAsked = Date.now()
374        void $.config.set({ key: 'model', value: String(c.value) }).then((r: Any) => { if (r?.deny) fail(r.deny) }).catch(fail)
375          .finally(() => { $.clock.after(1500, () => { st.modelAsked = 0 }); void syncModels($) })
376      }
377      if (st.turnId) st.afterTurn.push(go); else go()
378    } else if (c.type === 'effort') {
379      const go = () => { void runCommand($, 'effort', String(c.value)).catch(fail) }
380      if (st.turnId) st.afterTurn.push(go); else go()
381    }
382  } catch (err) { fail(err) }
383}
384
385/* ---------------- commands: the row each run gets on the screen ---------------- */
386// This module's own `$.command.run` does not pass through its command.run hook (no hook runs under its own call),
387// so a run from the screen is followed here; one typed in the terminal, in the hook. Both end in cmdDone.
388async function runCommand($: Any, name: string, args: string) {
389  st.lastCmd = { name, args, t: Date.now(), screen: true }
390  const run = cmdBegin(name, args, true)
391  const r = await $.command.run({ command: name, args })
392  if (typeof r?.text === 'string' && !st.turnId && st.lastCmd?.name === name) st.lastCmd = null
393  cmdDone($, run, r)
394  return r
395}
396function cmdBegin(name: string, args: string, screen: boolean): CmdRun {
397  const run: CmdRun = { id: rid(), name, args, via: screen ? 'screen' : undefined, text: '', t: Date.now(), told: false, settled: false, shown: false }
398  st.cmdRun = run
399  return run
400}
401/** What the command answered: a moment later its row is told, with the line it printed if that came by then. */
402function cmdDone($: Any, run: CmdRun, r: Any) {
403  // most commands hand back no text: their printed line comes as a row of the conversation (session.append).
404  // /context's grid is drawn for the terminal; the table it leaves for the model reads better here
405  const text = typeof r?.text === 'string' ? r.text : run.name === 'context' && Array.isArray(r?.context) ? r.context.join('\n\n') : ''
406  if (text.trim() && !run.text) run.text = text
407  if (run.name === 'effort') effortIs(effortSet(text))
408  if (run.name === 'model' || run.name === 'config') $.clock.after(400, () => { void syncModels($) })
409  if (RELOADS.has(run.name)) void reload($)
410  $.clock.after(800, () => {
411    run.settled = true
412    // it started no turn: the next prompt is the person's own, not this command's (a prompt typed right after /effort
413    // would otherwise be told as "/effort auto")
414    if (!run.shown && !st.turnId && st.lastCmd && st.lastCmd.name === run.name && st.lastCmd.args === run.args) st.lastCmd = null
415    cmdTell(run)
416  })
417}
418function cmdTell(run: CmdRun) {
419  if (run.told || run.shown || SKIP_CMDS.has(run.name)) return
420  // a resume that went through is told by the conversation it brings back (resumed); one that did not, by the line it printed
421  if (run.name === 'resume' && !run.text.trim()) return
422  run.told = true
423  push({ k: 'cmd', name: run.name, args: run.args, text: keepText(run.text, 5000), via: run.via, run: run.id })
424}
425
426/* ---------------- files named with @ in a prompt from the screen ---------------- */
427// The terminal attaches what a typed prompt names with @; a plugin's prompt gets no such expansion. So the files go in
428// here, as a row the model reads (the person does not see it) right before the prompt, read as the Read tool shows
429// them. Only what lies inside this terminal's folder is read; anything else stays a name, for Claude to read or not.
430type Attached = { p: string; kind?: 'file' | 'dir'; lines?: number; shown?: number; err?: string }
431const PER_FILE = 100_000, ALL_FILES = 200_000, MAX_LINES = 2000, MAX_BYTES = 1 << 20
432const NOT_TEXT = /\.(png|jpe?g|gif|webp|bmp|ico|pdf|zip|gz|tgz|7z|jar|exe|dll|so|dylib|woff2?|ttf|otf|mp[34]|mov|wav)$/i
433async function attachments($: Any, text: string): Promise<{ row: string; items: Attached[] }> {
434  const names = mentionsOf(text)
435  const items: Attached[] = [], parts: string[] = []
436  if (!names.length) return { row: '', items }
437  // every path is read from this terminal's folder (what the terminal's @ resolves against), and must land inside it
438  const base = String(st.cwd || '').replace(/[\\/]+$/, '')
439  const at = (p: string) => `${base}/${p}`
440  const root = (await $.fs.stat(base || '.', { resolve: true }).catch(() => null))?.realPath || base
441  const inside = (real: string) => !!real && (real === root || real.startsWith(root.endsWith('/') ? root : root + '/'))
442  let budget = ALL_FILES
443  for (const name of names) {
444    if (/^(~|\/|[A-Za-z]:[\\/]|\\\\)/.test(name)) { items.push({ p: name, err: 'outside' }); continue }
445    let hit: { p: string; s: Any } | null = null
446    for (const p of mentionCandidates(name)) {
447      const s = await $.fs.stat(at(p), { resolve: true }).catch(() => null)
448      if (s && s.realPath) { hit = { p, s }; break }
449    }
450    if (!hit) { items.push({ p: name, err: 'missing' }); continue }
451    const { p, s } = hit
452    if (!inside(s.realPath)) { items.push({ p, err: 'outside' }); continue }
453    if (s.kind !== 'file' && s.kind !== 'dir') { items.push({ p, err: 'missing' }); continue }
454    if (s.kind === 'dir') {
455      const list = ((await $.fs.list(at(p)).catch(() => [])) as Any[]).filter(x => x && x.name)
456      const names2 = list.map(x => x.kind === 'dir' ? `${x.name}/` : x.name).sort((a: string, b: string) => Number(b.endsWith('/')) - Number(a.endsWith('/')) || a.localeCompare(b))
457      const body = names2.slice(0, 300).join('\n') + (names2.length > 300 ? `\n… (${names2.length - 300} more)` : '')
458      if (body.length > budget) { items.push({ p, kind: 'dir', err: 'budget' }); continue }
459      budget -= body.length
460      parts.push(`<directory path="${p.replace(/\/?$/, '/')}">\n${body}\n</directory>`)
461      items.push({ p: p.replace(/\/?$/, '/'), kind: 'dir', lines: names2.length })
462      continue
463    }
464    if (NOT_TEXT.test(p)) { items.push({ p, kind: 'file', err: 'binary' }); continue }
465    if (Number(s.size) > MAX_BYTES) { items.push({ p, kind: 'file', err: 'large' }); continue }
466    const raw = await $.fs.read(at(p)).catch(() => null)
467    if (typeof raw !== 'string') { items.push({ p, kind: 'file', err: 'missing' }); continue }
468    if (raw.includes('\u0000')) { items.push({ p, kind: 'file', err: 'binary' }); continue }
469    if (budget < 2000) { items.push({ p, kind: 'file', err: 'budget' }); continue }
470    const n = numbered(raw, MAX_LINES, Math.min(PER_FILE, budget))
471    budget -= n.body.length
472    const range = n.shown < n.lines ? `1-${n.shown} of ${n.lines}` : String(n.lines)
473    parts.push(`<file path="${p}" lines="${range}">\n${n.body}\n</file>${n.shown < n.lines ? `\n(The rest of ${p} is not shown here; read it with the Read tool when needed.)` : ''}`)
474    items.push({ p, kind: 'file', lines: n.lines, shown: n.shown })
475  }
476  const row = parts.length
477    ? `The user named these with @ in the message that follows, sent from the Fogcast screen (where Claude Code does not attach @ mentions itself). Here is what they held when it was sent, as the terminal's @ would have attached them:\n\n${parts.join('\n\n')}`
478    : ''
479  return { row, items }
480}
481/** A prompt from the screen: the files it names go in first, then the prompt, as the person's own words. */
482async function sendPrompt($: Any, text: string, entry: { files?: Attached[] }) {
483  const a = await attachments($, text)
484  if (a.items.length) entry.files = a.items
485  if (a.row) {
486    const r = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: a.row }] } }).catch(() => null)
487    if (!r || r.deny) entry.files = a.items.map(x => (x.err ? x : { p: x.p, kind: x.kind, err: 'refused' }))
488  }
489  await $.prompt.submit({ text, asUser: true })
490}
491
492/* ---------------- approving from the screen (only when it was turned on and paired there) ---------------- */
493async function waitDecision($: Any, id: string, signal?: AbortSignal): Promise<string> {
494  const until = Date.now() + 125000
495  while (Date.now() < until) {
496    if (signal?.aborted) { void post($, '/api/mod/ask/answer', { id, decision: 'cancel' }); return 'cancel' }
497    const t0 = Date.now()
498    const r = await post($, '/api/mod/ask/wait', { id })     // the hub holds this up to 25 s
499    if (!r) return 'terminal'
500    if (r.decision) return r.decision
501    if (Date.now() - t0 < 1000) await $.clock.sleep(1000)     // a hub that answers at once is not holding: do not spin
502  }
503  return 'terminal'
504}
505
506/* ---------------- a question Claude asks (AskUserQuestion), answerable from the screen ---------------- */
507/**
508 * The terminal's own dialog opens as usual (next). The screen gets the question too; whichever answers first
509 * is the answer. One from the screen is handed to Claude in the tool's own terms, and returning it while the
510 * dialog is still open closes the dialog.
511 */
512async function question($: Any, e: Any, next: Any, id: string, args: Any): Promise<Any> {
513  const qs = questionsOf(args)
514  if (!st.on || !qs.length) return next(e)
515  push({ k: 'question', id, qs })
516  const inTerminal = next(e)
517  let settle: (v: Any) => void = () => {}
518  const fromScreen = new Promise<Any>(res => { settle = res })
519  st.qWait.set(id, sent => { const answers = answersFor(args, sent); if (answers) settle(answers) })
520  try {
521    const first = await Promise.race([inTerminal.then((r: Any) => ({ r })), fromScreen.then(answers => ({ answers }))])
522    if ('answers' in first) {
523      inTerminal.catch(() => {})                  // the dialog, closed by this answer, settles as refused: not Claude's answer
524      push({ k: 'questionEnd', id, by: 'screen', answers: first.answers })
525      return { result: { questions: e.questions, answers: first.answers } }
526    }
527    const a = first.r?.result?.answers
528    push({ k: 'questionEnd', id, by: 'terminal', answers: a && typeof a === 'object' && !first.r?.isError ? a : undefined, err: !!(first.r?.isError || first.r?.deny) })
529    return first.r
530  } finally { st.qWait.delete(id) }
531}
532
533/* ---------------- /fog ---------------- */
534/** Under WSL the browser is Windows': this terminal opens it through interop itself. The hub may have been
535 *  started from a terminal that has since closed, and Windows programs cannot be run from a session that is gone. */
536async function openFromWsl($: Any, url: string): Promise<boolean> {
537  if (!((await $.env.get('WSL_DISTRO_NAME')) || (await $.env.get('WSL_INTEROP')))) return false
538  for (const argv of [['rundll32.exe', 'url.dll,FileProtocolHandler', url], ['/mnt/c/Windows/System32/rundll32.exe', 'url.dll,FileProtocolHandler', url], ['wslview', url]]) {
539    try { if ((await $.process.run(argv, { timeoutMs: 8000 })).exitCode === 0) return true } catch {}
540  }
541  return false
542}
543async function fog($: Any) {
544  if (!st.ready) await connect($)
545  if (!st.ready) return { text: '受け皿にまだ接続できていません。数秒待ってから、もう一度 /fog を実行してください。' }
546  const url = `${st.base}/#k=${st.token}`
547  const opened = (await openFromWsl($, url)) || !!(await post($, '/api/mod/open', {}))?.opened
548  // the address with its key goes to the person only: drawn under this row (never stored) and pointed at by a toast
549  st.fogShow = url
550  try { $.ui.toast(opened ? 'Fogcast: 画面をブラウザで開きました。開かないときは /fog の下の URL から' : 'Fogcast: ブラウザを開けませんでした。/fog の下の URL を開いてください', { timeoutMs: TOAST_MAX }) } catch {}
551  // the one link to press is the keyed one drawn below, on a line of its own (a terminal takes punctuation
552  // that touches a URL as part of it); the line the model reads has none
553  return { text: opened ? 'Fogcast の画面をブラウザで開きました。開いていなければ、この下の URL を開いてください。' : 'ブラウザを開けませんでした。この下の URL を開いてください。' }
554}
555
556export const register: Register = (on) => {
557  on('session.start', async ($: Any, e: Any, next: Any) => {
558    const r = await next(e)
559    try {
560      st.on = e.isInteractive === true || (await $.env.get('FOGCAST_HEADLESS')) === '1'
561      if (!st.on) return r
562      st.cwd = e.cwd
563      await paths($)
564      await $.command.register({ name: 'fog', description: 'Fogcast の画面(全ターミナルの会話と使用量)をブラウザで開く' }).catch(() => {})
565      $.clock.every(SYNC_MS, () => tick($))
566      line($)
567    } catch {}
568    return r
569  })
570
571  on('session.measure', async ($: Any, e: Any, next: Any) => {
572    try {
573      st.ctx = e.context.percent; st.limits = e.rateLimits ?? []; st.usd = e.cost?.usd
574      st.measure = {
575        k: 'measure', ctx: { tokens: e.context.tokens ?? 0, window: e.context.window, percent: e.context.percent ?? 0 },
576        limits: st.limits.map((w: Reading) => ({ kind: w.kind, pc: w.percentUsed, resetsAt: w.resetsAt })), usd: e.cost?.usd,
577      }
578      push({ ...st.measure })
579      line($)
580    } catch {}
581    return next(e)
582  })
583
584  // typed over a running turn: Claude Code hands it to the model inside that turn at its next step (with no step left, it starts the next turn)
585  on('prompt.submit', async ($: Any, e: Any, next: Any) => {
586    const r = await next(e)
587    try {
588      const who = e.origin?.kind
589      if (st.on && e.turnId && (who === 'composer' || who === 'bridge') && typeof e.text === 'string' && e.text.trim()) {
590        st.midTyped.push(e.text.trim()); if (st.midTyped.length > 5) st.midTyped.shift()
591      }
592    } catch {}
593    return r
594  })
595
596  on('turn.start', async ($: Any, e: Any, next: Any) => {
597    try {
598      st.turnId = e.turnId; st.turnText = e.text || ''; st.turnHasOutput = false
599      { const i = st.midTyped.indexOf(String(e.text || '').trim()); if (i >= 0) st.midTyped.splice(i, 1) }   // typed too late for the last one: a turn of its own
600      if (st.suggest) { st.suggest = ''; push({ k: 'suggest', text: '' }) }
601      const lc = st.lastCmd && Date.now() - st.lastCmd.t < 4000 ? st.lastCmd : null
602      // a command that starts a turn (a skill, /goal) is told by that turn, not by a row of its own
603      if (lc && st.cmdRun && st.cmdRun.name === lc.name && !st.cmdRun.told) st.cmdRun.shown = true
604      const text = lc ? `/${lc.name}${lc.args ? ' ' + lc.args : ''}` : e.text
605      const i = st.submitted.findIndex(x => x.text === e.text)
606      const via = i >= 0 || lc?.screen ? 'screen' : undefined
607      const sent = i >= 0 ? st.submitted.splice(i, 1)[0]! : undefined
608      st.lastCmd = null
609      push({ k: 'turn', id: e.turnId, text, via, cid: sent?.cid, ...(sent?.files ? { files: sent.files } : {}) })
610    } catch {}
611    return next(e)
612  })
613
614  on('turn.complete', async ($: Any, e: Any, next: Any) => {
615    try {
616      if (e.agentId) {
617        const id = st.agents.get(e.agentId)
618        if (id) { push({ k: 'agentEnd', id, stopped: !!e.isAborted }); st.agents.delete(e.agentId) }
619      } else {
620        const u = e.usage
621        push({ k: 'turnEnd', id: e.turnId, ms: e.durationMs, aborted: !!e.isAborted, reason: e.reason, usage: u ? { in: u.input_tokens, out: u.output_tokens, cw: u.cache_creation_input_tokens, cr: u.cache_read_input_tokens } : undefined })
622        st.turnId = ''; st.turnText = ''; st.lastCmd = null; st.tools.clear(); st.ceil.clear()
623        for (const f of st.afterTurn.splice(0)) $.clock.after(400, f)
624        if (++st.turnsDone % 5 === 0) void reload($)
625        void branch($).then(b => { if (b !== st.branch) { st.branch = b; push({ k: 'info', branch: b }) } })
626      }
627    } catch {}
628    return next(e)
629  })
630
631  // the model's words, anything typed while it works, and what a command printed; everything else passes untouched
632  on('session.append', async ($: Any, e: Any, next: Any) => {
633    const r = await next(e)
634    try {
635      if (st.on && !e.agentId) {
636        if (e.door === 'command') {
637          // a command's rows: the one naming it, then the line it printed ("Set model to …"), which no plugin is handed
638          const row = commandRow(e.message?.content)
639          if (row.name) st.appendCmd = row.name
640          if (row.out !== undefined && st.appendCmd && !SKIP_CMDS.has(st.appendCmd)) {
641            const run = st.cmdRun && st.cmdRun.name === st.appendCmd && Date.now() - st.cmdRun.t < 10 * 60e3 ? st.cmdRun : null
642            if (row.out && run) {
643              // the line joins its run's row: before the row is told it rides in it, after it fills it (unless the command answered with text)
644              if (!run.told) { if (!run.text.trim()) run.text = row.out; if (run.settled) cmdTell(run) }
645              else if (!run.text.trim()) { run.text = row.out; push({ k: 'cmdOut', name: run.name, text: keepText(row.out, 5000), run: run.id }) }
646            } else if (row.out) push({ k: 'cmdOut', name: st.appendCmd, text: keepText(row.out, 5000) })
647            if (st.appendCmd === 'effort') effortIs(effortSet(row.out))
648            st.appendCmd = ''
649          }
650        } else if (e.door === 'response' && e.message?.type === 'assistant') {
651          const t = textOf(e.message.content)
652          if (t.trim()) { push({ k: 'say', text: t }); st.turnHasOutput = true }
653        } else if (e.door === 'delivery' && st.turnId) {
654          // what the person typed while the model worked, reaching it inside this turn: shown there, as they typed it
655          const t = midTurnText(e.message, e.origin?.kind, st.midTyped)
656          if (t) push({ k: 'user', text: t, mid: true })
657        } else if (e.door === 'prompt' && e.message?.role === 'user' && !e.message.isMeta && st.turnId && st.turnHasOutput) {
658          const t = textOf(e.message.content).trim()
659          if (t && t !== st.turnText.trim()) push({ k: 'user', text: t, mid: true })
660        }
661      }
662    } catch {}
663    return r
664  })
665
666  on('tool.call', async ($: Any, e: Any, next: Any) => {
667    if (!st.on) return next(e)
668    const id: string = e.tool_use_id, name = String(e.tool), agent: string | undefined = e.agentId
669    const { tool: _t, tool_use_id: _i, agentId: _a, ...args } = e
670    try {
671      const link = linkFor(name, args)
672      const known = st.srcOf.get(link.id); if (known) { link.src = link.src || known.src; if (known.dt != null) link.dt = known.dt; if (known.desc) link.desc = known.desc }
673      if (link.kind === 'agent' && !link.desc && st.agentDesc.has(link.label)) link.desc = st.agentDesc.get(link.label)
674      const ev: Any = { k: 'tool', id, name: verb(name), input: summarize(name, args, st.cwd), link, agent }
675      const fc = fileChange(name, args, st.cwd); if (fc) ev.file = fc
676      const todos = todosOf(name, args); if (todos) ev.todos = todos
677      if (name === 'TaskUpdate') ev.task = { op: 'update', id: String(args.taskId ?? ''), st: str(args.status), t: str(args.subject) }
678      push(ev)
679      st.tools.set(id, { name, input: ev.input, agent })
680      if (!agent) st.turnHasOutput = true
681      if (name === 'Skill') st.recentSkill = { name: link.label, t: Date.now() }
682    } catch {}
683    let r: Any
684    try { r = name === 'AskUserQuestion' && !agent ? await question($, e, next, id, args) : await next(e) }
685    catch (err) { push({ k: 'toolEnd', id, err: true, out: '中断' }); st.tools.delete(id); throw err }
686    try {
687      st.tools.delete(id); st.ceil.delete(id)
688      const end: Any = { k: 'toolEnd', id, err: !!(r && (r.deny || r.isError)), out: outline(name, r) }
689      if (name === 'TaskCreate' && !end.err) { const m = /#(\d+)/.exec(str(r?.text)) ?? /\b(\d+)\b/.exec(str(r?.text)); end.task = { op: 'create', id: m ? m[1] : id, t: clip(str(args.subject) || str(args.description), 200) } }
690      push(end)
691    } catch {}
692    return r
693  })
694
695  // an organization that requires the dialog for a tool keeps it: those calls are never offered to the screen
696  on('tool.check', async ($: Any, e: Any, next: Any) => {
697    const v = await next(e)
698    try { if (e.tool_use_id && (v?.ceiling || e.ceiling)) st.ceil.add(e.tool_use_id) } catch {}
699    return v
700  })
701
702  // the moment a permission dialog would open
703  on('classic.PermissionRequest', async ($: Any, e: Any, next: Any) => {
704    const r = await next(e)
705    if (!st.on || r?.decision || e.tool_name === 'AskUserQuestion') return r      // a question is answered, not approved: see question()
706    try {
707      const name = String(e.tool_name)
708      let id = ''
709      const running = [...st.tools].reverse()
710      for (const [tid, t] of running) if (t.name === name && !t.waiting && (t.agent ?? '') === (e.agent_id ?? '')) { id = tid; break }
711      if (!id) for (const [tid, t] of running) if (t.name === name && !t.waiting) { id = tid; break }
712      const input = summarize(name, (e.tool_input ?? {}) as Any, st.cwd)
713      if (id) st.tools.get(id)!.waiting = true
714      push({ k: 'wait', id: id || `perm-${Date.now()}`, name: verb(name), input })
715      if (!st.ready || !st.approvals || !id || st.ceil.has(id)) return r
716      const ask = await post($, '/api/mod/ask', { chan: CHAN, id, tool: verb(name), input })
717      if (ask?.mode !== 'screen') return r
718      st.band = { id, tool: verb(name), input, until: ask.exp || Date.now() + 120000 }
719      $.ui.invalidate('ui.render')
720      const decision = await waitDecision($, id, next.signal)
721      st.band = null; $.ui.invalidate('ui.render')
722      if (decision === 'allow') { push({ k: 'run', id, how: 'screen' }); return { decision: { behavior: 'allow' } } }
723      if (decision === 'deny') return { decision: { behavior: 'deny', message: 'Fogcast の画面で拒否されました。' } }
724    } catch { st.band = null }
725    return r
726  })
727
728  on('agent.spawn', async ($: Any, e: Any, next: Any) => {
729    const r = await next(e)
730    try { if (st.on && r?.agentId) { st.agents.set(r.agentId, e.tool_use_id); push({ k: 'agent', id: e.tool_use_id, type: e.subagentType, desc: clip(e.description || '', 160) }) } } catch {}
731    return r
732  })
733
734  on('skill.prompt', async ($: Any, e: Any, next: Any) => {
735    try {
736      const rs = st.recentSkill
737      const known = st.srcOf.get(`skill:${e.skill}`)
738      if (!(rs && rs.name === e.skill && Date.now() - rs.t < 15000)) push({ k: 'skill', name: e.skill, link: { id: `skill:${e.skill}`, kind: 'skill', label: e.skill, src: known?.src, dt: known?.dt, desc: known?.desc || undefined } })
739      st.recentSkill = null
740      // a command the "/" list could not tell from the built-in panels: from now on the screen runs it as a turn
741      const name = String(e.skill || '')
742      if (name && !st.skillRan.has(name)) { st.skillRan.add(name); if (!st.srcOf.has(`skill:${name}`)) push({ k: 'skillCmd', name }) }
743    } catch {}
744    return next(e)
745  })
746
747  // what each agent type is for, as the engine offers it to the model before a request: kept for its コミュ card
748  on('agent.offer', async ($: Any, e: Any, next: Any) => {
749    const r = await next(e)
750    try {
751      const d = keepText(str(e.description), 1500), id = `agent:${e.agent}`
752      if (st.on && d && e.source !== 'built-in' && st.agentDesc.get(e.agent) !== d) {
753        st.agentDesc.set(e.agent, d)
754        const known = st.srcOf.get(id); if (known) known.desc = d
755        push({ k: 'desc', id, desc: d })
756      }
757    } catch {}
758    return r
759  })
760  // each command's argument hint (`/model [model]`), as the engine lists it for the typeahead at start
761  on('command.describe', async ($: Any, e: Any, next: Any) => {
762    const r = await next(e)
763    try {
764      const h = str(r?.argumentHint) || str(e.argumentHint); if (h) st.hints.set(String(e.command), clip(h, 120)); else st.hints.delete(String(e.command))
765      if (r?.isHidden ?? e.isHidden) st.hiddenCmds.add(String(e.command)); else st.hiddenCmds.delete(String(e.command))
766    } catch {}
767    return r
768  })
769  // the dim guess at the next prompt that Claude Code shows after a turn (Tab takes it): the screen shows it too
770  on('prompt.suggest', async ($: Any, e: Any, next: Any) => {
771    const r = await next(e)
772    try { const t = str(e.text).trim(); if (st.on && r?.isShown && t) { st.suggest = t; push({ k: 'suggest', text: clip(t, 400) }) } } catch {}
773    return r
774  })
775  // typing in the terminal's box (or taking the suggestion there) puts the suggestion away
776  on('prompt.edit', async ($: Any, e: Any, next: Any) => {
777    if (st.suggest) { st.suggest = ''; push({ k: 'suggest', text: '' }) }
778    return next(e)
779  })
780
781  on('command.run', { command: 'fog' }, async ($: Any) => fog($))
782  on('command.run', async ($: Any, e: Any, next: Any) => {
783    if (!st.on || e.command === 'fog') return next(e)
784    // typed in the terminal (or run by another plugin); the screen's own runs are followed in runCommand
785    st.lastCmd = { name: e.command, args: e.args || '', t: Date.now() }
786    const run = cmdBegin(e.command, e.args || '', false)
787    const r = await next(e)
788    try {
789      if (typeof r?.text === 'string' && !st.turnId) st.lastCmd = null          // it printed: no turn follows
790      cmdDone($, run, r)
791    } catch {}
792    return r
793  })
794
795  on('session.compact', async ($: Any, e: Any, next: Any) => {
796    const r = await next(e)
797    try { if (st.on && !e.agentId && e.trigger !== 'precompute' && !r?.skip) push({ k: 'compact', trigger: e.trigger }) } catch {}
798    return r
799  })
800
801  on('session.end', async ($: Any, e: Any, next: Any) => {
802    try {
803      if (st.on) {
804        if (e.reason === 'clear') { push({ k: 'clear', prev: e.sessionId }); st.turnId = ''; st.tools.clear() }
805        // /resume in this terminal: the conversation is swapped, the terminal goes on (classic.SessionStart says for which)
806        else if (e.reason === 'resume') { st.resumeFrom = String(e.sessionId || st.sid); st.turnId = ''; st.tools.clear(); st.ceil.clear(); st.suggest = '' }
807        else {
808          push({ k: 'end', reason: e.reason, resume: e.resume?.id ?? e.sessionId })
809          if (st.ready) await post($, '/api/mod/sync', { chan: CHAN, events: outbox.splice(0) })
810        }
811      }
812    } catch {}
813    return next(e)
814  })
815
816  // which conversation this terminal is on: at start (its file tells the hub where the project's conversations are),
817  // and after /clear or /resume, which swap it while the terminal goes on
818  on('classic.SessionStart', async ($: Any, e: Any, next: Any) => {
819    try {
820      const sid = str(e.session_id), tp = str(e.transcript_path)
821      if (tp) st.tp = tp
822      if (st.on && sid && (e.source === 'resume' || e.source === 'fork')) {
823        push({ k: 'resumed', from: st.resumeFrom || st.sid, sid, title: clip(str(e.session_title), 200), tp, source: e.source })
824        st.resumeFrom = ''; st.turnId = ''; st.turnText = ''; st.tools.clear(); st.lastCmd = null
825        if (st.cmdRun?.name === 'resume') st.cmdRun.shown = true
826        if (str(e.model) && e.model !== st.model) { st.model = e.model; push({ k: 'info', model: e.model }) }
827      } else if (st.on && sid && e.source === 'clear') push({ k: 'info', sid, tp })
828      if (sid) st.sid = sid
829    } catch {}
830    return next(e)
831  })
832  // /model, the /config Model row, the screen's picker or a fallback: the switch shows in the conversation
833  on('classic.PostModelSwitch', async ($: Any, e: Any, next: Any) => {
834    try {
835      // a switch is one from a model to another (st.model may already have caught up through the periodic check)
836      const to = str(e.to_model), from = str(e.from_model)
837      if (st.on && to && to !== (from || st.model)) {
838        // a resume restores the conversation's own model: that is part of the resume, not a switch to tell of
839        const screen = Date.now() - st.modelAsked < 15000; if (screen) st.modelAsked = 0
840        push(e.source === 'resume' ? { k: 'info', model: to } : { k: 'model', from: from || st.model, to, src: screen ? 'screen' : str(e.source) })
841        $.clock.after(300, () => { void syncModels($) })
842        // `/model sonnet`: the switch says it, with the model it left; the command's own row would say it again
843        if (st.cmdRun?.name === 'model' && !st.cmdRun.told && Date.now() - st.cmdRun.t < 60000) st.cmdRun.shown = true
844        st.model = to
845        effortIs(await savedEffort($, to))
846      }
847    } catch {}
848    return next(e)
849  })
850  // the Model row changed in the terminal's /config (this module's own writes are not seen here): the picker's mark follows
851  on('config.set', async ($: Any, e: Any, next: Any) => {
852    const r = await next(e)
853    try { if (st.on && e.key === 'model' && !r?.deny) $.clock.after(300, () => { void syncModels($) }) } catch {}
854    return r
855  })
856  // each request of the main conversation: the effort it really goes with (a model without effort sends none)
857  on('turn.step', async function* ($: Any, e: Any, next: Any) {
858    try {
859      if (st.on && !e.agentId) {
860        const eff = e.effort == null ? '' : String(e.effort)
861        if (eff && eff !== st.effortUsed) { st.effortUsed = eff; push({ k: 'info', effortUsed: eff }) }
862        if (eff && !st.effort) effortIs(eff)            // nothing set that this terminal saw: the model's own level stands for it
863      }
864    } catch {}
865    return yield* next(e)
866  })
867
868  // what the turn is doing right now, for the screen's status line
869  on('ui.render', { component: 'Spinner' }, async ($: Any, e: Any, next: Any) => {
870    try { const m = e.props?.mode; if (st.on && m && m !== st.mode) { st.mode = m; push({ k: 'mode', mode: m }) } } catch {}
871    return next(e)
872  })
873  // a call that shows progress has been let through its dialog
874  on('ui.render', { component: 'ToolProgress' }, async ($: Any, e: Any, next: Any) => {
875    try { const t = st.tools.get(e.props?.tool_use_id); if (t?.waiting) { t.waiting = false; push({ k: 'run', id: e.props.tool_use_id }) } } catch {}
876    return next(e)
877  })
878  // /fog's row, as the person sees it: with the address and its key when no browser could be opened
879  on('ui.render', { component: 'CommandOutput', props: { command: 'fog' } }, async ($: Any, e: Any, next: Any) => {
880    if (!st.fogShow || e.props?.isErrored) return next(e)
881    return next({ ...e, props: { ...e.props, text: `${e.props.text}\n\n鍵つきの URL(この画面にだけ出ています):\n\n${st.fogShow}` } })
882  })
883
884  // above the prompt: the code a browser is being paired with, and an approval the screen holds
885  on('ui.render', { component: 'AbovePrompt' }, async ($: Any, e: Any, next: Any) => {
886    const b = st.band, p = st.pair && st.pair.until > Date.now() ? st.pair : null
887    if ((!b && !p) || e.props?.hasSurvey) return next(e)
888    const ui = $.ui.resolve(e) as Any
889    const parts: Any[] = []
890    if (p) parts.push(pairBand($, ui, p))
891    if (b) parts.push(askBand($, ui, b))
892    return parts.length === 1 ? parts[0] : h(ui.Box, { flexDirection: 'column' }, ...parts)
893  })
894}
895
896/** The pairing code, framed in yellow right above the prompt, where the person is already looking. */
897function pairBand($: Any, ui: Any, p: { code: string; until: number }) {
898  const { Box, Text, Button } = ui
899  const cancel = () => {
900    st.pair = null; $.ui.invalidate('ui.render'); line($)
901    void post($, '/api/mod/pair/cancel', { chan: CHAN, code: p.code })
902  }
903  return h(Box, { key: 'fogcast-pair', flexDirection: 'column', borderStyle: 'round', borderColor: 'warning', paddingX: 1 },
904    h(Box, { flexDirection: 'row', gap: 2 },
905      h(Text, { color: 'warning', bold: true }, 'Fogcast  ブラウザ承認の合言葉'),
906      h(Text, { color: 'warning', bold: true, inverse: true }, ` ${p.code} `)),
907    h(Text, { wrap: 'wrap' }, `ブラウザの Fogcast の画面(設定 → ブラウザから承認)に入力してください。${pairLeft()}で使えなくなります。`),
908    h(Box, { flexDirection: 'row', gap: 1 },
909      h(Text, { dimColor: true }, '心当たりがなければ'),
910      h(Button, { key: 'fogcast-pair-cancel', label: '取り消す', onPress: cancel })))
911}
912
913/** While the screen holds an approval, the terminal says so and can take it back. */
914function askBand($: Any, ui: Any, b: { id: string; tool: string; input: string; until: number }) {
915  const { Box, Text, Button } = ui
916  const left = Math.max(0, Math.round((b.until - Date.now()) / 1000))
917  return h(Box, { key: 'fogcast-ask', flexDirection: 'column' },
918    h(Text, { color: 'yellow', bold: true }, `Fogcast の画面で承認を待っています(あと ${left} 秒でここの確認に切り替わります)`),
919    h(Text, { dimColor: true, wrap: 'truncate-end' }, `${b.tool}: ${b.input}`),
920    h(Button, { key: 'fogcast-here', hotkey: 't', label: 'ここで確認する', onPress: () => { void post($, '/api/mod/ask/answer', { id: b.id, decision: 'terminal' }) } }))
921}
922
hooks/shape.ts 335 lines
1// How a tool call, a row or a reading is described on the Fogcast screen.
2// Pure functions, no `$`: register.ts calls them and the tests check them.
3
4export type LinkKind = 'core' | 'tool' | 'skill' | 'agent' | 'mcp'
5export type Link = { id: string; kind: LinkKind; label: string; src?: string; dt?: number; desc?: string }
6export type FileChange = { path: string; add: number; del: number }
7export type Todo = { t: string; st: 'todo' | 'doing' | 'done' }
8type Args = Record<string, unknown>
9
10export const str = (v: unknown): string => (typeof v === 'string' ? v : '')
11export const clip = (s: string, n: number): string => {
12  const one = s.replace(/\s+/g, ' ').trim()
13  return one.length > n ? one.slice(0, n - 1) + '…' : one
14}
15const lines = (s: unknown): number => {
16  const t = str(s)
17  if (!t) return 0
18  return t.split('\n').length - (t.endsWith('\n') ? 1 : 0)
19}
20
21/** A path as the screen shows it: relative to the session's folder when it is inside it. */
22export function rel(path: string, cwd: string): string {
23  if (!path) return ''
24  const c = cwd.replace(/[\\/]+$/, '')
25  if (c && (path.startsWith(c + '/') || path.startsWith(c + '\\'))) return path.slice(c.length + 1)
26  return path
27}
28
29const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
30const MCP = /^mcp__(.+?)__(.+)$/
31
32/** Which コミュ card a tool call counts toward. */
33export function linkFor(tool: string, a: Args): Link {
34  if (tool === 'Skill') {
35    const s = str(a.skill) || str(a.command) || str(a.name)
36    if (s) return { id: `skill:${s.replace(/^\//, '')}`, kind: 'skill', label: s.replace(/^\//, '') }
37  }
38  if (tool === 'Agent' || tool === 'Task') {
39    const t = str(a.subagent_type) || 'general-purpose'
40    return { id: `agent:${t}`, kind: 'agent', label: t }
41  }
42  if (TASK_TOOLS.has(tool)) return { id: 'Tasks', kind: 'tool', label: 'Tasks', src: '組み込み' }
43  const m = MCP.exec(tool)
44  if (m) return { id: `mcp:${m[1]}`, kind: 'mcp', label: m[1]!, src: 'MCP' }
45  return { id: tool, kind: 'tool', label: tool, src: '組み込み' }
46}
47
48/** The verb of the row: the tool, or for an MCP tool its server. */
49export function verb(tool: string): string {
50  const m = MCP.exec(tool)
51  return m ? m[1]! : tool
52}
53
54function firstString(a: Args): string {
55  for (const v of Object.values(a)) if (typeof v === 'string' && v.trim()) return v
56  return ''
57}
58function host(url: string): string {
59  try { const u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname) } catch { return url }
60}
61
62/** One line saying what the call does. */
63export function summarize(tool: string, a: Args, cwd: string): string {
64  const p = (k: string) => rel(str(a[k]), cwd)
65  switch (tool) {
66    case 'Bash': return clip(str(a.command).split('\n')[0] ?? '', 160)
67    case 'Read': return clip(p('file_path'), 160)
68    case 'Edit': case 'Write': case 'MultiEdit': return clip(p('file_path'), 160)
69    case 'NotebookEdit': return clip(p('notebook_path'), 160)
70    case 'Grep': return clip(`"${str(a.pattern)}" in ${p('path') || '.'}`, 160)
71    case 'Glob': return clip(str(a.pattern) + (a.path ? ` in ${p('path')}` : ''), 160)
72    case 'WebFetch': return clip(host(str(a.url)), 160)
73    case 'WebSearch': return clip(str(a.query), 160)
74    case 'Agent': case 'Task': return clip(`${str(a.subagent_type) || 'general-purpose'}:${str(a.description)}`, 160)
75    case 'Skill': return clip(`${str(a.skill) || str(a.command)}${a.args ? ' ' + str(a.args) : ''}`, 160)
76    case 'TodoWrite': return `${Array.isArray(a.todos) ? a.todos.length : 0} 件のタスク`
77    case 'TaskCreate': return clip(str(a.subject) || str(a.description), 160)
78    case 'TaskUpdate': return clip(`#${str(a.taskId) || String(a.taskId ?? '')}${a.status ? ' → ' + str(a.status) : ''}${a.subject ? ' ' + str(a.subject) : ''}`, 160)
79    case 'AskUserQuestion': {
80      const q = Array.isArray(a.questions) ? (a.questions[0] as Args | undefined) : undefined
81      return clip(str(q?.question) || str(a.question), 160)
82    }
83    case 'ExitPlanMode': return '計画の確認'
84  }
85  const m = MCP.exec(tool)
86  if (m) return clip(`${m[2]}${firstString(a) ? ' ' + firstString(a) : ''}`, 160)
87  return clip(firstString(a), 160)
88}
89
90/** What a finished call came to, in a few words. */
91export function outline(tool: string, r: { deny?: string; isError?: boolean; text?: string } | undefined): string {
92  if (!r) return ''
93  if (r.deny) return '拒否'
94  const text = str(r.text)
95  const first = (text.split('\n').find(x => x.trim()) ?? '').trim()
96  if (r.isError) return clip(`エラー:${first}`, 60)
97  switch (tool) {
98    case 'Read': return `${lines(text)} 行`
99    case 'Grep': case 'Glob': return text.trim() ? `${lines(text.trim())} 件` : '0 件'
100    case 'Edit': case 'MultiEdit': case 'Write': case 'NotebookEdit': return '保存'
101    case 'Bash': {
102      const last = text.trim().split('\n').reverse().find(x => x.trim()) ?? ''
103      return clip(last || '完了', 60)
104    }
105    case 'TodoWrite': case 'TaskCreate': case 'TaskUpdate': return '更新'
106    case 'Agent': case 'Task': return '完了'
107    case 'WebSearch': return '検索結果'
108    case 'WebFetch': return '取得'
109  }
110  return clip(first || '完了', 60)
111}
112
113/** Lines a file-writing call adds and removes, roughly. */
114export function fileChange(tool: string, a: Args, cwd: string): FileChange | null {
115  const path = rel(str(a.file_path) || str(a.notebook_path), cwd)
116  if (!path) return null
117  if (tool === 'Write') return { path, add: lines(a.content), del: 0 }
118  if (tool === 'Edit') return { path, add: lines(a.new_string), del: lines(a.old_string) }
119  if (tool === 'MultiEdit' && Array.isArray(a.edits)) {
120    let add = 0, del = 0
121    for (const e of a.edits as Args[]) { add += lines(e.new_string); del += lines(e.old_string) }
122    return { path, add, del }
123  }
124  if (tool === 'NotebookEdit') return { path, add: lines(a.new_source), del: 0 }
125  return null
126}
127
128/** The task list as TodoWrite hands it over. */
129export function todosOf(tool: string, a: Args): Todo[] | null {
130  if (tool !== 'TodoWrite' || !Array.isArray(a.todos)) return null
131  return (a.todos as Args[]).slice(0, 40).map(t => ({
132    t: clip(str(t.content) || str(t.activeForm), 200),
133    st: t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : 'todo',
134  }))
135}
136
137/** The text blocks of a row, joined. */
138export function textOf(content: unknown): string {
139  if (typeof content === 'string') return content
140  if (!Array.isArray(content)) return ''
141  return content.map(b => (b && typeof b === 'object' && (b as Args).type === 'text' ? str((b as Args).text) : '')).filter(Boolean).join('\n')
142}
143
144/**
145 * What the person typed while a turn ran, as it reaches the model inside that turn (a `queued_command`
146 * row the engine delivers), or ''. `typed` holds what they submitted over the turn: the one this row
147 * carries is taken out of it, word for word; failing that, the row's own wording is read.
148 */
149export function midTurnText(row: { name?: string; content?: unknown } | undefined, origin: string | undefined, typed: string[]): string {
150  if (row?.name !== 'queued_command') return ''
151  const body = textOf(row.content)
152  const i = typed.findIndex(t => body.includes(t))
153  if (i >= 0) return typed.splice(i, 1)[0]!
154  if (origin && origin !== 'composer' && origin !== 'bridge') return ''     // a task's notice, another session's message
155  return (/while you were working:\n([\s\S]*?)\n\n/.exec(body)?.[1] ?? '').trim()
156}
157
158/**
159 * A slash command's rows as the conversation keeps them (`session.append`, door `command`): the row that names
160 * the command (`<command-name>/model</command-name>`) and the row with what it printed
161 * (`<local-command-stdout>…</local-command-stdout>`). The plugin that ran a command is handed no text for most
162 * of them; the printed line reaches the screen this way.
163 */
164export function commandRow(content: unknown): { name?: string; out?: string } {
165  const body = textOf(content)
166  const name = /<command-name>\/?([^<\s]+)<\/command-name>/.exec(body)?.[1]
167  const m = /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/.exec(body)
168  const out = m ? m[1]!.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\r\n?/g, '\n').trim() : undefined
169  return { ...(name ? { name } : {}), ...(out !== undefined ? { out } : {}) }
170}
171
172/** The effort levels `/effort` takes, read off its argument hint (`[low|medium|…|auto|ultracode [on|off]]`). */
173export function effortLevels(hint: string | undefined): string[] {
174  const inner = String(hint ?? '').trim().replace(/^\[/, '')
175  const words = inner.split(/\[/)[0]!.split('|').map(w => w.replace(/[\]\s]/g, '')).filter(w => /^[a-z]+$/.test(w) && w !== 'ultracode')
176  return words.length ? [...new Set(words)] : ['low', 'medium', 'high', 'xhigh', 'max']
177}
178
179/** A model id as its key in the settings' `modelSettings` (`claude-opus-5-5[1m]` → `claude-opus-5-5`). */
180export function modelKey(model: string): string { return String(model || '').replace(/\[[^\]]*\]$/, '') }
181
182/**
183 * The /config Model row's value as one of the row's own choices. The row can hold a label rather than a choice
184 * (`Default (recommended)` before anything is set) or a model id; the screen's picker marks the choice.
185 */
186export function settingOf(value: unknown, options: readonly string[]): string {
187  const v = typeof value === 'string' ? value.trim() : ''
188  if (!v) return ''
189  if (options.includes(v)) return v
190  const lo = v.toLowerCase(), bare = lo.replace(/\s*\([^)]*\)\s*$/, '').trim()
191  const same = options.find(o => o.toLowerCase() === bare); if (same) return same
192  // a model id or a longer label (claude-sonnet-5-5[1m], "Opus (1M context)"): its family, long-context when it says so
193  const fam = options.find(o => /^[a-z]+$/.test(o) && new RegExp(`(^|[^a-z])${o}([^a-z]|$)`).test(bare))
194  if (!fam) return v
195  const long = `${fam}[1m]`
196  return /\[1m\]|\b1m\b/.test(lo) && options.includes(long) ? long : fam
197}
198
199/** The level an /effort run set, read off the line it printed (`Set effort level to high …`, `Effort level set to auto`). */
200export function effortSet(text: string): string {
201  return /(?:set effort level to|effort level set to)\s+([a-z]+)/i.exec(String(text || ''))?.[1]?.toLowerCase() ?? ''
202}
203
204/**
205 * The files a prompt names with @ (`@src/a.ts`, `@"notes/my file.md"`), as the terminal takes them: at the start or
206 * after a space, an opening bracket or Japanese punctuation (not inside a word, so mail@example.com is no mention),
207 * ten at most, each once.
208 */
209export function mentionsOf(text: string): string[] {
210  const out: string[] = []
211  const re = /(^|[\s((「『【、。,.:;])@(?:"([^"\n]+)"|([^\s"]+))/g
212  let m: RegExpExecArray | null
213  while ((m = re.exec(String(text || ''))) && out.length < 10) {
214    const p = (m[2] ?? m[3] ?? '').trim()
215    if (p && !out.includes(p)) out.push(p)
216  }
217  return out
218}
219
220/**
221 * The spellings a mention may stand for, longest first: as written, without the punctuation that follows it in a
222 * sentence, and its plain-path head (`@src/a.tsを直して` names src/a.ts; a path with Japanese in it is tried whole first).
223 */
224export function mentionCandidates(p: string): string[] {
225  const out = [p]
226  const trimmed = p.replace(/[,.;:!?)\]}」』】、。,.!?)]+$/u, ''); if (trimmed && !out.includes(trimmed)) out.push(trimmed)
227  const head = /^[\w./~@+#%=,-]+/.exec(p)?.[0]?.replace(/[,.]+$/, ''); if (head && !out.includes(head)) out.push(head)
228  return out
229}
230
231/** A file's text as the Read tool shows it to the model: numbered lines, long ones cut, at most `maxLines` and `maxChars`. */
232export function numbered(text: string, maxLines: number, maxChars: number): { body: string; lines: number; shown: number } {
233  const all = String(text).replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n')
234  const out: string[] = []
235  let size = 0
236  for (let i = 0; i < all.length && i < maxLines; i++) {
237    const l = all[i]!.length > 2000 ? all[i]!.slice(0, 2000) + '…' : all[i]!
238    const row = `${String(i + 1).padStart(6)}\t${l}`
239    if (size + row.length > maxChars && out.length) break
240    out.push(row); size += row.length + 1
241  }
242  return { body: out.join('\n'), lines: all.length, shown: out.length }
243}
244
245/** A longer text kept with its line breaks (a skill's description): trimmed, blank runs closed up, cut at `n`. */
246export function keepText(s: string, n: number): string {
247  const t = s.replace(/\r\n?/g, '\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
248  return t.length > n ? t.slice(0, n - 1) + '…' : t
249}
250
251/** A question Claude asks with AskUserQuestion, as the screen draws it. Options keep their places, so a pick is an index. */
252export type Question = { q: string; h: string; multi: boolean; opts: { l: string; d: string }[] }
253export function questionsOf(a: Args): Question[] {
254  const qs = Array.isArray(a.questions) ? (a.questions as Args[]) : []
255  return qs.slice(0, 4).map(x => ({
256    q: keepText(str(x?.question), 600), h: clip(str(x?.header), 40), multi: x?.multiSelect === true,
257    opts: (Array.isArray(x?.options) ? (x.options as Args[]) : []).slice(0, 8).map(o => ({ l: clip(str(o?.label), 120), d: clip(str(o?.description), 300) })),
258  }))
259}
260/**
261 * The answers a screen sent for a question, in the tool's own terms: each question's text → the chosen
262 * label (several joined with ", ", as the terminal's dialog joins them) or what was typed. Every question
263 * must be answered and every pick must be one of its options; anything else is null.
264 */
265export function answersFor(a: Args, sent: unknown): Record<string, string> | null {
266  const qs = Array.isArray(a.questions) ? (a.questions as Args[]).slice(0, 4) : []
267  if (!qs.length || !Array.isArray(sent) || sent.length !== qs.length) return null
268  const out: Record<string, string> = {}
269  for (let i = 0; i < qs.length; i++) {
270    const x = qs[i]!, s = sent[i] as Args | undefined, q = str(x.question)
271    const opts = Array.isArray(x.options) ? (x.options as Args[]) : []
272    const pick = Array.isArray(s?.pick) ? [...new Set(s.pick as unknown[])] : []
273    if (pick.some(n => !Number.isInteger(n) || (n as number) < 0 || (n as number) >= Math.min(opts.length, 8))) return null
274    if (!x.multiSelect && pick.length > 1) return null
275    const labels = (pick as number[]).sort((m, n) => m - n).map(n => str(opts[n]?.label)).filter(Boolean)
276    const typed = str(s?.text).trim().slice(0, 4000)
277    const v = x.multiSelect ? [...labels, ...(typed ? [typed] : [])].join(', ') : typed || labels[0] || ''
278    if (!q || !v) return null
279    out[q] = v
280  }
281  return out
282}
283
284/** Where a skill or an agent was defined, in the screen's words. */
285export function srcLabel(source: string, plugin?: string): string {
286  if (plugin) return `プラグイン ${plugin}`
287  switch (source) {
288    case 'userSettings': case 'user': return '~/.claude'
289    case 'projectSettings': case 'project': return '.claude'
290    case 'localSettings': return '.claude(ローカル)'
291    case 'policySettings': case 'managed': case 'policy': return '組織'
292    case 'plugin': return 'プラグイン'
293    case 'bundled': case 'builtin': case 'built-in': return '組み込み'
294    default: return source || '—'
295  }
296}
297
298/** A project's own (.claude in the repository) as against everywhere's (~/.claude, plugins, built in, the organization). */
299export function isLocalSrc(src: unknown): boolean { return /^\.claude/.test(String(src ?? '')) }
300
301export type Reading = { kind: string; percentUsed: number; resetsAt?: string }
302export type Forecast = { pc: number; proj?: number; hit?: number | null } | null
303
304function hhmm(t: number): string {
305  const d = new Date(t)
306  return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
307}
308
309/** The terminal's status line. */
310export function statusLine(o: { num: number; ready: boolean; ctx?: number; limits: Reading[]; usd?: number; forecast: Forecast; pair?: string }): string {
311  const parts: string[] = [o.ready ? `Fogcast ch.${o.num}` : 'Fogcast 未接続']
312  // while a browser is being paired, its code leads the line (the band above the prompt shows it too)
313  if (o.ready && o.pair) parts.push(`合言葉 ${o.pair}(Fogcast の画面に入力)`)
314  if (o.ctx !== undefined) parts.push(`ctx ${o.ctx}%`)
315  for (const w of o.limits) {
316    if (w.kind === 'five_hour') {
317      const f = o.forecast
318      const tail = f && f.hit ? ` → ${hhmm(f.hit)} に上限` : f && f.proj !== undefined ? ` → 予報 ${Math.min(100, f.proj)}%` : ''
319      parts.push(`5h ${w.percentUsed}%${tail}`)
320    } else if (w.kind === 'seven_day') parts.push(`7d ${w.percentUsed}%`)
321  }
322  if (o.usd !== undefined) parts.push(`$${o.usd.toFixed(2)}`)
323  parts.push('/fog で画面')
324  return parts.join(' · ')
325}
326
327/** Whether a version string is older than another (x.y.z); anything unreadable is not, so it is left alone. */
328export function older(v: unknown, than: string): boolean {
329  const p = (s: string) => s.split('.').map(n => parseInt(n, 10) || 0)
330  if (typeof v !== 'string' || !/^\d+\.\d+\.\d+/.test(v)) return false
331  const a = p(v), b = p(than)
332  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return (a[i] ?? 0) < (b[i] ?? 0)
333  return false
334}
335