coxpit 3.4.1 → 3.6.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,9 +70,12 @@ 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 |
78
+ | `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
75
79
 
76
80
  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.
77
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.4.1",
3
+ "version": "3.6.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
@@ -4,7 +4,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
4
4
  <html lang="en">
5
5
  <head>
6
6
  <meta charset="utf-8" />
7
- <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
8
8
  <title>coxpit · fleet</title>
9
9
  <link rel="stylesheet" href="/vendor/xterm.css" />
10
10
  <style>
@@ -295,22 +295,51 @@ export const BOARD_HTML = /* html */ `<!doctype html>
295
295
  #xterm{width:100%;height:100%}
296
296
  .term-hint{font-family:var(--mono);font-size:11px;color:var(--faint)}
297
297
 
298
+ /* ── mobile · the pocket pass ───────────── */
299
+ .menu-btn{display:none;font-size:15px;padding:5px 10px}
300
+ .scrim{position:fixed;inset:0;top:54px;background:rgba(5,7,10,.5);z-index:29;display:none}
298
301
  @media (max-width:860px){
299
- .layout{grid-template-columns:1fr}
300
- aside{border-right:none;border-bottom:1px solid var(--line)}
301
- .modal-b{grid-template-columns:1fr}
302
+ .layout{grid-template-columns:1fr;min-height:calc(100dvh - 55px)}
303
+ /* 사이드바 = 오프캔버스 드로어 — 플릿이 첫 화면, 런처는 ☰ 뒤에 */
304
+ .menu-btn{display:inline-flex}
305
+ aside{position:fixed;top:54px;bottom:0;left:0;width:min(86vw,340px);z-index:30;
306
+ transform:translateX(-103%);transition:transform .22s ease;overflow-y:auto;
307
+ -webkit-overflow-scrolling:touch;box-shadow:var(--shadow)}
308
+ aside.open{transform:translateX(0)}
309
+ .scrim.on{display:block}
310
+ header{padding:0 12px;gap:9px}
311
+ .brand .sub,.daemon-badge,.machines{display:none}
312
+ main{padding:14px}
313
+ .grid{grid-template-columns:1fr}
314
+ /* 모달 = 풀블리드 */
315
+ .overlay.open{padding:0}
316
+ .modal,.modal.wide{width:100%;height:100dvh;max-height:none;border:none;border-radius:0}
317
+ .modal-b{grid-template-columns:1fr;grid-template-rows:1fr 1fr}
302
318
  .pane{border-right:none;border-bottom:1px solid var(--line)}
319
+ .modal-f{flex-wrap:wrap;padding:10px 12px}
320
+ #steerRow .seg{flex:0 0 108px}
321
+ /* iOS: 16px 미만 input 포커스 시 강제 줌 — 폰에서만 16px 로 */
322
+ input,textarea,select,.dd-btn{font-size:16px}
323
+ /* 온보딩 패널이 좁은 화면을 넘치지 않게 */
324
+ .empty{padding:24px 0}
325
+ .setup{max-width:100%;width:100%;margin:8px 0}
326
+ .chk .v{min-width:0}
327
+ .cmp{flex-direction:column;overflow-y:auto;overflow-x:hidden}
328
+ .cmp-col{min-width:0;border-right:none;border-bottom:1px solid var(--line);flex:0 0 auto;max-height:72vh}
303
329
  }
330
+ @media (prefers-reduced-motion:reduce){aside{transition:none}}
304
331
  </style>
305
332
  </head>
306
333
  <body>
307
334
  <header>
335
+ <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
308
336
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
309
337
  <span class="daemon-badge" id="daemonBadge" style="display:none"></span>
310
338
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
311
339
  <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
312
340
  <div class="machines" id="machines"></div>
313
341
  </header>
342
+ <div class="scrim" id="scrim"></div>
314
343
  <div class="layout">
315
344
  <aside>
316
345
  <div class="sect">
@@ -343,6 +372,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
343
372
  <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
344
373
  <input id="taskTitle" placeholder="Task title" />
345
374
  <textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
375
+ <div class="seg" id="provSeg" role="group" aria-label="agent provider">
376
+ <button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
377
+ <button type="button" class="seg-opt" data-agent="codex">Codex</button>
378
+ </div>
346
379
  <p class="flabel">design capture · optional</p>
347
380
  <select id="taskCapture"><option value="">no design capture</option></select>
348
381
  </div>
@@ -734,7 +767,8 @@ function paintOnboarding(){
734
767
  checks = chkRow('connection', !!r.reachable, machines.length ? machines[0].slug : '')
735
768
  + chkRow('git', r.git ? r.git.ok : false, r.git ? r.git.version : '')
736
769
  + 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') : '');
770
+ + chkRow('agent', r.agent ? r.agent.ok : false, r.agent ? (r.agent.ok ? agentBin+' '+r.agent.version : 'not found on PATH') : '')
771
+ + (r.codex && r.codex.ok ? chkRow('codex', true, r.codex.bin+' '+r.codex.version+' · optional 2nd provider') : '');
738
772
  }
739
773
  const agentMissing = r && r.agent && !r.agent.ok;
740
774
  $('empty').innerHTML = '<div class="setup">'
@@ -863,10 +897,12 @@ function notifySettleUI(ev){
863
897
  if (!notifyOn) return;
864
898
  const t = tasks.get(ev.taskId ?? (runs.get(ev.runId)||{}).taskId);
865
899
  try {
866
- new Notification('coxpit · r'+ev.runId+' '+ev.status, {
900
+ const n = new Notification('coxpit · r'+ev.runId+' '+ev.status, {
867
901
  body: (t ? t.title+' — ' : '') + (ev.filesChanged??0)+' file(s) changed',
868
902
  tag: 'coxpit-r'+ev.runId,
869
903
  });
904
+ // 딥링크 — 알림 탭이 곧 그 run 의 모달
905
+ n.onclick = ()=>{ try{ window.focus(); }catch{} if (runs.has(ev.runId)) openModal(ev.runId); n.close(); };
870
906
  } catch {}
871
907
  }
872
908
 
@@ -1318,6 +1354,7 @@ $('repoForm').addEventListener('submit', async (e)=>{
1318
1354
  });
1319
1355
  $('taskForm').addEventListener('submit', async (e)=>{
1320
1356
  e.preventDefault();
1357
+ setDrawer(false); // 모바일: 발사하면 드로어를 닫고 플릿을 보여준다
1321
1358
  if (lTab === 'goal') return submitGoal();
1322
1359
  if (lTab === 'bench') return submitBench();
1323
1360
  const repoId = Number($('taskRepo').value);
@@ -1329,7 +1366,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
1329
1366
  if (!t.ok){ toast('task create failed', 'error'); return; }
1330
1367
  tasks.set(t.task.id, t.task);
1331
1368
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
1332
- body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked})});
1369
+ body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent})});
1333
1370
  $('taskTitle').value=''; $('taskPrompt').value='';
1334
1371
  });
1335
1372
 
@@ -1349,7 +1386,38 @@ let savedMode = null;
1349
1386
  try { savedMode = localStorage.getItem('coxpit.real'); } catch {}
1350
1387
  setMode(savedMode === '1', false);
1351
1388
 
1352
- hydrate().then(connectWS);
1389
+ /* ── provider segmented control — Task 탭 전용(Goal 플래너·Workbench 는 무관) ── */
1390
+ let selAgent = 'claude-code';
1391
+ const provOpts = Array.from(document.querySelectorAll('#provSeg .seg-opt'));
1392
+ function setProvider(id, persist){
1393
+ selAgent = id;
1394
+ for (const b of provOpts){
1395
+ const on = b.dataset.agent === id;
1396
+ b.classList.toggle('on', on);
1397
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
1398
+ }
1399
+ if (persist) try { localStorage.setItem('coxpit.agent', id); } catch {}
1400
+ }
1401
+ for (const b of provOpts) b.addEventListener('click', ()=>setProvider(b.dataset.agent, true));
1402
+ let savedAgent = null;
1403
+ try { savedAgent = localStorage.getItem('coxpit.agent'); } catch {}
1404
+ if (savedAgent === 'codex') setProvider('codex', false);
1405
+
1406
+ /* ── mobile drawer ── */
1407
+ const asideEl = document.querySelector('aside');
1408
+ function setDrawer(on){ asideEl.classList.toggle('open', on); $('scrim').classList.toggle('on', on); }
1409
+ $('menuBtn').addEventListener('click', ()=>setDrawer(!asideEl.classList.contains('open')));
1410
+ $('scrim').addEventListener('click', ()=>setDrawer(false));
1411
+
1412
+ /* 딥링크 — /?run=N 이면 하이드레이션 후 그 run 모달을 연다 (웹훅 링크·알림용) */
1413
+ function openFromURL(){
1414
+ const q = new URLSearchParams(location.search).get('run');
1415
+ const id = Number(q);
1416
+ if (q && runs.has(id)){ openModal(id); }
1417
+ if (q) history.replaceState(null, '', location.pathname);
1418
+ }
1419
+
1420
+ hydrate().then(()=>{ connectWS(); openFromURL(); });
1353
1421
  </script>
1354
1422
  </body>
1355
1423
  </html>`;
package/src/config.ts CHANGED
@@ -65,6 +65,8 @@ export const config = {
65
65
  sshKey: process.env.COXPIT_SSH_KEY ?? '',
66
66
  // run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
67
67
  webhookUrl: process.env.COXPIT_WEBHOOK_URL ?? '',
68
+ // 웹훅 payload 의 딥링크 base(선택) — 설정 시 url: <base>/?run=<id> 포함 (폰에서 탭 → 그 run)
69
+ publicUrl: (process.env.COXPIT_PUBLIC_URL ?? '').replace(/\/+$/, ''),
68
70
  agent: {
69
71
  // 기본 드라이런(모의 에이전트). 실제 CLI 실행은 켤 때만(크레딧 소모).
70
72
  real: process.env.COXPIT_AGENT_REAL === '1',
@@ -73,6 +75,13 @@ export const config = {
73
75
  // 완전 자율은 bypassPermissions.
74
76
  perm: process.env.COXPIT_AGENT_PERM ?? 'acceptEdits',
75
77
  },
78
+ codex: {
79
+ // 두 번째 프로바이더 — OpenAI Codex CLI (선택 설치). providers.ts 의 시임.
80
+ bin: process.env.COXPIT_CODEX_BIN ?? 'codex',
81
+ // workspace-write = acceptEdits 상응(워크스페이스 안 편집 자동허용).
82
+ // 완전 자율은 danger-full-access.
83
+ sandbox: process.env.COXPIT_CODEX_SANDBOX ?? 'workspace-write',
84
+ },
76
85
  auth: {
77
86
  disabled: process.env.COXPIT_AUTH_DISABLED === '1',
78
87
  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;
@@ -259,6 +218,8 @@ async function notifySettle(runId: number, status: string, filesChanged: number,
259
218
  body: JSON.stringify({
260
219
  event: 'run.settled',
261
220
  run: { id: runId, status, filesChanged, exitSummary: exitSummary.slice(0, 300), task: tr[0]?.title ?? '' },
221
+ // COXPIT_PUBLIC_URL 설정 시 폰에서 탭 → 보드가 그 run 모달을 딥링크로 연다
222
+ ...(config.publicUrl ? { url: `${config.publicUrl}/?run=${runId}` } : {}),
262
223
  }),
263
224
  signal: AbortSignal.timeout(8000),
264
225
  });
@@ -293,10 +254,10 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
293
254
 
294
255
  const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
295
256
  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}`;
257
+ const provider = getProvider(ctx.agent);
258
+ const resume = provider.resumeCmd(run.sessionId, finalMessage);
298
259
  const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
299
- void runAgentChild(runId, ctx.machine, wt, cmd);
260
+ void runAgentChild(runId, ctx.machine, wt, cmd, provider);
300
261
  return { ok: true, detail: 'steering' };
301
262
  }
302
263
 
@@ -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)