SLOPSHOPPER

ask-codex

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

newpanecommandtoastprocess
v1.0.0MITupdated 2026-10-15marulimo/ask-codex
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ask-codex
│ ┃ Devin / Codex ✕ › fix the failing auth test and add an audit log call │ ┃ [ Devin(相談) ] [ Codex(作業) ] │ ┃ Codex ⏺ Read(src/auth.ts) │ ┃ は今の作業フォルダーの中だけ書き換えます ⎿ Read 6 lines │ ┃ 推論: high ▾ ⏺ Update(src/auth.ts) │ ┃ Codexに質問: ここに書いて Enter(コマンドは不 ⎿ Added 2 lines, removed 1 line │ ┃ [ Claudeの入力欄の文を送る ] ⏺ 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 │ │ › /devin │ ⎿ ask-codex: パネルを開きました。Devin に送る文を入力してください。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Devin / Codex
[ Devin(相談) ] [ Codex(作業) ] Codex は今の作業フォルダーの中だけ書き換えます 推論: high ▾ Codexに質問: ここに書いて Enter(コマンドは不要) ⏎ 送信 [ Claudeの入力欄の文を送る ] まだ送信していません。
README

ask-codex

Claude Code の右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送る Mod です。 送った分は Devin と Codex の利用枠で動くので、Claude の使用量は使いません。

  • Devin(相談): Devin CLI の SWE-2 に質問する。冒頭に「ツール禁止・ファイル読取禁止」を付け、中身の空な専用フォルダーで動かす
  • Codex(作業): Codex CLI(codex exec)に今の作業フォルダーで作業を頼む。既定は作業フォルダーの中だけ書き換えられる権限

返答はパネルに Markdown で出ます。 「コピー」と「Claudeの入力欄へ」で、返答をそのまま次に使えます。

必要なもの

  • Claude Code 2.1.295 以降(Mod の仕組みが入っている版)
  • 使う送り先の CLI(片方だけでも動きます)
  • Codex CLI(npm i -g @openai/codex のあと codex login)
  • Devin CLI(ログイン済みで devin models list に SWE-2 が出ること)

入れ方

Claude Code のターミナルで次の1行を打ちます。

/plugin install ask-codex --marketplace marulimo/ask-codex

Add marketplace? には y、入れる範囲は user(すべてのセッション)を選びます。 入れたあと新しいセッションを始めると、右側に「Devin / Codex」のパネルが開きます。

使い方

  • パネル上部の「Devin(相談)」「Codex(作業)」で送り先を選び、入力欄に書いて Enter
  • 「推論」で強さを選ぶ(Devin は medium / high / max、Codex は low〜xhigh)。送り先ごとに覚えます
  • 複数行の長い文は Claude の入力欄に書いて「Claudeの入力欄の文を送る」を押す。Claude には送られません
  • コマンドでも送れます: /devin <質問>、/codex <依頼>、/codex-panel(パネルを開く)

設定

/config の ask-codex の行で変えられます。

項目既定内容
Codex の権限workspace-writedanger-full-access にすると確認もサンドボックスもなしで動く。自分の環境だけで使うこと
Codex の推論強度highパネルで送り先ごとに変えられる
Codex のモデル空空なら Codex CLI の既定モデル
codex の場所空空なら自動で探す
Devin の強さhighSWE-2 の medium / high / max
devin の場所空空なら自動で探す
Devin を動かすフォルダー空空なら ~/.ask-codex/devin-chat

注意

  • Devin は --respect-workspace-trust false を付けて、その1回だけフォルダーの信頼確認を省いて動かします。動かす場所は中身の空な専用フォルダーです
  • Codex を danger-full-access にすると、今の作業フォルダーのファイルを確認なしで書き換え、コマンドも実行します。git で戻せる状態で使ってください
  • 返答は日本語で返すよう指示しています
  • 失敗の記録は ~/.ask-codex/errors.log に残ります
  • 利用料金は各サービスの規約に従います。SWE-2 が無料かどうかは Devin 側の提供条件によります

English

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

Source 2 files
hooks/register.tsx 409 lines
1import { 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}
409
types/index.d.ts 21 lines
1export 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