親が子からのメッセージ (send_message) を受けたら、自分が起動した子のうちタイトル規約 ([S] / [O] / #p…-c…) と違うモデルのものを set_session_model で切り替える Claude Mod (function hooks)

claude.ai/code (Claude Code on the Web) に、Claude Code desktop 版の 「セッション横並び」を足す Chrome 拡張。PoC。
| 操作 | 何が起きるか |
|---|---|
| サイドバーのセッションを Ctrl / ⌘ + クリック | 右のペインに開く (desktop 版と同じ操作) |
| セッション行にホバーして右端の ⧉ | 同上 |
| ペイン上部の ⇄ | メインと入れ替える |
| ペイン上部の ↗ | メインで開く (ペインは閉じる) |
| ペイン上部の × | 閉じる |
| ペイン群の左端の縦バーをドラッグ | ペイン領域の幅を変える (15〜85%) |
| 縦バー上部の ⇤ / ⇥ | 親 (メイン) を畳んでペインに全幅を渡す / 戻す |
| 縦バー上部の ▤、サイドバー上部 (「チャット / Code」の左) の ▤ | サイドバーを畳む (claude.ai 自身の「サイドバーを非表示」を押す。戻すのは左上に出る「サイドバーを表示」か同じ ▤) |
ペインで開いているセッションは、サイドバーの行に claude.ai 自身の選択色が付く (desktop 版の横並びと同じ)。
ペインの中のセッションがアーカイブされる (画面に「このセッションはアーカイブされています」のバナーと 「アーカイブ解除」が出る) と、ペインは数秒で自動的に閉じる。その path は 7 日間覚えて、サイドバーに行が 残っていても開き直さず、行は薄く表示する。
ペインのタイトルバー右側に状態が出る: ● 実行中 / ○ 待機中 (composer の停止ボタンの有無)、 現在のモデル、モデル判定の段階 (判定前 / 題待ち / composer待ち / 切替中… / 規約どおり / 切替済 / 規約外 / 切替失敗)。 2 秒ごとにペイン側から更新される。
| タイトル | 役 | 期待するモデル |
|---|---|---|
#p<issue> <題> | 親 | Opus (Fable でも可) |
[S] #p<issue>-c<番号>(-<n>) <題> | 子 | Sonnet |
[O] #p<issue>-c<番号>(-<n>) <題> / タグ無しの子 | 子 | Opus (Fable でも可) |
[旧] #p<issue> <題> | 旧親 | 触らない |
c の後は子の issue 番号でも分岐番号でもよい (見分けない)。旧形 #c<issue>-<n> は廃止した — その題の子は規約外になり、子の自動展開・⊞・モデル強制・model-by-title の対象から外れる。
[S] なのに Opus → Sonnet、親なのに Sonnet → Opus)。 規約に合わないタイトルは触らない。題や composer がまだ描画されていない回は何も決めず、開いた直後の 30 秒は 1 秒おき、その後は 15 秒おきに、決着 (期待どおり / 切り替えた / 規約外) がつくまで見直す。 決着後は手で変えたものを尊重するmodel-by-title上のモデル強制は claude.ai/code でそのセッションを開いている間しか効かない。開かずに済ませたいときは Claude Code 側の mod (function hooks) mods/model-by-title を入れる。
send_message は session.receive も prompt.submit も通らず、classic.UserPromptSubmit の prompt に <cross-session-message …> を含む形で届くので、それを含むときだけ動く (ユーザーの入力では動かない)。 list_sessions の中から題規約に当たり get_session の parentSessionId が自分のもの (= 自分が起動した子) を見直す。規約と違うモデルの子を set_session_model で切り替える (set_session_model は自分自身には効かないので、起動元の親から直す。自分が起動した子を同じか 安いモデルへ変えるのは通常は確認なしで通る — [S] の子を Opus → Sonnet はこれに当たる) (list_sessions({ linked: true }) は start_session の家族だけで、spawn_task のチップから起動した子は入らない)get_session の model は claude.ai 側 (Remote Control) での切り替えを拾わず、古い値のまま残ることがある$.store の log に残る導入 (Claude Code 2.1.260 以上):
# ~/.claude/settings.json の env に "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
claude plugin marketplace add ippoan/claude-code-web-split-view
claude plugin install model-by-title@claude-code-web-split-view
mod からの set_session_model も auto mode の判定を通るので、~/.claude/settings.json の permissions.allow に mcp__ccd_session_mgmt__set_session_model を足す。
入れた / 更新した plugin は起動中のセッションでは読み直されない。新しく起動した親から効くほか、起動中の親は 入力欄の /reload-plugins で読み直せる (更新は先に claude plugin update model-by-title@claude-code-web-split-view)。 読み込まれるとステータスラインに model-by-title が出る。
ペインは何枚でも増やせる (1 枚あたり最小 360px、あふれたら横スクロール)。 構成は chrome.storage.local に残り、リロードで復元する。
claude.ai の CSP は frame-ancestors 'self' で、x-frame-options は無い (2026-09-10 実測)。 つまり claude.ai 自身のページからなら同一オリジン iframe で /code/session_… を開ける。 content script が #root を左に縮め、右に position: fixed のペイン群を置き、各ペインに iframe を入れるだけ。iframe 内のセッション画面はサイドバー無しで描画される。
逆に chrome-extension:// オリジン (popup ウィンドウ・chrome.sidePanel・options ページ) からは frame-ancestors で弾かれるので、UI は拡張ページ側に持てない。claude.ai をホストにする content script 一択で、ippoan/gh-actions-live (拡張ページが本体) とは作りが逆になる。
claude.ai の DOM class は難読化されているので、フックは a[href^="/code/session_"] のような href ベースだけにしている。触るのは #root の幅と <a> の中身 (⧉) のみ。
claude-code-web-split-view-*-x64.msi を落として実行 (ダブルクリックで可)。 %LOCALAPPDATA%\Programs\claude-code-web-split-view\extension に配置され、 native messaging host (ボタン更新の実体) が HKCU に登録されるchrome://extensions → 右上「デベロッパー モード」ON → 「パッケージ化されていない拡張機能を読み込む」→ 上のフォルダibdmdncjfdpdmcakmdbdohieahjlkhoa になっていることを確認。claude.ai/code を開き直すMSI を使わない場合は claude-code-web-split-view-extension.zip を展開して読み込む (この場合 native host が無いので、更新は新しい zip を同じフォルダに上書き展開して拡張カードの ↻)。
msiexec /i claude-code-web-split-view-x.y.z-x64.msi ALLUSERS=1 (UAC 昇格) → Chrome 再起動。 HKLM の ExtensionSettings (force_installed + update_url = releases/latest/download/update.xml) で入り、更新も Chrome が拾う。
Chrome は Web Store 外の拡張の update_url を相手にしないので、gh-actions-live と同じ 2 段で自前に組む:
installer/update.ps1 が Release の update.xml の版を見て、新しければ claude-code-web-split-view-extension.zip を落とし、sha256 を照合して extension\ を差し替えるchrome.runtime.reload()①の起動は ツールバーのアイコン (新版があるとバッジに UP が出る。クリックで native host が update.ps1 を実行。実行中はバッジが …) か、update.ps1 -Register でタスク スケジューラ (ログオン時 + 1 時間ごと) に登録。結果は Windows の通知で出る (更新した / 最新だった / 失敗の理由)。 更新が無ければアイコンのクリックは claude.ai/code を開く (既に開いていれば前面に出す)。 連打しても update.ps1 は 1 本しか走らない (拡張側は in-flight guard、ps1 側は named mutex)。
拡張は externally_connectable で https://claude.ai/* からのメッセージを受ける。 Claude in Chrome の javascript_tool で claude.ai のタブから打てる:
chrome.runtime.sendMessage('ibdmdncjfdpdmcakmdbdohieahjlkhoa', { command: 'get-version' }, r => console.log(r));
// running / onDisk / latestVersion / lastUpdateResult (native host の直近の応答) が返る
// command: check-update / native-ping / update / reload / get-log / clear-log も同じ経路
get-log は content script の判定ログ (直近 300 件、chrome.storage.local の ccw:log)。 モデル強制の各分岐 (enforce:no-title / no-composer / out-of-convention / ok / switched / switch-failed / menu-item-missing)、ペインの起動 (pane:init)、子の自動展開 (child:auto-open) が frame (top / pane)・path・題つきで残る。「切り替わらない」ときはまずこれを読む。
ログは %LOCALAPPDATA%\Programs\claude-code-web-split-view\update.log。
gh-actions-live と同じ。main への push で patch を自動採番して vX.Y.Z の Release を作る。 PR (non-draft) は CI が緑になると dev-<run_number> の prerelease を出し、org 標準の auto-merge が squash merge する。tag を手で打つ経路は無い。 .crx の署名鍵は repo secret CRX_PRIVATE_KEY (manifest の key と同じ鍵)。
extension/ MV3 拡張本体 (content.js = 分割画面、background.js = 自動更新)
installer/main.wxs MSI (WiX v5、perUserOrMachine)
installer/update.ps1 更新スクリプト (native host / 手動 / 任意でタスク登録)
installer/host.ps1 native messaging host (アイコンクリック更新の実体)
mods/model-by-title/ Claude Code の mod (子のモデルを題規約に合わせる)。.claude-plugin/marketplace.json から配る
%LOCALAPPDATA%\Programs\claude-code-web-split-view\extension を 「パッケージ化されていない拡張機能を読み込む」で読み込み、ID が ibdmdncjfdpdmcakmdbdohieahjlkhoa で一致#root が 1920px → 960px に縮む。 iframe 内は content script が入り (html[data-ccw-pane])、composer 付きでサイドバー無し#root が 1920px に戻るhooks/register.ts 101 lines1import type { EngineInterface, Register } from 'claude-code'
2import { expectedFamily, lastModel, planFor, transcriptDir } from './rules'
3
4// 親が子からのメッセージ (起動報告など) を受けたら、自分が起動した子を見直し、
5// タイトル規約と違うモデルの子を set_session_model で切り替える。
6// - きっかけ: デスクトップでは子の send_message は session.receive も prompt.submit も通らず、
7// classic.UserPromptSubmit の e.prompt に <cross-session-message …> を含む形で届く。
8// それを含むときだけ見直す (ユーザーの入力では動かさない)
9// - set_session_model は自分自身には効かないので、起動元の親から直す。
10// mod からの呼び出しも auto mode の判定を通るので、permissions.allow に
11// mcp__ccd_session_mgmt__set_session_model が要る (README)
12// - 今のモデルは transcript の最後の応答から読む (get_session の model は claude.ai 側の切り替えを拾わない)
13// - 1 つの子につき判定は 1 回 (期待どおり / 切り替えた / 失敗)。以後は手で変えたものを尊重する
14// - $ を渡す先は最上位の function (claude plugin validate の要件)。状態はモジュール変数に置く
15const SERVER = 'ccd_session_mgmt'
16const MARKER = '<' + 'cross-session-message'
17
18type Row = { sessionId: string; title?: string; cwd?: string; isArchived?: boolean }
19type Session = { sessionId?: string; parentSessionId?: string }
20
21const textOf = (r: { content: { type: string; text?: string }[] }) => r.content.find(c => c.type === 'text')?.text ?? ''
22
23// MCP 結果のテキストは JSON の後ろに注記が付くことがある。最初の [ か { から最後の ] か } までを parse する
24const parseJson = <T>(text: string): T => {
25 const start = text.search(/[[{]/)
26 const end = Math.max(text.lastIndexOf(']'), text.lastIndexOf('}'))
27 return JSON.parse(text.slice(start, end + 1)) as T
28}
29
30let isBusy = false
31const othersChildren = new Set<string>() // 他の親の子。get_session を毎回打たない
32
33export const register: Register = on => {
34 // 読み込まれた印 (子からメッセージが来るまで何もしないので、これが無いと入ったか分からない)
35 on('session.start', async ($, e, next) => {
36 const result = await next(e)
37 $.ui.status('model-by-title')
38 return result
39 })
40
41 on('classic.UserPromptSubmit', async ($, e, next) => {
42 if (typeof e.prompt === 'string' && e.prompt.includes(MARKER)) await review($)
43 return next(e)
44 })
45}
46
47async function review($: EngineInterface) {
48 if (isBusy) return
49 isBusy = true
50 try {
51 const home = (await $.env.get('HOME')) ?? ''
52 const me = parseJson<Session>(textOf(await $.mcp.call(SERVER, 'get_session', { session_id: 'self' }))).sessionId
53 // linked: true は start_session の家族だけで、spawn_task のチップから起動した子は入らない。
54 // 全件から題で絞り、get_session の parentSessionId で自分の子か確かめる
55 const rows = parseJson<Row[]>(textOf(await $.mcp.call(SERVER, 'list_sessions', { limit: 50 })))
56 for (const row of rows) {
57 if (row.isArchived || !row.cwd || expectedFamily(row.title) === null) continue
58 const key = `settled:${row.sessionId}`
59 if (othersChildren.has(row.sessionId) || (await $.store.get(key))) continue
60 const info = parseJson<Session>(textOf(await $.mcp.call(SERVER, 'get_session', { session_id: row.sessionId })))
61 if (!me) continue
62 if (info.parentSessionId !== me) {
63 othersChildren.add(row.sessionId)
64 continue
65 }
66
67 const model = await currentModel($, transcriptDir(home, row.cwd))
68 if (model === undefined) continue
69 const to = planFor(row.title, model)
70 if (to === null) {
71 await $.store.set(key, 'ok')
72 await log($, `ok ${model} ${row.title}`)
73 continue
74 }
75
76 const r = await $.mcp.call(SERVER, 'set_session_model', { session_id: row.sessionId, model: to })
77 await $.store.set(key, r.isError ? 'failed' : 'switched')
78 await log($, `${r.isError ? 'failed' : 'switched'} ${model} → ${to} ${row.title}${r.isError ? ` :: ${textOf(r).slice(0, 200)}` : ''}`)
79 $.ui.toast(r.isError ? `model-by-title: 切替失敗 ${row.title}` : `model-by-title: ${row.title} → ${to}`)
80 }
81 } catch (err) {
82 await log($, `error ${String(err).slice(0, 200)}`)
83 } finally {
84 isBusy = false
85 }
86}
87
88// 応答がまだ無い (起動直後) なら undefined。次のメッセージでもう一度見る
89async function currentModel($: EngineInterface, dir: string) {
90 if (!(await $.fs.exists(dir))) return undefined
91 const newest = (await $.fs.list(dir))
92 .filter(f => f.kind === 'file' && f.name.endsWith('.jsonl'))
93 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
94 return newest ? lastModel(await $.fs.read(`${dir}/${newest.name}`)) : undefined
95}
96
97async function log($: EngineInterface, line: string) {
98 const prev = ((await $.store.get('log')) as string[] | undefined) ?? []
99 await $.store.set('log', [...prev, `${new Date(await $.clock.now()).toISOString()} ${line}`].slice(-100))
100}
101hooks/rules.ts 56 lines1// タイトル規約 (ippoan/claude-skills task-split §1)。claude-code-web-split-view の content.js と同じ判定。
2// 親: #p<issue> <題> → Opus (Fable でも可)
3// 子: [S]/[O] #p<issue>-c<番号>(-<n>) <題> (c の後は子 issue 番号でも分岐番号でもよい。-<n> は再起票の連番)
4// [S] = Sonnet、それ以外 (= 既定) は Opus
5// [旧] #p… は旧親 (何もしない)。規約外も触らない。旧形 #c<issue>-<n> は廃止 (#19) で規約外
6export type Family = 'sonnet' | 'opus'
7
8export const cleanTitle = (t: string | undefined): string =>
9 String(t ?? '').replace(/[\uE000-\uF8FF\u200B-\u200D\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim()
10
11export const expectedFamily = (title: string | undefined): Family | null => {
12 const s = cleanTitle(title)
13 const m0 = s.match(/^\[(S|O|旧)\]\s*/i)
14 const tag = m0?.[1]?.toUpperCase() ?? null
15 const rest = m0 ? s.slice(m0[0].length) : s
16 if (tag === '旧') return null
17 const tagged: Family = tag === 'S' ? 'sonnet' : 'opus'
18 if (/^#p\d+-c\d+(?:-\d+)?(\s|$)/.test(rest)) return tagged
19 if (/^#p\d+(\s|$)/.test(rest)) return 'opus'
20 if (/^#c\d/.test(rest)) return null // 廃止した旧形 (#19)。タグがあっても触らない
21 return tag ? tagged : null
22}
23
24// 期待どおりなら null、違えば差し替え先のモデル id
25const OK: Record<Family, RegExp> = { sonnet: /sonnet/i, opus: /opus|fable/i }
26const PICK: Record<Family, string> = { sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
27
28export const rewriteTo = (family: Family, model: string): string | null =>
29 OK[family].test(model) ? null : PICK[family]
30
31// 題とモデルから、切り替え先 (不要なら null) を出す
32export const planFor = (title: string | undefined, model: string): string | null => {
33 const family = expectedFamily(title)
34 return family === null ? null : rewriteTo(family, model)
35}
36
37// transcript (jsonl) の置き場: ~/.claude/projects/<cwd の / と . を - にしたもの>
38export const transcriptDir = (home: string, cwd: string): string =>
39 `${home}/.claude/projects/${cwd.replace(/[/.]/g, '-')}`
40
41// transcript の最後の assistant 応答のモデル。get_session の model は Remote Control 側の切り替えを拾わないので、こちらを正とする
42export const lastModel = (jsonl: string): string | undefined => {
43 const lines = jsonl.split('\n')
44 for (let i = lines.length - 1; i >= 0; i--) {
45 const line = lines[i]
46 if (!line?.includes('"assistant"')) continue
47 try {
48 const row = JSON.parse(line) as { type?: string; message?: { model?: string } }
49 if (row.type === 'assistant' && row.message?.model) return row.message.model
50 } catch {
51 // 書きかけの行は飛ばす
52 }
53 }
54 return undefined
55}
56