coxpit 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ Your machines. Your auth. Your code never leaves your network.
13
13
  ## What it does
14
14
 
15
15
  - **Fleet runs** — one task, N agents. Each run = worktree + branch + tmux window. No agent ever touches your checkout.
16
+ - **Two providers** — Claude Code and OpenAI Codex CLI, selectable per launch. Fan the same task across both and compare; steering resumes each agent's own session. The provider seam (`src/providers.ts`) is ~100 lines per provider — adding a third is a PR, not a fork.
16
17
  - **Live board** — WebSocket-driven console: status, event timeline (parsed from the agent's stream-json), per-run diff.
17
18
  - **Compare & merge** — all runs of a task side by side; pick the winner, merge to the base branch (auto-commits the worktree, guards a clean base, aborts on conflict).
18
19
  - **Real terminal** — attach to any run's tmux session in the browser (xterm.js over a server-side PTY; resize propagates, `Ctrl-b d` detaches).
@@ -53,7 +54,7 @@ Coxpit has no accounts of its own — it drives the agent CLI already on your ma
53
54
  npm i -g @anthropic-ai/claude-code
54
55
  claude # first run opens browser login
55
56
  ```
56
- Prefer another CLI? Set `COXPIT_AGENT_BIN`.
57
+ For the Codex provider, also: `npm i -g @openai/codex && codex` (sign in once). Other binaries: `COXPIT_AGENT_BIN` / `COXPIT_CODEX_BIN`.
57
58
  2. **Open the board** — the first-run panel checks this machine (git · tmux · agent CLI) and tells you what's missing.
58
59
  3. **Rehearse with Dry run**, then flip to Real agent. Real runs spend your CLI account's credits — nothing is billed through coxpit.
59
60
 
@@ -69,8 +70,10 @@ Your keys and login never touch coxpit's config or database.
69
70
  | `COXPIT_AUTH_DISABLED` | — | `1` disables auth (local dev only) |
70
71
  | `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
71
72
  | `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
72
- | `COXPIT_AGENT_BIN` | `claude` | agent command |
73
- | `COXPIT_AGENT_PERM` | `acceptEdits` | headless permission mode passed to the agent |
73
+ | `COXPIT_AGENT_BIN` | `claude` | Claude Code command |
74
+ | `COXPIT_AGENT_PERM` | `acceptEdits` | Claude Code headless permission mode |
75
+ | `COXPIT_CODEX_BIN` | `codex` | Codex CLI command (optional second provider) |
76
+ | `COXPIT_CODEX_SANDBOX` | `workspace-write` | Codex sandbox policy (`danger-full-access` for full autonomy) |
74
77
  | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
75
78
 
76
79
  Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep basic auth on — it exposes shells.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/board.ts CHANGED
@@ -343,6 +343,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
343
343
  <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
344
344
  <input id="taskTitle" placeholder="Task title" />
345
345
  <textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
346
+ <div class="seg" id="provSeg" role="group" aria-label="agent provider">
347
+ <button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
348
+ <button type="button" class="seg-opt" data-agent="codex">Codex</button>
349
+ </div>
346
350
  <p class="flabel">design capture · optional</p>
347
351
  <select id="taskCapture"><option value="">no design capture</option></select>
348
352
  </div>
@@ -734,7 +738,8 @@ function paintOnboarding(){
734
738
  checks = chkRow('connection', !!r.reachable, machines.length ? machines[0].slug : '')
735
739
  + chkRow('git', r.git ? r.git.ok : false, r.git ? r.git.version : '')
736
740
  + chkRow('tmux', r.tmux ? r.tmux.ok : false, r.tmux ? r.tmux.version : '')
737
- + chkRow('agent', r.agent ? r.agent.ok : false, r.agent ? (r.agent.ok ? agentBin+' '+r.agent.version : 'not found on PATH') : '');
741
+ + chkRow('agent', r.agent ? r.agent.ok : false, r.agent ? (r.agent.ok ? agentBin+' '+r.agent.version : 'not found on PATH') : '')
742
+ + (r.codex && r.codex.ok ? chkRow('codex', true, r.codex.bin+' '+r.codex.version+' · optional 2nd provider') : '');
738
743
  }
739
744
  const agentMissing = r && r.agent && !r.agent.ok;
740
745
  $('empty').innerHTML = '<div class="setup">'
@@ -1158,14 +1163,20 @@ let termRunId = null, termClosing = false, termRetry = 0, termRetryTimer = null;
1158
1163
  function termConnect(){
1159
1164
  if (termRunId==null || termClosing || !termObj) return;
1160
1165
  const proto = location.protocol==='https:'?'wss':'ws';
1161
- termWS = new WebSocket(proto+'://'+location.host+'/ws/term/'+termRunId
1166
+ // 소켓 정체성 가드 — close 이벤트는 비동기라 termClosing 토글만으론 못 막는다.
1167
+ // 대체된(sock!==termWS) 소켓은 출력도 재연결도 금지: 같은 tmux 세션에 WS 가
1168
+ // 누적 attach 되면 tmux 가 전 클라이언트에 미러링해 글자가 N번씩 보인다.
1169
+ const sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+termRunId
1162
1170
  +'?cols='+termObj.cols+'&rows='+termObj.rows);
1163
- termWS.onopen = ()=>{
1171
+ termWS = sock;
1172
+ sock.onopen = ()=>{
1173
+ if (sock!==termWS){ try{ sock.close(); }catch{} return; }
1164
1174
  termRetry = 0;
1165
1175
  $('termTitle').textContent = ((runs.get(termRunId)||{}).tmuxWindow||'terminal');
1166
1176
  termObj.focus();
1167
1177
  };
1168
- termWS.onmessage = (m)=>{
1178
+ sock.onmessage = (m)=>{
1179
+ if (sock!==termWS || !termObj) return;
1169
1180
  try{
1170
1181
  const d = JSON.parse(m.data);
1171
1182
  if (d.t==='o') termObj.write(d.d);
@@ -1173,7 +1184,8 @@ function termConnect(){
1173
1184
  else if (d.t==='exit') termObj.write('\\r\\n\\x1b[90m[session ended — reconnecting will revive it]\\x1b[0m\\r\\n');
1174
1185
  }catch{}
1175
1186
  };
1176
- termWS.onclose = ()=>{
1187
+ sock.onclose = ()=>{
1188
+ if (sock!==termWS) return;
1177
1189
  if (termClosing || termRunId==null) return;
1178
1190
  // 예기치 않은 끊김 — 백오프 재연결(서버가 죽은 세션도 소생시킴)
1179
1191
  const delay = Math.min(8000, 800 * Math.pow(2, termRetry++));
@@ -1204,6 +1216,9 @@ function termSwitch(id){
1204
1216
  }
1205
1217
  function openTerm(runId){
1206
1218
  const r = runs.get(runId); if(!r) return;
1219
+ // 재진입 방어 — 이전 소켓/타이머가 남아 있으면 정리(중복 attach 방지)
1220
+ if (termRetryTimer){ clearTimeout(termRetryTimer); termRetryTimer=null; }
1221
+ if (termWS){ const old=termWS; termWS=null; try{ old.close(); }catch{} }
1207
1222
  termRunId = runId; termClosing = false; termRetry = 0;
1208
1223
  $('termRid').textContent = 'r'+runId;
1209
1224
  $('termTitle').textContent = (r.tmuxWindow||'terminal');
@@ -1319,7 +1334,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
1319
1334
  if (!t.ok){ toast('task create failed', 'error'); return; }
1320
1335
  tasks.set(t.task.id, t.task);
1321
1336
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
1322
- body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked})});
1337
+ body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent})});
1323
1338
  $('taskTitle').value=''; $('taskPrompt').value='';
1324
1339
  });
1325
1340
 
@@ -1339,6 +1354,23 @@ let savedMode = null;
1339
1354
  try { savedMode = localStorage.getItem('coxpit.real'); } catch {}
1340
1355
  setMode(savedMode === '1', false);
1341
1356
 
1357
+ /* ── provider segmented control — Task 탭 전용(Goal 플래너·Workbench 는 무관) ── */
1358
+ let selAgent = 'claude-code';
1359
+ const provOpts = Array.from(document.querySelectorAll('#provSeg .seg-opt'));
1360
+ function setProvider(id, persist){
1361
+ selAgent = id;
1362
+ for (const b of provOpts){
1363
+ const on = b.dataset.agent === id;
1364
+ b.classList.toggle('on', on);
1365
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
1366
+ }
1367
+ if (persist) try { localStorage.setItem('coxpit.agent', id); } catch {}
1368
+ }
1369
+ for (const b of provOpts) b.addEventListener('click', ()=>setProvider(b.dataset.agent, true));
1370
+ let savedAgent = null;
1371
+ try { savedAgent = localStorage.getItem('coxpit.agent'); } catch {}
1372
+ if (savedAgent === 'codex') setProvider('codex', false);
1373
+
1342
1374
  hydrate().then(connectWS);
1343
1375
  </script>
1344
1376
  </body>
package/src/config.ts CHANGED
@@ -73,6 +73,13 @@ export const config = {
73
73
  // 완전 자율은 bypassPermissions.
74
74
  perm: process.env.COXPIT_AGENT_PERM ?? 'acceptEdits',
75
75
  },
76
+ codex: {
77
+ // 두 번째 프로바이더 — OpenAI Codex CLI (선택 설치). providers.ts 의 시임.
78
+ bin: process.env.COXPIT_CODEX_BIN ?? 'codex',
79
+ // workspace-write = acceptEdits 상응(워크스페이스 안 편집 자동허용).
80
+ // 완전 자율은 danger-full-access.
81
+ sandbox: process.env.COXPIT_CODEX_SANDBOX ?? 'workspace-write',
82
+ },
76
83
  auth: {
77
84
  disabled: process.env.COXPIT_AUTH_DISABLED === '1',
78
85
  user: process.env.COXPIT_AUTH_USER ?? 'admin',
@@ -10,15 +10,13 @@ import { db } from './db';
10
10
  import { agentRuns, agentEvents, tasks, repos, machines, designCaptures } from './db/schema';
11
11
  import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
12
12
  import { broadcast } from './hub';
13
+ import { getProvider, type Provider } from './providers';
13
14
 
14
15
  /** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
15
- function agentCommand(prompt: string, real: boolean): string {
16
- if (real) {
17
- // claude-code headless. stream-json 라인이 stdout 으로 흐른다.
18
- return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
19
- ` --permission-mode ${config.agent.perm}`;
20
- }
21
- // 모의: init → assistant → (파일 변경) → result. 진짜 stream-json 라인 형태.
16
+ function agentCommand(provider: Provider, prompt: string, real: boolean): string {
17
+ if (real) return provider.launchCmd(prompt);
18
+ // 모의: init → assistant → (파일 변경) → result. claude stream-json 라인 형태
19
+ // (드라이런은 프로바이더 불문 배관 리허설 claude 파서가 처리한다).
22
20
  return [
23
21
  `printf '%s\\n' '{"type":"system","subtype":"init","session":"dryrun"}'`,
24
22
  `printf '%s\\n' '{"type":"assistant","text":"planning: '"$(printf %s ${shq(prompt)} | cut -c1-40)"'"}'`,
@@ -70,6 +68,7 @@ interface RunContext {
70
68
  baseBranch: string;
71
69
  prompt: string;
72
70
  real: boolean;
71
+ agent: string;
73
72
  }
74
73
 
75
74
  async function loadContext(runId: number): Promise<RunContext | null> {
@@ -107,7 +106,8 @@ async function loadContext(runId: number): Promise<RunContext | null> {
107
106
  repoPath: repo.path,
108
107
  baseBranch: repo.defaultBranch,
109
108
  prompt,
110
- real: run.agent === 'claude-code' ? config.agent.real : config.agent.real,
109
+ real: config.agent.real,
110
+ agent: run.agent,
111
111
  };
112
112
  }
113
113
 
@@ -153,8 +153,10 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
153
153
  // 원격은 ssh 채널이 죽어도 프로세스가 남을 수 있어 pid 파일을 남긴다(stop 시 원격 kill).
154
154
  const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
155
155
  const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
156
- const cmd = `cd ${shq(wtPath)} && ${pidPrefix}{ ${agentCommand(ctx.prompt, useReal)}; }`;
157
- await runAgentChild(runId, ctx.machine, wtPath, cmd);
156
+ // 드라이런 모의 스트림은 claude 형태 파서도 claude 로 (배관 리허설은 프로바이더 불문)
157
+ const provider = useReal ? getProvider(ctx.agent) : getProvider('claude-code');
158
+ const cmd = `cd ${shq(wtPath)} && ${pidPrefix}{ ${agentCommand(provider, ctx.prompt, useReal)}; }`;
159
+ await runAgentChild(runId, ctx.machine, wtPath, cmd, provider);
158
160
  } catch (e) {
159
161
  await recordEvent(runId, 'error', String(e).slice(0, 500));
160
162
  await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
@@ -162,10 +164,10 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
162
164
  }
163
165
 
164
166
  /**
165
- * 에이전트 자식 프로세스 배선(공용) — stream-json 파싱→이벤트, session 캡처,
166
- * 종료 시 files_changed 집계 + 상태 전이. launchRun/steerRun 공유.
167
+ * 에이전트 자식 프로세스 배선(공용) — 프로바이더가 stdout 라인을 정규화 이벤트로
168
+ * 파싱, session 캡처, 종료 시 files_changed 집계 + 상태 전이. launchRun/steerRun 공유.
167
169
  */
168
- async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string): Promise<void> {
170
+ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider): Promise<void> {
169
171
  const child = spawnShellOn(machine, cmd);
170
172
  liveChildren.set(runId, child);
171
173
 
@@ -173,44 +175,11 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
173
175
  if (child.stdout) {
174
176
  const rl = createInterface({ input: child.stdout });
175
177
  rl.on('line', (line: string) => {
176
- const s = line.trim();
177
- if (!s) return;
178
- let kind = 'log';
179
- let stored = s;
180
- try {
181
- const obj = JSON.parse(s) as {
182
- type?: string; subtype?: string; model?: string; result?: string; session_id?: string;
183
- message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, unknown> }> };
184
- };
185
- if (obj.type) kind = obj.type;
186
- // steer(--resume) 용 세션 키 캡처
187
- if (obj.type === 'system' && typeof obj.session_id === 'string') {
188
- void setRun(runId, { sessionId: obj.session_id });
189
- }
190
- // result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
191
- if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
192
- // 2000자 초과 이벤트는 자르면 JSON 이 깨져 잔해가 화면에 노출된다 —
193
- // 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
194
- if (s.length > 2000) {
195
- if (obj.type === 'assistant' && obj.message) {
196
- const content = (obj.message.content ?? [])
197
- .filter((c) => c.type === 'text' || c.type === 'tool_use')
198
- .map((c) => c.type === 'text'
199
- ? { type: 'text', text: (c.text ?? '').slice(0, 600) }
200
- : { type: 'tool_use', name: c.name, input: compactInput(c.input) });
201
- stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
202
- } else if (obj.type === 'user') {
203
- stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
204
- } else if (obj.type === 'system') {
205
- stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
206
- } else if (obj.type === 'result') {
207
- stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
208
- } else {
209
- stored = s.slice(0, 2000);
210
- }
211
- }
212
- } catch { stored = s.slice(0, 2000); /* 비-JSON 로그 라인 */ }
213
- void recordEvent(runId, kind, stored.slice(0, 2000));
178
+ const p = provider.parseLine(line);
179
+ if (!p) return;
180
+ if (p.sessionId) void setRun(runId, { sessionId: p.sessionId }); // steer(resume)용 세션 키
181
+ if (p.resultText != null) lastResult = p.resultText;
182
+ void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
214
183
  });
215
184
  }
216
185
  if (child.stderr) {
@@ -237,16 +206,6 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
237
206
  void notifySettle(runId, status, filesChanged, exitSummary);
238
207
  }
239
208
 
240
- /** tool_use input 을 표시용 핵심 필드만 남긴다(이벤트 압축용). */
241
- function compactInput(input?: Record<string, unknown>): Record<string, string> {
242
- const out: Record<string, string> = {};
243
- if (!input) return out;
244
- for (const k of ['file_path', 'command', 'path', 'pattern', 'url']) {
245
- if (typeof input[k] === 'string') out[k] = (input[k] as string).slice(0, 200);
246
- }
247
- return out;
248
- }
249
-
250
209
  /** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
251
210
  async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
252
211
  if (!config.webhookUrl) return;
@@ -293,10 +252,10 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
293
252
 
294
253
  const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
295
254
  const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
296
- const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(finalMessage)}` +
297
- ` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
255
+ const provider = getProvider(ctx.agent);
256
+ const resume = provider.resumeCmd(run.sessionId, finalMessage);
298
257
  const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
299
- void runAgentChild(runId, ctx.machine, wt, cmd);
258
+ void runAgentChild(runId, ctx.machine, wt, cmd, provider);
300
259
  return { ok: true, detail: 'steering' };
301
260
  }
302
261
 
@@ -0,0 +1,186 @@
1
+ import { config } from './config';
2
+ import { shq } from './exec';
3
+
4
+ /**
5
+ * Provider seam — coxpit 은 "에이전트들"을 오케스트레이션한다(특정 에이전트가 아니라).
6
+ * 각 프로바이더는 세 가지만 답하면 된다:
7
+ * 1) 헤드리스 1회 실행 커맨드는 무엇인가 (launchCmd)
8
+ * 2) 정착한 세션을 어떻게 이어가는가 (resumeCmd)
9
+ * 3) stdout 한 줄을 coxpit 이벤트로 어떻게 정규화하는가 (parseLine)
10
+ * 정규화 목표 = 보드가 이미 아는 형태({type:'assistant',text}·tool_use·result)로
11
+ * 수렴시키는 것. 보드는 프로바이더를 모른다.
12
+ */
13
+
14
+ export interface ParsedEvent {
15
+ kind: string;
16
+ stored: string;
17
+ /** steer(세션 이어가기)용 세션 키 — 이 라인에서 발견되면 채운다 */
18
+ sessionId?: string;
19
+ /** 사람이 읽는 최종 요약 후보 — 마지막 값이 exitSummary 가 된다 */
20
+ resultText?: string;
21
+ }
22
+
23
+ export interface Provider {
24
+ id: string;
25
+ label: string;
26
+ bin: string;
27
+ launchCmd(prompt: string): string;
28
+ resumeCmd(sessionId: string, message: string): string;
29
+ /** null = 저장하지 않는 라인(스트림 잡음) */
30
+ parseLine(raw: string): ParsedEvent | null;
31
+ }
32
+
33
+ /** tool_use input 을 표시용 핵심 필드만 남긴다(이벤트 압축용). */
34
+ function compactInput(input?: Record<string, unknown>): Record<string, string> {
35
+ const out: Record<string, string> = {};
36
+ if (!input) return out;
37
+ for (const k of ['file_path', 'command', 'path', 'pattern', 'url']) {
38
+ if (typeof input[k] === 'string') out[k] = (input[k] as string).slice(0, 200);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** 보드 tool 렌더용 정규화 tool_use 이벤트 */
44
+ function toolEvent(name: string, input: Record<string, string>): string {
45
+ return JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name, input }] } });
46
+ }
47
+
48
+ // ─── claude-code ────────────────────────────────────────────────
49
+ // stream-json: {type:system|assistant|user|result, ...} 라인. 세션 키 = system.session_id.
50
+
51
+ const claudeProvider: Provider = {
52
+ id: 'claude-code',
53
+ label: 'Claude Code',
54
+ get bin() { return config.agent.bin; },
55
+ launchCmd(prompt: string): string {
56
+ return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
57
+ ` --permission-mode ${config.agent.perm}`;
58
+ },
59
+ resumeCmd(sessionId: string, message: string): string {
60
+ return `${config.agent.bin} -p --resume ${shq(sessionId)} ${shq(message)}` +
61
+ ` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
62
+ },
63
+ parseLine(raw: string): ParsedEvent | null {
64
+ const s = raw.trim();
65
+ if (!s) return null;
66
+ let kind = 'log';
67
+ let stored = s;
68
+ const ev: ParsedEvent = { kind, stored };
69
+ try {
70
+ const obj = JSON.parse(s) as {
71
+ type?: string; subtype?: string; model?: string; result?: string; session_id?: string;
72
+ message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, unknown> }> };
73
+ };
74
+ if (obj.type) kind = obj.type;
75
+ if (obj.type === 'system' && typeof obj.session_id === 'string') ev.sessionId = obj.session_id;
76
+ if (obj.type === 'result') ev.resultText = typeof obj.result === 'string' ? obj.result : s;
77
+ // 2000자 초과 이벤트는 자르면 JSON 이 깨져 잔해가 화면에 노출된다 —
78
+ // 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
79
+ if (s.length > 2000) {
80
+ if (obj.type === 'assistant' && obj.message) {
81
+ const content = (obj.message.content ?? [])
82
+ .filter((c) => c.type === 'text' || c.type === 'tool_use')
83
+ .map((c) => c.type === 'text'
84
+ ? { type: 'text', text: (c.text ?? '').slice(0, 600) }
85
+ : { type: 'tool_use', name: c.name, input: compactInput(c.input) });
86
+ stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
87
+ } else if (obj.type === 'user') {
88
+ stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
89
+ } else if (obj.type === 'system') {
90
+ stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
91
+ } else if (obj.type === 'result') {
92
+ stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
93
+ } else {
94
+ stored = s.slice(0, 2000);
95
+ }
96
+ }
97
+ } catch { stored = s.slice(0, 2000); /* 비-JSON 로그 라인 */ }
98
+ ev.kind = kind;
99
+ ev.stored = stored.slice(0, 2000);
100
+ return ev;
101
+ },
102
+ };
103
+
104
+ // ─── codex ──────────────────────────────────────────────────────
105
+ // codex exec --json: JSONL 이벤트(thread.started·turn.*·item.completed·error).
106
+ // 세션 키 = thread.started.thread_id, resume = `codex exec resume <id>`.
107
+ // 이벤트는 보드가 아는 claude 형태로 정규화해 저장한다.
108
+
109
+ interface CodexItem {
110
+ type?: string; text?: string; command?: string; exit_code?: number;
111
+ changes?: Array<{ path?: string; kind?: string }>;
112
+ server?: string; tool?: string; query?: string;
113
+ }
114
+
115
+ const codexProvider: Provider = {
116
+ id: 'codex',
117
+ label: 'Codex',
118
+ get bin() { return config.codex.bin; },
119
+ launchCmd(prompt: string): string {
120
+ return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox} ${shq(prompt)}`;
121
+ },
122
+ resumeCmd(sessionId: string, message: string): string {
123
+ // --sandbox 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
124
+ return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox} resume ${shq(sessionId)} ${shq(message)}`;
125
+ },
126
+ parseLine(raw: string): ParsedEvent | null {
127
+ const s = raw.trim();
128
+ if (!s) return null;
129
+ let obj: { type?: string; thread_id?: string; message?: string; item?: CodexItem };
130
+ try { obj = JSON.parse(s) as typeof obj; } catch {
131
+ return { kind: 'log', stored: s.slice(0, 2000) }; // 비-JSON 로그 라인
132
+ }
133
+ switch (obj.type) {
134
+ case 'thread.started':
135
+ return {
136
+ kind: 'system',
137
+ stored: JSON.stringify({ type: 'system', subtype: 'init', model: 'codex' }),
138
+ sessionId: typeof obj.thread_id === 'string' ? obj.thread_id : undefined,
139
+ };
140
+ case 'item.completed': {
141
+ const it = obj.item ?? {};
142
+ if (it.type === 'agent_message' && typeof it.text === 'string') {
143
+ return {
144
+ kind: 'assistant',
145
+ stored: JSON.stringify({ type: 'assistant', text: it.text.slice(0, 1500) }),
146
+ resultText: it.text, // 마지막 agent_message = 최종 요약
147
+ };
148
+ }
149
+ if (it.type === 'command_execution') {
150
+ return { kind: 'assistant', stored: toolEvent('shell', { command: (it.command ?? '').slice(0, 200) }) };
151
+ }
152
+ if (it.type === 'file_change') {
153
+ const paths = (it.changes ?? []).map((c) => c.path ?? '').filter(Boolean);
154
+ return { kind: 'assistant', stored: toolEvent('edit', { file_path: paths.join(', ').slice(0, 200) }) };
155
+ }
156
+ if (it.type === 'mcp_tool_call') {
157
+ return { kind: 'assistant', stored: toolEvent(`${it.server ?? 'mcp'}.${it.tool ?? 'tool'}`, {}) };
158
+ }
159
+ if (it.type === 'web_search') {
160
+ return { kind: 'assistant', stored: toolEvent('web_search', { pattern: (it.query ?? '').slice(0, 120) }) };
161
+ }
162
+ return null; // reasoning·plan_update 등 — 표시 잡음
163
+ }
164
+ case 'error':
165
+ return { kind: 'error', stored: (obj.message ?? s).slice(0, 500) };
166
+ default:
167
+ return null; // turn.started/completed·item.started/updated — 잡음
168
+ }
169
+ },
170
+ };
171
+
172
+ // ─── registry ───────────────────────────────────────────────────
173
+
174
+ const registry: Record<string, Provider> = {
175
+ [claudeProvider.id]: claudeProvider,
176
+ [codexProvider.id]: codexProvider,
177
+ };
178
+
179
+ /** run.agent → Provider. 미지의 값(workbench 포함)은 claude 로 폴백. */
180
+ export function getProvider(agentId: string | null | undefined): Provider {
181
+ return registry[agentId ?? ''] ?? claudeProvider;
182
+ }
183
+
184
+ export function listProviders(): Array<{ id: string; label: string; bin: string }> {
185
+ return Object.values(registry).map((p) => ({ id: p.id, label: p.label, bin: p.bin }));
186
+ }
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import { runShellOn, shq } from './exec';
15
15
  import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
+ import { getProvider, listProviders } from './providers';
18
19
  import { BOARD_HTML } from './board';
19
20
 
20
21
  const require_ = createRequire(import.meta.url);
@@ -59,6 +60,7 @@ export async function buildServer(): Promise<FastifyInstance> {
59
60
  runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
60
61
  // 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
61
62
  daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
63
+ providers: listProviders(),
62
64
  };
63
65
  });
64
66
 
@@ -103,10 +105,12 @@ export async function buildServer(): Promise<FastifyInstance> {
103
105
  if (!m) return reply.code(404).send({ error: 'not found' });
104
106
 
105
107
  const agentBin = config.agent.bin;
108
+ const codexBin = config.codex.bin;
106
109
  const cmd = [
107
110
  'echo GIT:$(git --version 2>&1)',
108
111
  'echo TMUX:$(tmux -V 2>&1)',
109
112
  `echo AGENT:$(command -v ${shq(agentBin)} >/dev/null 2>&1 && ${shq(agentBin)} --version 2>/dev/null | head -1 || echo missing)`,
113
+ `echo CODEX:$(command -v ${shq(codexBin)} >/dev/null 2>&1 && ${shq(codexBin)} --version 2>/dev/null | head -1 || echo missing)`,
110
114
  'echo OS:$(uname -sr 2>&1)',
111
115
  ].join('; ');
112
116
  const r = await runShellOn(m, cmd, 20000);
@@ -118,11 +122,14 @@ export async function buildServer(): Promise<FastifyInstance> {
118
122
  const gitStr = pick('GIT');
119
123
  const tmuxStr = pick('TMUX');
120
124
  const agentStr = pick('AGENT');
125
+ const codexStr = pick('CODEX');
121
126
  const reachable = r.ok;
122
127
  const git = { ok: /git version/i.test(gitStr), version: gitStr };
123
128
  const tmux = { ok: /tmux \d/i.test(tmuxStr), version: tmuxStr };
124
129
  // 에이전트 CLI 존재 여부(인증까지는 여기서 알 수 없음 — 첫 real run 이 판정)
125
130
  const agent = { ok: agentStr !== '' && agentStr !== 'missing', version: agentStr, bin: agentBin };
131
+ // 두 번째 프로바이더(선택) — 없어도 ready 판정엔 영향 없음
132
+ const codex = { ok: codexStr !== '' && codexStr !== 'missing', version: codexStr, bin: codexBin };
126
133
 
127
134
  await db.update(machines)
128
135
  .set({ online: reachable, lastSeen: new Date() })
@@ -130,7 +137,7 @@ export async function buildServer(): Promise<FastifyInstance> {
130
137
 
131
138
  return {
132
139
  slug, reachable,
133
- git, tmux, agent, os: pick('OS'),
140
+ git, tmux, agent, codex, os: pick('OS'),
134
141
  ready: reachable && git.ok && tmux.ok,
135
142
  error: reachable ? undefined : (r.stderr.trim() || `ssh exit ${r.code}`),
136
143
  };
@@ -312,7 +319,8 @@ export async function buildServer(): Promise<FastifyInstance> {
312
319
  if (!rp[0]) return reply.code(404).send({ error: 'repo missing' });
313
320
 
314
321
  const count = Math.max(1, Math.min(8, Number(b.count) || 1));
315
- const agent = b.agent ?? 'claude-code';
322
+ // 미지의 값은 기본 프로바이더로 정규화(런처 조작·API 오타 방어)
323
+ const agent = getProvider(b.agent).id;
316
324
  const created: Array<typeof agentRuns.$inferSelect> = [];
317
325
  for (let i = 0; i < count; i++) {
318
326
  const ins = await db.insert(agentRuns)