Claude の推論待ちのあいだ、Dots役(ChatGPT)と GrokBot役(Grok)が右のパネルで雑談する

Claude Code の Mod(10/1 公開の拡張機能)6本をまとめたリポジトリです。
ターミナルの Claude Code で、<mod> を下記の名前に置き換えて実行します。
/plugin install <mod> --marketplace marulimo/claude-code-mods
続けて y を入力し、インストール先のスコープを選びます。デスクトップアプリの Code タブではこのコマンドは使えないため、ターミナルでインストールしてください。user スコープに入れれば、デスクトップのセッションでも読み込まれます。
Mod の設定項目は各 mods/<mod>/.claude-plugin/plugin.json の userConfig に定義しています。実行コマンドは PATH 上で見つかる必要があります。別の場所にある場合は設定で実行ファイルのパスを指定してください。
右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送ります。送った分は Devin と Codex の利用枠で動き、Claude の使用量は使いません。単独のリポジトリ marulimo/ask-codex でも同じ版を公開しています。
/devin <質問>、/codex <依頼>、/codex-panel。workspace-write)、Codex の推論強度(high)、Codex のモデル(空なら CLI の既定)、codex の場所(空なら自動で探す)、Devin の強さ(high)、devin の場所(空なら自動で探す)、Devin を動かすフォルダー(空なら ~/.ask-codex/devin-chat)です。danger-full-access にすると、確認もサンドボックスもなしで作業フォルダーを書き換えます。Devin は中身の空な専用フォルダーで、その1回だけフォルダーの信頼確認を省いて動かします。失敗の記録は ~/.ask-codex/errors.log に残ります。Claude、Codex、Grok、Gemini、Devin の使用率とリセット時刻をパネルに表示します。Claude のセッション情報と外部 collector の結果を使います。
/usage-hub。起動時にもパネルを開き、5分おきに取得します。userConfig はありません。環境変数 AI_USAGE_NODE(既定 node)と AI_USAGE_DASH_DIR(既定は USERPROFILE または HOME の下の ai-usage-dashboard)を使います。node <AI_USAGE_DASH_DIR>/server.mjs --json codex,grok,gemini,devin,claude
JSON は次の形です。groups は必須配列で、グループには文字列の name と配列の rows が必要です。行には文字列の label と有限の数値または null の used が必要です。used は使用率の百分率です。
{
"groups": [
{
"name": "CODEX",
"rows": [
{ "label": "5時間", "used": 25, "reset": "2026-10-16T00:00:00Z" },
{ "label": "週次", "used": null, "state": "データなし" }
],
"stale": false,
"note": null
}
]
}
認識するグループ名は CLAUDE、CODEX、GROK、GEMINI、DEVIN です。省略可能な項目は、行の reset(日時文字列)、state(状態文字列)、star(真なら Claude の Fable 枠)、グループの stale(真なら古いデータ)、note(文字列)です。Claude の補完行は 5時間 と 週次 で始まるラベルを使います。取得に失敗したときは前回値を残すため、表示の失敗時刻も確認してください。
入力を送信すると、依頼に適した AI の判定結果をトーストとステータス行に表示します。Claude への送信は判定の完了を待ちません。
pythonPath(python)、routerScript(空)、backend(jev、または decisions)、privateFolders(空)、privateWords(空)です。routerScript が空なら実行せず、「判定スクリプト未設定」と表示します。privateFolders は除外する絶対パス、privateWords は除外する語句をカンマ区切りで指定します。フォルダー自身とその配下、語句の部分一致を大文字小文字を区別せず判定します。既定ではどちらも除外しません。呼び出しは次の形です。入力全文を標準入力から読み、12秒以内に終了コード 0 と標準出力の JSON を返す必要があります。
python <routerScript> --backend jev
backend を decisions にすると末尾の引数も decisions になります。成功結果の例です。
{
"ok": true,
"label": "候補A",
"entry": "作業の入口",
"top": [{ "id": "candidate_a", "p": 0.82 }, { "id": "candidate_b", "p": 0.11 }]
}
成功時は ok: true、文字列の label と entry、1件以上の top が必要です。p は 0〜1 の有限数なら百分率で表示します。2件目の id と p が有効なら次点も表示します。失敗時は {"ok":false,"reason":"service_unavailable"} の形を返せます。reason は英字で始まる英数字・アンダースコア80文字以内だけを表示します。それ以外は一般的な失敗表示になります。
Claude が考えている間、Dots 役(ChatGPT)と GrokBot 役(Grok)がパネルで交互に、いまの会話の話題について雑談します。直近の会話(最大8発言・1発言300字・合計1500字)、このターンの使用ツール名、直近の雑談を送り先に渡します。
privateWords の語句を含む発言は丸ごと省き、APIキー・トークン・メールアドレス・32文字以上の英数字列は伏せ字にします。/side-chat、/side-chat off、/side-chat on です。hermes_cli を読み込める Python / Hermes Agent です。Grok は xai-oauth 経由で X Premium の枠を使います。codexPath(codex)、gptModel(gpt-6.1-sol)、python(py)、pythonVersion(-3.13)、grokModel(grok-4.3)、startDelaySec(20秒)、gapSec(20秒)、maxLines(6)、privateFolders(空)、privateWords(空)です。Python ランチャーに版指定が不要なら pythonVersion を空にします。privateFolders は除外する絶対パスをカンマ区切りで指定します。設定したフォルダーとその配下では送りません。既定では除外しません。privateWords は雑談の材料に渡さない語句をカンマ区切りで指定します。大文字小文字を区別せず部分一致で判定します。既定では除外しません。off で止めてください。X のハッシュタグに合う投稿を探し、パネルに新着を表示します。タグを登録すると定期取得します。
/tag-feed #タグ #別のタグ、/tag-feed です。パネルには即時取得と停止・再開のボタンがあります。hermes_cli を読み込める Python / Hermes Agent と、Grok の xai-oauth ログインです。python(py)、pythonVersion(-3.13)、model(grok-4.3)、intervalMinutes(10分、最短2分)です。本文に登録タグを付け、ブラウザで X の投稿画面や返信画面を開きます。最後の「ポスト」は人が押します。この Mod が自分で投稿することはありません。
/x 本文、/x-reply <投稿URL> 本文、/x-tag [add|rm|clear] [タグ]、/x-mode on|off、/x-account @名前、/x-pane です。hermes)と Grok のログインが必要です。hermes が無くても投稿はできます。userConfig はありません。タグ、推論中だけ投稿可能にするモード、自分のアカウントを上記コマンドで保存します。アカウントの既定値は空で、未設定ならペインに /x-account @名前 で設定 と出ます。hermes のモデルとプロバイダーは環境変数 X_POST_HERMES_MODEL(既定 grok-4.3)と X_POST_HERMES_PROVIDER(既定 xai-oauth)で変えられます。rundll32、macOS では open、それ以外では xdg-open で開きます。開けなければコマンドが返すリンクから開いてください。デスクトップ版ではペインの幅を Mod から変えられないので、狭いときは境目をドラッグしてください。作者は Windows 11 で動かしています。他 OS は未確認です。Mod 対応の Claude Code が必要です。
MIT ライセンスです。詳しくは LICENSE を参照してください。
hooks/register.tsx 339 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Chat, Line, Speaker } from '../types'
5
6const PANE = 'side-chat'
7const DOCK_COLUMNS = 56
8const TICK_MS = 4_000
9const MAX_KEEP = 60
10
11const lines = atom({ plugin: 'side-chat', key: 'lines' } as const, [] as Line[])
12const chat = atom({ plugin: 'side-chat', key: 'chat' } as const, { isEnabled: true, isBlocked: false } as Chat)
13
14export type Config = {
15 codexPath: string
16 gptModel: string
17 python: string
18 pythonVersion: string
19 grokModel: string
20 startDelayMs: number
21 gapMs: number
22 maxLines: number
23 privateWords: string[]
24}
25
26const NAME: Record<Speaker, string> = { dots: 'Dots', grok: 'GrokBot' }
27
28// 外へ送るのは依頼文の先頭とツール名だけ。モジュール変数なので再読込で消えてよい
29let promptHead = ''
30let toolsSeen: string[] = []
31
32// 設定したフォルダーとその配下では外部に送らない
33export function isBlockedPath(cwd: string, folders: string[]): boolean {
34 const normalize = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
35 const path = normalize(cwd)
36 return folders.some(folder => path === normalize(folder) || path.startsWith(`${normalize(folder)}/`))
37}
38
39const SECRET_RES = [
40 /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{10,}/g,
41 /\b(?:ghp|gho|ghs|github_pat|xox[abprs]|AIza|ya29)[A-Za-z0-9_.-]{10,}/g,
42 /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
43 /[A-Za-z0-9+/_=-]{32,}/g,
44]
45
46// 設定した語句を含む発言は丸ごと送らない
47export function sanitize(text: string, privateWords: readonly string[] = []): string | undefined {
48 let s = text
49 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
50 .replace(/<[^>\n]{1,80}>/g, '')
51 .replace(/```[\s\S]*?```/g, '(コード省略)')
52 .replace(/\s+/g, ' ')
53 .trim()
54 if (!s) return undefined
55 const lower = s.toLowerCase()
56 if (privateWords.some(word => lower.includes(word.toLowerCase()))) return '(除外した話題なので省略)'
57 for (const re of SECRET_RES) s = s.replace(re, '[伏せ字]')
58 return s
59}
60
61const RECENT_MAX = 1500
62const MESSAGE_MAX = 300
63
64// 直近の会話を、新しい方から上限まで拾って古い順に並べる
65export function recentTalk(messages: readonly { role: string; text: string }[], privateWords: readonly string[] = []): string {
66 const out: string[] = []
67 let used = 0
68 for (let i = messages.length - 1; i >= 0 && out.length < 8; i--) {
69 const m = messages[i]!
70 const s = sanitize(m.text, privateWords)
71 if (!s) continue
72 const cut = s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX)}…` : s
73 const line = `${m.role === 'user' ? 'ユーザー' : 'Claude'}: ${cut}`
74 if (used + line.length > RECENT_MAX && out.length) break
75 out.unshift(line)
76 used += line.length
77 }
78 return out.join('\n')
79}
80
81export function nextSpeaker(chatNow: Chat, turnLines: Line[], now: number, cfg: Config): Speaker | undefined {
82 if (!chatNow.isEnabled || chatNow.isBlocked || !chatNow.turnId || chatNow.busy) return undefined
83 if (now - (chatNow.turnStartedAt ?? now) < cfg.startDelayMs) return undefined
84 if (turnLines.length >= cfg.maxLines) return undefined
85 const last = turnLines[turnLines.length - 1]
86 if (last && now - last.at < cfg.gapMs) return undefined
87 // 交互に話す。ターンの最初は前ターンの最後と逆側から
88 if (last) return last.who === 'dots' ? 'grok' : 'dots'
89 return turnLines.length % 2 === 0 ? 'dots' : 'grok'
90}
91
92const PERSONA: Record<Speaker, string> = {
93 dots:
94 'あなたは「Dots」。ユーザーの日常を支える穏やかな相棒(ChatGPT)。生活や体調、息抜きに目を向ける。',
95 grok:
96 'あなたは「GrokBot」。Xをよく見ている、ちょっと茶目っ気のある相棒(Grok)。軽いツッコミや小ネタが得意。',
97}
98
99export function buildPrompt(who: Speaker, talk: string, tools: string[], recent: Line[]): string {
100 const other = who === 'dots' ? 'GrokBot' : 'Dots'
101 const log = recent.map(l => `${NAME[l.who]}: ${l.text}`).join('\n')
102 return [
103 'ツール禁止・ファイル読取禁止・検索禁止。考えたことだけで答える。',
104 PERSONA[who],
105 `いま、ユーザーは Claude Code と作業の話をしていて、Claude の返事を待っている。あなたは右側の小窓で ${other} と雑談している。`,
106 talk ? `ユーザーと Claude の最近の会話(抜粋):\n${talk}` :'ユーザーと Claude の会話の中身は分からない。',
107 `Claude がこのターンで使った道具: ${tools.length ? tools.join('、') : 'まだなし'}`,
108 log ? `ここまでの雑談:\n${log}` : 'まだ誰も話していない。あなたから話し始める。',
109 `次のあなたの一言を書く。40字以内、1文か2文。会話の話題に触れた感想や軽いツッコミにする。${other}の発言があれば受けて返してよい。`,
110 `作業をしているのは Claude で、あなたと ${other} は横で見ているだけ。自分たちが作業したように言わない。`,
111 'ここまでの雑談と同じ小ネタや言い回しを繰り返さない。雑談の流れより、ユーザーと Claude の会話の話題を優先する。',
112 '会話に書かれていないことは決めつけない。説教や助言の押しつけはしない。名前の前置きや引用符は付けない。',
113 ].join('\n')
114}
115
116export function cleanReply(raw: string): string {
117 const one = raw
118 .split(/\r?\n/)
119 .map(s => s.trim())
120 .filter(Boolean)
121 .join(' ')
122 .replace(/^(Dots|GrokBot|Grok)\s*[::]\s*/i, '')
123 .replace(/^[「"']+|[」"']+$/g, '')
124 .trim()
125 return one.length > 90 ? `${one.slice(0, 89)}…` : one
126}
127
128async function say($: EngineInterface, cfg: Config, who: Speaker, prompt: string): Promise<string> {
129 const env = { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
130 if (who === 'dots') {
131 const r = await $.process.run(
132 [
133 cfg.codexPath, 'exec', '--skip-git-repo-check',
134 '-c', `model="${cfg.gptModel}"`, '-c', 'model_reasoning_effort=low',
135 '-s', 'read-only', '-',
136 ],
137 { stdin: prompt, timeoutMs: 90_000, env },
138 )
139 if (r.exitCode !== 0) throw new Error(`codex exit ${r.exitCode}: ${r.stderr.slice(-300)}`)
140 return r.stdout
141 }
142 const code =
143 'import sys\n' +
144 'from hermes_cli.main import main\n' +
145 `sys.argv=['hermes','-z',sys.argv[1],'-m',sys.argv[2],'--provider','xai-oauth']\n` +
146 'main()\n'
147 const r = await $.process.run(
148 [cfg.python, ...(cfg.pythonVersion ? [cfg.pythonVersion] : []), '-c', code, prompt, cfg.grokModel],
149 { timeoutMs: 90_000, env },
150 )
151 if (r.exitCode !== 0) throw new Error(`hermes exit ${r.exitCode}: ${r.stderr.slice(-300)}`)
152 return r.stdout
153}
154
155export async function tick($: EngineInterface, cfg: Config) {
156 const c = await read($, chat)
157 const all = await read($, lines)
158 const turnLines = all.filter(l => l.turnId === c.turnId)
159 const now = await $.clock.now()
160 const who = nextSpeaker(c, turnLines, now, cfg)
161 if (!who) return
162 const turnId = c.turnId!
163 await update($, chat, x => ({ ...x, busy: who, error: undefined }))
164 try {
165 let talk = ''
166 try {
167 talk = recentTalk(await $.session.messages(), cfg.privateWords)
168 } catch {
169 // 履歴が読めないときは依頼文の冒頭だけで話す
170 }
171 if (!talk && promptHead) talk = recentTalk([{ role: 'user', text: promptHead }], cfg.privateWords)
172 // 前のターンの雑談は2発言だけ持ち込む。多いと同じ小ネタを引きずる
173 const recent = [...all.filter(l => l.turnId !== turnId).slice(-2), ...turnLines].slice(-6)
174 const text = cleanReply(await say($, cfg, who, buildPrompt(who, talk, toolsSeen, recent)))
175 if (text) {
176 const at = await $.clock.now()
177 await update($, lines, list => [...list, { id: `${turnId}-${at}-${who}`, who, text, at, turnId }].slice(-MAX_KEEP))
178 }
179 } catch (err) {
180 await update($, chat, x => ({ ...x, error: `${NAME[who]}: ${String(err).slice(0, 200)}` }))
181 } finally {
182 await update($, chat, x => ({ ...x, busy: undefined }))
183 }
184}
185
186function openPane($: EngineInterface, focus?: boolean) {
187 return $.ui.open({ id: PANE, title: '待ち時間の雑談', columns: DOCK_COLUMNS, ...(focus ? { focus } : {}) })
188}
189
190async function setEnabled($: EngineInterface, on: boolean) {
191 await update($, chat, x => ({ ...x, isEnabled: on }))
192 await $.store.set('enabled', on)
193}
194
195function fmtWait(ms: number): string {
196 const s = Math.max(0, Math.floor(ms / 1000))
197 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
198}
199
200let timer: Timer | undefined
201
202export const register: Register = (on, options) => {
203 const cfg: Config = {
204 codexPath: String(options.codexPath || 'codex'),
205 gptModel: String(options.gptModel || 'gpt-6.1-sol'),
206 python: String(options.python || 'py'),
207 pythonVersion: String(options.pythonVersion ?? ''),
208 grokModel: String(options.grokModel || 'grok-4.3'),
209 startDelayMs: Math.max(5, Number(options.startDelaySec) || 20) * 1000,
210 gapMs: Math.max(5, Number(options.gapSec) || 20) * 1000,
211 maxLines: Math.max(1, Number(options.maxLines) || 6),
212 privateWords: String(options.privateWords || '').split(',').map(s => s.trim()).filter(Boolean),
213 }
214
215 const privateFolders = String(options.privateFolders || '').split(',').map(s => s.trim()).filter(Boolean)
216
217 on('session.start', async ($, e, next) => {
218 const res = await next(e)
219 await $.command.register({
220 name: 'side-chat',
221 description: '推論待ちの雑談パネル。on / off で切り替え、引数なしでパネルを開く',
222 argumentHint: '[on|off]',
223 })
224 const stored = await $.store.get('enabled')
225 const cwd = String(e.cwd ?? (await $.session.cwd()) ?? '')
226 await update($, chat, x => ({
227 ...x,
228 isEnabled: stored !== false,
229 isBlocked: isBlockedPath(cwd, privateFolders),
230 turnId: undefined,
231 busy: undefined,
232 }))
233 timer?.cancel()
234 timer = $.clock.every(TICK_MS, () => void tick($, cfg))
235 void openPane($)
236 return res
237 })
238
239 on('session.end', async ($, e, next) => {
240 timer?.cancel()
241 return next(e)
242 })
243
244 on('turn.start', async ($, e, next) => {
245 const c = await read($, chat)
246 // 実行中のターンの内側で始まったもの(サブエージェント等)は数えない
247 if (!c.turnId) {
248 promptHead = e.text.replace(/\s+/g, ' ').trim().slice(0, 200)
249 toolsSeen = []
250 const now = await $.clock.now()
251 await update($, chat, x => ({ ...x, turnId: e.turnId, turnStartedAt: now }))
252 }
253 return next(e)
254 })
255
256 on('turn.complete', async ($, e, next) => {
257 const c = await read($, chat)
258 if (c.turnId === e.turnId) await update($, chat, x => ({ ...x, turnId: undefined, turnStartedAt: undefined }))
259 return next(e)
260 })
261
262 on('tool.call', async ($, e, next) => {
263 try {
264 const name = String(e.tool)
265 if (!toolsSeen.includes(name)) toolsSeen = [...toolsSeen, name].slice(-10)
266 } catch {
267 // 記録に失敗してもツール呼び出しは止めない
268 }
269 return next(e)
270 })
271
272 on('command.run', { command: 'side-chat' }, async ($, e) => {
273 const arg = e.args.trim().toLowerCase()
274 if (arg === 'off') {
275 await setEnabled($, false)
276 return { text: '雑談を止めました。/side-chat on で再開します。' }
277 }
278 if (arg === 'on') await setEnabled($, true)
279 await openPane($, true)
280 const c = await read($, chat)
281 if (c.isBlocked) return { text: '除外設定されたフォルダーの作業なので、このセッションでは雑談を送りません。' }
282 return { text: arg === 'on' ? '雑談を再開しました。' : 'パネルを開きました。' }
283 })
284
285 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286 const { Box, Text, Button } = $.ui.resolve(e) as any
287 const c = await read($, chat)
288 const all = await read($, lines)
289 const now = await $.clock.now()
290 const room = e.surface === 'terminal' ? Math.max(4, Math.floor(((e.viewport?.rows ?? 30) - 6) / 2)) : 40
291 const shown = all.slice(-room)
292
293 const state = c.isBlocked
294 ? '除外設定されたフォルダーの作業なので送らない'
295 : !c.isEnabled
296 ? '停止中'
297 : c.busy
298 ? `${NAME[c.busy]} が考え中…`
299 : c.turnId
300 ? `Claude 考え中 ${fmtWait(now - (c.turnStartedAt ?? now))}`
301 : '待機中(Claude が考え始めたら話します)'
302
303 let lastTurn = ''
304 return (
305 <Box flexDirection="column" gap={1}>
306 <Box flexDirection="column">
307 <Text dimColor>{state}</Text>
308 {c.error ? <Text color="red">{c.error}</Text> : null}
309 <Box flexDirection="row" gap={1}>
310 <Button
311 key="toggle"
312 label={c.isEnabled ? '止める' : '再開'}
313 onPress={() => void setEnabled($, !c.isEnabled)}
314 />
315 <Button key="clear" label="消す" onPress={() => void update($, lines, () => [])} />
316 </Box>
317 </Box>
318 {shown.length === 0 ? <Text dimColor>まだ誰も話していません。</Text> : null}
319 <Box flexDirection="column">
320 {shown.map(l => {
321 const sep = lastTurn && l.turnId !== lastTurn
322 lastTurn = l.turnId
323 return (
324 <Box key={l.id} flexDirection="column" marginTop={sep ? 1 : 0}>
325 <Text>
326 <Text bold color={l.who === 'dots' ? 'cyan' : 'magenta'}>
327 {NAME[l.who]}
328 </Text>
329 <Text> {l.text}</Text>
330 </Text>
331 </Box>
332 )
333 })}
334 </Box>
335 </Box>
336 )
337 })
338}
339types/index.d.ts 25 lines1export type Speaker = 'dots' | 'grok'
2
3export type Line = {
4 id: string
5 who: Speaker
6 text: string
7 at: number
8 turnId: string
9}
10
11export type Chat = {
12 isEnabled: boolean
13 isBlocked: boolean
14 turnId?: string
15 turnStartedAt?: number
16 busy?: Speaker
17 error?: string
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'side-chat': { lines: Line[]; chat: Chat }
23 }
24}
25