SLOPSHOPPER

model-by-title

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

newtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-by-title
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ model-by-title: model-by-title
README

Claude Code Web Split View

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 秒ごとにペイン側から更新される。

親子グループ (ippoan/claude-skills の task-split 命名規約)

タイトル役期待するモデル
#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 の対象から外れる。

  • 子を右に開く: 親をメイン (またはペイン) で開いている間、サイドバーにあるその issue の子のうち ペインに出ていないものを自動で開く (起動時点で既にある子も、あとから spawn されて現れた子も)。 × で閉じた子だけは、そのページを開いている間は開き直さない。リロードすれば再び開く
  • ⊞ (親の行): 親と子をまとめて手で右に開く
  • モデルの強制: セッション画面 (メインもペインも) を開いたとき、composer の「モデル: …」が 上の期待と違えば、モデルメニューから切り替える ([S] なのに Opus → Sonnet、親なのに Sonnet → Opus)。 規約に合わないタイトルは触らない。題や composer がまだ描画されていない回は何も決めず、開いた直後の 30 秒は 1 秒おき、その後は 15 秒おきに、決着 (期待どおり / 切り替えた / 規約外) がつくまで見直す。 決着後は手で変えたものを尊重する
ブラウザで開かなくても直す — Claude Code の mod 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 のチップから起動した子は入らない)
  • 今のモデルは子の transcript の最後の応答から読む。デスクトップアプリの get_session の model は claude.ai 側 (Remote Control) での切り替えを拾わず、古い値のまま残ることがある
  • 判定は子 1 つにつき 1 回 (期待どおり / 切り替えた / 失敗)。以後は手で変えたものを尊重する。 結果はトーストと plugin の $.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> の中身 (⧉) のみ。

インストール

非管理端末 (既定、admin 不要)

  1. Releases から claude-code-web-split-view-*-x64.msi を落として実行 (ダブルクリックで可)。 %LOCALAPPDATA%\Programs\claude-code-web-split-view\extension に配置され、 native messaging host (ボタン更新の実体) が HKCU に登録される
  2. chrome://extensions → 右上「デベロッパー モード」ON → 「パッケージ化されていない拡張機能を読み込む」→ 上のフォルダ
  3. ID が ibdmdncjfdpdmcakmdbdohieahjlkhoa になっていることを確認。claude.ai/code を開き直す

MSI を使わない場合は claude-code-web-split-view-extension.zip を展開して読み込む (この場合 native host が無いので、更新は新しい zip を同じフォルダに上書き展開して拡張カードの ↻)。

管理端末 (AD / Entra / Chrome Enterprise Core)

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 段で自前に組む:

  1. installer/update.ps1 が Release の update.xml の版を見て、新しければ claude-code-web-split-view-extension.zip を落とし、sha256 を照合して extension\ を差し替える
  2. 拡張の service worker が 10 分ごとに「ディスク上の manifest.json の版」と「動いている版」を比べ、 違えば 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)。

診断 (claude.ai のタブから)

拡張は 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 から配る

既知の制約

  • ペイン 1 枚ごとに claude.ai への接続 (WebSocket / SSE) とメモリが増える
  • ペインのタイトルは開いた時点のサイドバーの文字列。ペイン内で別セッションへ移ると サイドバーから引き直す (見つからなければパス表示)
  • iframe 内にサイドバーが出ない挙動は claude.ai 側のもので、変わったら CSS で隠す必要がある
  • desktop 版の「ドラッグで横並び」ジェスチャは未実装 (Ctrl/⌘+クリックと ⧉ のみ)
  • native host 経由のボタン更新 (ツールバーのアイコン → update.ps1) は Windows 実機で未確認 (MSI の配置と拡張の読み込み・分割画面そのものは v0.0.1 で確認済み。下記)

実機確認 (v0.0.1、Windows 11 + Chrome、2026-09-10)

  • MSI (perUser) を実行 → %LOCALAPPDATA%\Programs\claude-code-web-split-view\extension を 「パッケージ化されていない拡張機能を読み込む」で読み込み、ID が ibdmdncjfdpdmcakmdbdohieahjlkhoa で一致
  • claude.ai/code を開くとサイドバーの全セッション行に ⧉ が付く
  • セッション行を Ctrl+クリック → 右にペインが開き、#root が 1920px → 960px に縮む。 iframe 内は content script が入り (html[data-ccw-pane])、composer 付きでサイドバー無し
  • ⧉ で 2 枚目 → 2 ペインが等幅で並ぶ (各 477px)。ストリーミング中のセッションもそのまま流れる
  • リロード → 2 ペインとも復元。× で閉じると #root が 1920px に戻る
  • 未署名 MSI なので初回実行時に SmartScreen (「Windows によって PC が保護されました」) が出る。 「詳細情報」→「実行」で通る (gh-actions-live の MSI と同じ)
Source 2 files
hooks/register.ts 101 lines
1import 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}
101
hooks/rules.ts 56 lines
1// タイトル規約 (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