Claude の使用量を使わずに、Devin SWE-2 への相談や Codex への作業依頼を右のパネルから送れる Claude Code の Mod

Claude Code の右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送る Mod です。 送った分は Devin と Codex の利用枠で動くので、Claude の使用量は使いません。
codex exec)に今の作業フォルダーで作業を頼む。既定は作業フォルダーの中だけ書き換えられる権限返答はパネルに Markdown で出ます。 「コピー」と「Claudeの入力欄へ」で、返答をそのまま次に使えます。
npm i -g @openai/codex のあと codex login)devin models list に SWE-2 が出ること)Claude Code のターミナルで次の1行を打ちます。
/plugin install ask-codex --marketplace marulimo/ask-codex
Add marketplace? には y、入れる範囲は user(すべてのセッション)を選びます。 入れたあと新しいセッションを始めると、右側に「Devin / Codex」のパネルが開きます。
/devin <質問>、/codex <依頼>、/codex-panel(パネルを開く)/config の ask-codex の行で変えられます。
| 項目 | 既定 | 内容 |
|---|---|---|
| Codex の権限 | workspace-write | danger-full-access にすると確認もサンドボックスもなしで動く。自分の環境だけで使うこと |
| Codex の推論強度 | high | パネルで送り先ごとに変えられる |
| Codex のモデル | 空 | 空なら Codex CLI の既定モデル |
| codex の場所 | 空 | 空なら自動で探す |
| Devin の強さ | high | SWE-2 の medium / high / max |
| devin の場所 | 空 | 空なら自動で探す |
| Devin を動かすフォルダー | 空 | 空なら ~/.ask-codex/devin-chat |
--respect-workspace-trust false を付けて、その1回だけフォルダーの信頼確認を省いて動かします。動かす場所は中身の空な専用フォルダーですdanger-full-access にすると、今の作業フォルダーのファイルを確認なしで書き換え、コマンドも実行します。git で戻せる状態で使ってください~/.ask-codex/errors.log に残りますA Claude Code mod that adds a right-side pane for sending prompts to Devin SWE-2 (chat only, tools forbidden, run in an empty folder) and Codex CLI (works in your current folder; workspace-write by default, danger-full-access opt-in). Answers render as Markdown in the pane, and nothing is sent to Claude unless you press "Claudeの入力欄へ". Install with /plugin install ask-codex --marketplace marulimo/ask-codex. The UI and the chat guard are in Japanese.
MIT
hooks/register.tsx 409 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mode, Run } from '../types'
5
6const PANE = 'ask-codex'
7const runs = atom({ plugin: 'ask-codex', key: 'runs' } as const, [] as Run[])
8const mode = atom({ plugin: 'ask-codex', key: 'mode' } as const, 'devin' as Mode)
9// パネルで選んだ推論強度。未選択の送り先は設定の既定値
10const efforts = atom({ plugin: 'ask-codex', key: 'effort' } as const, {} as Partial<Record<Mode, string>>)
11
12const EFFORTS: Record<Mode, string[]> = {
13 devin: ['medium', 'high', 'max'],
14 codex: ['low', 'medium', 'high', 'xhigh'],
15}
16
17// /devin は相談だけ。ファイルやシェルに手を出さないよう冒頭で縛る
18const CHAT_GUARD =
19 'これは相談です。ツール禁止・ファイル読取禁止・コマンド実行禁止。質問にそのまま日本語で答えてください。\n\n'
20
21const LABEL: Record<Mode, string> = { devin: 'Devin', codex: 'Codex' }
22
23type Sandbox = 'workspace-write' | 'danger-full-access'
24
25type Config = {
26 codexPath: string
27 model: string
28 codexSandbox: Sandbox
29 devinPath: string
30 devinDir: string
31 effort: Record<Mode, string>
32}
33
34type Paths = { codex: string; devin: string; devinDir: string; errorLog: string }
35
36// 実行中の子プロセス。止めるボタンが return() で殺す
37const live = new Map<string, AsyncGenerator<unknown, unknown>>()
38let resolved: Paths | undefined
39
40async function exists($: EngineInterface, path: string) {
41 try {
42 await $.fs.stat(path)
43 return true
44 } catch {
45 return false
46 }
47}
48
49// Windows の npm は codex.cmd を置くが、シェルなしでは起動できない。隣の node_modules から本体の exe を探す
50async function findCodexOnWindows($: EngineInterface): Promise<string | undefined> {
51 try {
52 const { stdout } = await $.process.run(['where.exe', 'codex'])
53 for (const line of stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
54 if (/\.exe$/i.test(line)) return line
55 const dir = line.replace(/[\\/][^\\/]+$/, '')
56 for (const [pkg, triple] of [
57 ['codex-win32-x64', 'x86_64-pc-windows-msvc'],
58 ['codex-win32-arm64', 'aarch64-pc-windows-msvc'],
59 ] as const) {
60 const exe = `${dir}/node_modules/@openai/codex/node_modules/@openai/${pkg}/vendor/${triple}/bin/codex.exe`
61 if (await exists($, exe)) return exe
62 }
63 }
64 } catch {
65 // where.exe が無い、または codex が PATH に無い
66 }
67 return undefined
68}
69
70async function resolvePaths($: EngineInterface, cfg: Config): Promise<Paths> {
71 if (resolved) return resolved
72 const windows = (await $.env.get('OS')) === 'Windows_NT'
73 const home = (windows ? await $.env.get('USERPROFILE') : await $.env.get('HOME')) ?? '.'
74 const base = `${home}/.ask-codex`
75
76 const codex = cfg.codexPath || (windows ? ((await findCodexOnWindows($)) ?? 'codex') : 'codex')
77
78 let devin = cfg.devinPath || 'devin'
79 if (!cfg.devinPath && windows) {
80 const local = await $.env.get('LOCALAPPDATA')
81 const exe = local ? `${local}/devin/cli/bin/devin.exe` : ''
82 if (exe && (await exists($, exe))) devin = exe
83 }
84
85 // Devin は中身の空な専用フォルダーで動かす
86 const devinDir = cfg.devinDir || `${base}/devin-chat`
87 await $.fs.write(`${devinDir}/.keep`, '').catch(() => undefined)
88
89 resolved = { codex, devin, devinDir, errorLog: `${base}/errors.log` }
90 return resolved
91}
92
93// 信頼確認はその1回だけ省き、auto は読み取り系ツールだけ通す
94function devinArgv(paths: Paths, run: Run): string[] {
95 return [
96 paths.devin, '--model', `swe-2-${run.effort}`, '--permission-mode', 'auto',
97 '--respect-workspace-trust', 'false', '-p', CHAT_GUARD + run.prompt,
98 ]
99}
100
101function codexArgv(cfg: Config, paths: Paths, run: Run, cwd: string): string[] {
102 const access =
103 cfg.codexSandbox === 'danger-full-access'
104 ? ['--dangerously-bypass-approvals-and-sandbox']
105 : ['-s', 'workspace-write', '-c', 'approval_policy="never"']
106 return [
107 paths.codex, 'exec', '--skip-git-repo-check', '-C', cwd,
108 ...(cfg.model ? ['-c', `model="${cfg.model}"`] : []),
109 '-c', `model_reasoning_effort=${run.effort}`,
110 ...access, '-',
111 ]
112}
113
114function patch($: EngineInterface, id: string, fields: Partial<Run>) {
115 return update($, runs, list => list.map(r => (r.id === id ? { ...r, ...fields } : r)))
116}
117
118async function watch($: EngineInterface, cfg: Config, run: Run, cwd: string) {
119 let stdout = ''
120 let stderrTail = ''
121 let code: number | null = null
122 try {
123 const paths = await resolvePaths($, cfg)
124 const child = $.process.spawn(
125 run.mode === 'devin'
126 ? { argv: devinArgv(paths, run), cwd: paths.devinDir }
127 : { argv: codexArgv(cfg, paths, run, cwd), input: run.prompt },
128 )
129 live.set(run.id, child)
130 while (true) {
131 const step = await child.next()
132 if (step.done) {
133 code = step.value.code
134 break
135 }
136 const { stream, text } = step.value
137 if (stream === 'stdout') {
138 stdout += text
139 continue
140 }
141 stderrTail = (stderrTail + text).slice(-4000)
142 const sid = stderrTail.match(/session id: ([0-9a-f-]+)/)?.[1]
143 const last = stderrTail
144 .split('\n')
145 .map(s => s.trim())
146 .filter(s => s && !s.startsWith('warning:') && !s.includes('is ignored'))
147 .pop()
148 await patch($, run.id, { progress: last?.slice(0, 200), ...(sid ? { sessionId: sid } : {}) })
149 }
150 } catch (err) {
151 stderrTail += `\n${String(err)}`
152 } finally {
153 live.delete(run.id)
154 }
155
156 const now = (await read($, runs)).find(r => r.id === run.id)
157 if (now?.status === 'cancelled') return
158 const ok = code === 0 && stdout.trim() !== ''
159 await patch($, run.id, {
160 status: ok ? 'done' : 'error',
161 endedAt: await $.clock.now(),
162 answer: ok ? stdout.trim() : `exit ${code}\n\n${stderrTail.slice(-1500)}`,
163 progress: undefined,
164 })
165 if (!ok) noteError($, `run ${run.mode} exit=${code}`, stderrTail.slice(-1500))
166 $.ui.toast(`${LABEL[run.mode]} ${ok ? 'の返答が届きました' : 'がエラーで終了しました'}`)
167}
168
169async function send($: EngineInterface, cfg: Config, m: Mode, prompt: string) {
170 const id = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
171 const cwd = await $.session.cwd()
172 const effort = (await read($, efforts))[m] ?? cfg.effort[m]
173 const run: Run = { id, mode: m, effort, prompt, status: 'running', startedAt: await $.clock.now() }
174 await update($, runs, list => [run, ...list].slice(0, 30))
175 void openPane($)
176 // dispatch を越えて走らせる。ループが子プロセスの寿命
177 void watch($, cfg, run, cwd)
178}
179
180// 右のドックに出たときの幅(文字数)。置き場所そのものは表示側が決める
181const DOCK_COLUMNS = 64
182
183function openPane($: EngineInterface, focus?: true) {
184 return $.ui.open({ id: PANE, title: 'Devin / Codex', columns: DOCK_COLUMNS, ...(focus ? { focus } : {}) })
185}
186
187async function cancel($: EngineInterface, id: string) {
188 await patch($, id, { status: 'cancelled', endedAt: await $.clock.now(), progress: undefined })
189 await live.get(id)?.return(undefined)
190}
191
192function fillClaude($: EngineInterface, r: Run) {
193 return $.prompt.fill({
194 text: `${LABEL[r.mode]} の返答:\n${(r.answer ?? '').replace(/^/gm, '> ')}\n\n`,
195 mode: 'insert',
196 })
197}
198
199function copyAnswer($: EngineInterface, r: Run, surface: string) {
200 return $.ui.copy({ text: r.answer ?? '', surface: surface as never })
201}
202
203// 長い文や複数行の文は Claude の入力欄で書いて、そのまま送る(Claude は呼ばない)
204async function sendComposer($: EngineInterface, cfg: Config, m: Mode) {
205 const { text } = await $.prompt.read()
206 if (!text.trim()) {
207 $.ui.toast('Claudeの入力欄が空です')
208 return
209 }
210 await send($, cfg, m, text.trim())
211 await $.prompt.fill({ text: '', mode: 'replace' })
212}
213
214function pickEffort($: EngineInterface, m: Mode, v: string) {
215 return update($, efforts, map => ({ ...map, [m]: v }))
216}
217
218function pickMode($: EngineInterface, v: string) {
219 return update($, mode, () => v as Mode)
220}
221
222function removeRun($: EngineInterface, id: string) {
223 return update($, runs, l => l.filter(x => x.id !== id))
224}
225
226// 失敗の理由を ~/.ask-codex/errors.log に残す。置き場所が決まる前の失敗は残さない
227function noteError($: EngineInterface, step: string, err: unknown) {
228 const log = resolved?.errorLog
229 if (!log) return
230 const line = `${new Date().toISOString()} ${step}: ${String(err)}\n`
231 void $.fs
232 .read(log)
233 .catch(() => '')
234 .then(prev => $.fs.write(log, String(prev).slice(-8000) + line))
235 .catch(() => undefined)
236}
237
238async function registerCommands($: EngineInterface) {
239 for (const spec of [DEVIN_COMMAND, CODEX_COMMAND, PANEL_COMMAND]) {
240 try {
241 await $.command.register(spec)
242 } catch (err) {
243 noteError($, `command.register /${spec.name}`, err)
244 }
245 }
246}
247
248const CODEX_COMMAND = {
249 name: 'codex',
250 description: 'Codex に作業を頼む。今の作業フォルダーで動く',
251 argumentHint: '<プロンプト>',
252}
253const DEVIN_COMMAND = {
254 name: 'devin',
255 description: 'Devin SWE-2 にチャットで質問する。ツールは使わせない',
256 argumentHint: '<プロンプト>',
257}
258
259const PANEL_COMMAND = { name: 'codex-panel', description: 'Devin / Codex のパネルを開く' }
260
261export const register: Register = (on, options) => {
262 const cfg: Config = {
263 codexPath: String(options.codexPath ?? ''),
264 model: String(options.model ?? ''),
265 codexSandbox: options.codexSandbox === 'danger-full-access' ? 'danger-full-access' : 'workspace-write',
266 devinPath: String(options.devinPath ?? ''),
267 devinDir: String(options.devinDir ?? ''),
268 effort: {
269 codex: String(options.codexEffort),
270 devin: String(options.devinEffort),
271 },
272 }
273 const codexNote =
274 cfg.codexSandbox === 'danger-full-access'
275 ? 'Codex は今の作業フォルダーを確認なしで全権限で書き換えます'
276 : 'Codex は今の作業フォルダーの中だけ書き換えます'
277
278 on('session.start', async ($, e, next) => {
279 // 1つ失敗しても残りとセッション開始は続ける。理由はファイルに残す
280 await resolvePaths($, cfg).catch(() => undefined)
281 await registerCommands($)
282 const started = await next(e)
283 // 起動時に開いておく。ドックできる画面では右のサイドバーになる
284 void openPane($).catch(err => noteError($, 'ui.open', err))
285 return started
286 }).catch(($, e, next) => next(e))
287
288 for (const m of ['devin', 'codex'] as const) {
289 on('command.run', { command: m }, async ($, e) => {
290 const prompt = e.args.trim()
291 if (!prompt) {
292 await pickMode($, m)
293 await openPane($, true)
294 return { text: `パネルを開きました。${LABEL[m]} に送る文を入力してください。` }
295 }
296 await send($, cfg, m, prompt)
297 return { text: `${LABEL[m]} に送信しました。返答はパネルに出ます。` }
298 })
299 }
300
301 on('command.run', { command: 'codex-panel' }, async $ => {
302 await openPane($, true)
303 return { text: 'Devin / Codex のパネルを開きました。' }
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const ui = $.ui.resolve(e) as any
308 const { Box, Text, Button, Input, Select, Markdown } = ui
309 const list = await read($, runs)
310 const stored = await read($, mode)
311 const current: Mode = stored in LABEL ? stored : 'devin'
312 const effort = (await read($, efforts))[current] ?? cfg.effort[current]
313 const now = await $.clock.now()
314 const secs = (r: Run) => Math.round(((r.endedAt ?? now) - r.startedAt) / 1000)
315 const mark = { running: '実行中', done: '完了', error: 'エラー', cancelled: '中止' }
316
317 return (
318 <Box flexDirection="column" gap={1}>
319 <Box flexDirection="column">
320 <Box flexDirection="row" gap={1}>
321 <Button
322 key="mode-devin"
323 label="Devin(相談)"
324 variant={current === 'devin' ? 'primary' : 'secondary'}
325 dimColor={current !== 'devin'}
326 onPress={() => pickMode($, 'devin')}
327 />
328 <Button
329 key="mode-codex"
330 label="Codex(作業)"
331 variant={current === 'codex' ? 'primary' : 'secondary'}
332 dimColor={current !== 'codex'}
333 onPress={() => pickMode($, 'codex')}
334 />
335 </Box>
336 {current === 'codex' ? <Text dimColor>{codexNote}</Text> : null}
337 {Select ? (
338 <Select
339 key="effort"
340 label="推論"
341 value={effort}
342 options={EFFORTS[current].map(v => ({ value: v, label: v }))}
343 onSelect={(v: string) => pickEffort($, current, v)}
344 />
345 ) : null}
346 {Input ? (
347 <Input
348 key="prompt"
349 label={`${LABEL[current]}に質問`}
350 placeholder="ここに書いて Enter(コマンドは不要)"
351 submitLabel="送信"
352 autoFocus
353 value=""
354 onSubmit={(v: string) => {
355 if (v.trim()) void send($, cfg, current, v.trim())
356 }}
357 />
358 ) : (
359 <Text dimColor>この画面では入力欄が使えません。/devin /codex で送ってください。</Text>
360 )}
361 <Box flexDirection="row">
362 <Button
363 key="send-composer"
364 label="Claudeの入力欄の文を送る"
365 dimColor
366 onPress={() => sendComposer($, cfg, current)}
367 />
368 </Box>
369 </Box>
370 {list.length === 0 && <Text dimColor>まだ送信していません。</Text>}
371 {list.map(r => (
372 <Box key={r.id} flexDirection="column">
373 <Text bold>
374 [{LABEL[r.mode]} {r.effort}] {mark[r.status]} {secs(r)}秒
375 {r.sessionId ? ` ${r.sessionId.slice(0, 8)}` : ''}
376 </Text>
377 <Text dimColor>{r.prompt.length > 300 ? `${r.prompt.slice(0, 300)}…` : r.prompt}</Text>
378 {r.status === 'running' && r.progress ? <Text dimColor>{r.progress}</Text> : null}
379 {r.answer ? <Markdown key={`a-${r.id}`} text={r.answer} /> : null}
380 <Box flexDirection="row" gap={1}>
381 {r.status === 'running' ? (
382 <Button key={`stop-${r.id}`} label="止める" onPress={() => cancel($, r.id)} />
383 ) : null}
384 {r.answer ? (
385 <Button
386 key={`copy-${r.id}`}
387 label="コピー"
388 onPress={(pe: { surface: string }) => copyAnswer($, r, pe?.surface ?? e.surface)}
389 />
390 ) : null}
391 {r.answer ? (
392 <Button
393 key={`claude-${r.id}`}
394 label="Claudeの入力欄へ"
395 variant="primary"
396 onPress={() => fillClaude($, r)}
397 />
398 ) : null}
399 {r.status !== 'running' ? (
400 <Button key={`del-${r.id}`} label="消す" dimColor onPress={() => removeRun($, r.id)} />
401 ) : null}
402 </Box>
403 </Box>
404 ))}
405 </Box>
406 )
407 })
408}
409types/index.d.ts 21 lines1export type Mode = 'devin' | 'codex'
2
3export type Run = {
4 id: string
5 mode: Mode
6 effort: string
7 prompt: string
8 status: 'running' | 'done' | 'error' | 'cancelled'
9 startedAt: number
10 endedAt?: number
11 answer?: string
12 progress?: string
13 sessionId?: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'ask-codex': { runs: Run[]; mode: Mode; effort: Partial<Record<Mode, string>> }
19 }
20}
21