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

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 922 lines1// 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}
922hooks/shape.ts 335 lines1// 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