coxpit 3.0.0 → 3.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.0.0",
3
+ "version": "3.1.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
@@ -13,12 +13,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
13
13
  --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
14
14
  --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
15
15
  --s-pending:#8792a2; --s-preparing:#d6a249; --s-running:#55a7e0;
16
- --s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0;
16
+ --s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0; --s-open:#7f9cf5;
17
17
  --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
18
18
  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
19
19
  --r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
20
20
  }
21
21
  *{box-sizing:border-box}
22
+ [hidden]{display:none !important}
22
23
  html,body{height:100%}
23
24
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
24
25
  -webkit-font-smoothing:antialiased}
@@ -298,60 +299,65 @@ export const BOARD_HTML = /* html */ `<!doctype html>
298
299
  <div class="layout">
299
300
  <aside>
300
301
  <div class="sect">
301
- <p class="sect-label">Repositories</p>
302
- <div id="repos" style="display:flex;flex-direction:column;gap:6px"></div>
303
- <form id="repoForm">
304
- <p class="flabel">machine</p>
305
- <select id="repoMachine"></select>
306
- <p class="flabel">add a repository</p>
307
- <button class="btn sm" type="button" id="repoBrowse">Browse this machine…</button>
302
+ <p class="sect-label">Context</p>
303
+ <p class="flabel">machine</p>
304
+ <select id="repoMachine"></select>
305
+ <p class="flabel">repository</p>
306
+ <select id="taskRepo"></select>
307
+ <div class="row">
308
+ <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
309
+ <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
310
+ <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
311
+ </div>
312
+ <form id="repoForm" hidden>
308
313
  <div class="row">
309
- <input id="repoPath" placeholder="or type an absolute path" />
314
+ <input id="repoPath" placeholder="/abs/path/to/repo" />
310
315
  <button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
311
316
  </div>
312
317
  </form>
313
318
  </div>
319
+
314
320
  <div class="sect">
315
- <p class="sect-label">Launch agents</p>
321
+ <p class="sect-label">Start</p>
322
+ <div class="seg" id="launchTabs">
323
+ <button type="button" class="seg-opt on" data-tab="task">Task</button>
324
+ <button type="button" class="seg-opt" data-tab="goal">Goal</button>
325
+ <button type="button" class="seg-opt" data-tab="bench">Workbench</button>
326
+ </div>
316
327
  <form id="taskForm">
317
- <p class="flabel">repository</p>
318
- <select id="taskRepo"></select>
319
- <p class="flabel">task</p>
320
- <input id="taskTitle" placeholder="Task title" />
321
- <textarea id="taskPrompt" placeholder="Prompt name the target files, constraints, and how to verify"></textarea>
322
- <p class="flabel">design capture · optional</p>
323
- <select id="taskCapture"><option value="">no design capture</option></select>
324
- <p class="flabel">mode</p>
328
+ <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
329
+ <input id="taskTitle" placeholder="Task title" />
330
+ <textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
331
+ <p class="flabel">design capture · optional</p>
332
+ <select id="taskCapture"><option value="">no design capture</option></select>
333
+ </div>
334
+ <div id="panelGoal" hidden style="flex-direction:column;gap:8px">
335
+ <textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge with Select runs → Integrate."></textarea>
336
+ </div>
337
+ <div id="panelBench" hidden style="flex-direction:column;gap:8px">
338
+ <input id="benchTitle" placeholder="Workbench name · optional" />
339
+ <p style="font-size:11.5px;color:var(--faint);margin:0;line-height:1.55">Isolated worktree + terminal. Work interactively — run <span style="color:var(--brand);font-family:var(--mono)">claude</span> inside, take hours — then decide the merge from the card.</p>
340
+ </div>
325
341
  <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
326
342
  <button type="button" class="seg-opt" data-real="0">Dry run</button>
327
343
  <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
328
344
  </div>
329
345
  <input type="checkbox" id="taskReal" hidden />
330
346
  <div class="row">
331
- <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="number of agents — 1 for a job, N to explore variants" />
347
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
332
348
  <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
333
349
  </div>
334
350
  </form>
335
351
  </div>
336
- <div class="sect">
337
- <p class="sect-label">Plan a goal · swarm</p>
338
- <form id="planForm">
339
- <p class="flabel">repository</p>
340
- <select id="planRepo"></select>
341
- <p class="flabel">goal</p>
342
- <textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge later with Select runs → Integrate."></textarea>
343
- <button class="btn" type="submit" id="planGo">Plan &amp; fan out</button>
344
- <span style="font-size:11px;color:var(--faint);font-family:var(--mono)">follows the Dry/Real mode above · planner reads only</span>
345
- </form>
346
- </div>
347
- <div class="sect">
348
- <p class="sect-label">Design captures</p>
349
- <div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
350
- <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px"
352
+
353
+ <details class="sect" id="libraryBox">
354
+ <summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
355
+ <div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
356
+ <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
351
357
  title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
352
358
  <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
353
359
  With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
354
- </div>
360
+ </details>
355
361
  </aside>
356
362
  <main>
357
363
  <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
@@ -610,7 +616,11 @@ function humanize(e){
610
616
  if (kind === 'stderr') return { k:'stderr', t:payload };
611
617
  try{
612
618
  const o = JSON.parse(payload);
613
- if (o.type === 'system') return { k:'session', t:'started · '+(o.model||o.subtype||'') };
619
+ if (o.type === 'system'){
620
+ if (o.subtype === 'init' || !o.subtype) return { k:'session', t:'started'+(o.model?' · '+o.model:'') };
621
+ if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
622
+ return null; // thinking_tokens 등 스트림 잡음
623
+ }
614
624
  if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
615
625
  if (o.type === 'assistant' && o.message){
616
626
  const parts = [];
@@ -628,7 +638,17 @@ function humanize(e){
628
638
  if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
629
639
  if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
630
640
  return { k:kind, t:payload.slice(0,140) };
631
- }catch{ return { k:kind, t:payload }; }
641
+ }catch{
642
+ // 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
643
+ // text 조각만 구제하고, 없으면 생략.
644
+ if (payload.trim().startsWith('{')){
645
+ const texts = [];
646
+ const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
647
+ while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
648
+ return texts.length ? { k:'said', t:texts.join(' · ') } : null;
649
+ }
650
+ return { k:kind, t:payload };
651
+ }
632
652
  }
633
653
  function humanLines(events){
634
654
  const out = [];
@@ -753,23 +773,19 @@ async function hydrate(){
753
773
  function paintSidebar(){
754
774
  $('machines').innerHTML = machines.map(m =>
755
775
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
756
- $('repos').innerHTML = repos.map(r =>
757
- '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
758
- + '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
759
- + '<div class="path">'+esc(r.path)+'</div></div>').join('')
760
- || '<div class="repo" style="color:var(--faint)">none registered</div>';
761
776
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
777
+ const prevRepo = $('taskRepo').value;
762
778
  $('taskRepo').innerHTML = repos.length
763
- ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
764
- : '<option value="">register a repo first ↑</option>';
779
+ ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+' · '+esc(r.defaultBranch)+'</option>').join('')
780
+ : '<option value="">register a repo Browse… ↓</option>';
781
+ if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
765
782
  $('runFleetBtn').disabled = !repos.length;
766
783
  const capSel = $('taskCapture');
767
784
  const cur = capSel.value;
768
785
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
769
786
  '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
770
787
  capSel.value = cur;
771
- $('planRepo').innerHTML = $('taskRepo').innerHTML;
772
- ['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
788
+ ['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
773
789
  $('captures').innerHTML = captures.map(c=>
774
790
  '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
775
791
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
@@ -783,16 +799,21 @@ $('captures').addEventListener('click', async (e)=>{
783
799
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
784
800
  hydrate();
785
801
  });
786
- $('repos').addEventListener('click', async (e)=>{
787
- const b = e.target.closest('button[data-delrepo]'); if(!b) return;
788
- const yes = await confirmUI('Remove this repository from coxpit?',
802
+ $('repoRemove').addEventListener('click', async ()=>{
803
+ const rid = $('taskRepo').value;
804
+ if (!rid){ toast('no repository selected', 'error'); return; }
805
+ const yes = await confirmUI('Remove the selected repository from coxpit?',
789
806
  { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
790
807
  if (!yes) return;
791
- const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
808
+ const res = await fetch('/api/repos/'+rid,{method:'DELETE'});
792
809
  const j = await res.json().catch(()=>({}));
793
810
  if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
794
811
  else toast('remove: '+(j.detail||res.status), 'error');
795
812
  });
813
+ $('repoManual').addEventListener('click', ()=>{
814
+ const f = $('repoForm'); f.hidden = !f.hidden;
815
+ if (!f.hidden) $('repoPath').focus();
816
+ });
796
817
 
797
818
  /* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
798
819
  let notifyOn = false;
@@ -1158,28 +1179,53 @@ $('mTerm').addEventListener('click', ()=>{
1158
1179
  });
1159
1180
 
1160
1181
  /* ── forms ── */
1161
- $('planForm').addEventListener('submit', async (e)=>{
1162
- e.preventDefault();
1163
- const repoId = Number($('planRepo').value);
1182
+ /* ── 런처 탭 — 하나의 시작점: Task | Goal | Workbench ── */
1183
+ let lTab = 'task';
1184
+ const L_LABEL = { task: 'Run fleet', goal: 'Plan & fan out', bench: 'Open workbench' };
1185
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(b=>{
1186
+ b.addEventListener('click', ()=>{
1187
+ lTab = b.dataset.tab;
1188
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
1189
+ $('panelTask').style.display = lTab==='task' ? 'flex' : 'none';
1190
+ $('panelGoal').hidden = lTab!=='goal';
1191
+ $('panelGoal').style.display = lTab==='goal' ? 'flex' : 'none';
1192
+ $('panelBench').hidden = lTab!=='bench';
1193
+ $('panelBench').style.display = lTab==='bench' ? 'flex' : 'none';
1194
+ $('modeSeg').style.display = lTab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
1195
+ $('taskCount').style.display = lTab==='task' ? '' : 'none';
1196
+ $('runFleetBtn').textContent = L_LABEL[lTab];
1197
+ });
1198
+ });
1199
+
1200
+ async function submitGoal(){
1201
+ const repoId = Number($('taskRepo').value);
1164
1202
  const goal = $('planGoal').value.trim();
1165
1203
  if (!repoId){ toast('register a repo first', 'error'); return; }
1166
1204
  if (!goal){ toast('write a goal first', 'error'); return; }
1167
1205
  const real = $('taskReal').checked;
1168
- const btn = $('planGo');
1206
+ const btn = $('runFleetBtn');
1169
1207
  btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
1170
1208
  try{
1171
1209
  const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
1172
1210
  body:JSON.stringify({repoId, goal, real})});
1173
1211
  const j = await res.json().catch(()=>({}));
1174
- if (res.ok){
1175
- toast(j.tasks.length+' task(s) planned & launched', 'ok');
1176
- $('planGoal').value='';
1177
- hydrate();
1178
- } else toast('plan: '+(j.detail||j.error||res.status), 'error');
1179
- } finally {
1180
- btn.disabled = false; btn.textContent = 'Plan & fan out';
1181
- }
1182
- });
1212
+ if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; hydrate(); }
1213
+ else toast('plan: '+(j.detail||j.error||res.status), 'error');
1214
+ } finally { btn.disabled = false; btn.textContent = L_LABEL[lTab]; }
1215
+ }
1216
+
1217
+ async function submitBench(){
1218
+ const repoId = Number($('taskRepo').value);
1219
+ if (!repoId){ toast('register a repo first', 'error'); return; }
1220
+ const res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
1221
+ body:JSON.stringify({repoId, title: $('benchTitle').value.trim()})});
1222
+ const j = await res.json().catch(()=>({}));
1223
+ if (!res.ok){ toast('workbench: '+(j.detail||j.error||res.status), 'error'); return; }
1224
+ toast('workbench open — terminal attached', 'ok');
1225
+ $('benchTitle').value='';
1226
+ await hydrate();
1227
+ openTerm(j.runId); // 바로 터미널로
1228
+ }
1183
1229
 
1184
1230
  $('repoForm').addEventListener('submit', async (e)=>{
1185
1231
  e.preventDefault();
@@ -1191,9 +1237,11 @@ $('repoForm').addEventListener('submit', async (e)=>{
1191
1237
  });
1192
1238
  $('taskForm').addEventListener('submit', async (e)=>{
1193
1239
  e.preventDefault();
1240
+ if (lTab === 'goal') return submitGoal();
1241
+ if (lTab === 'bench') return submitBench();
1194
1242
  const repoId = Number($('taskRepo').value);
1195
1243
  const title = $('taskTitle').value.trim();
1196
- if (!repoId || !title) return;
1244
+ if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
1197
1245
  const capId = Number($('taskCapture').value) || undefined;
1198
1246
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
1199
1247
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
@@ -155,8 +155,12 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
155
155
  const s = line.trim();
156
156
  if (!s) return;
157
157
  let kind = 'log';
158
+ let stored = s;
158
159
  try {
159
- const obj = JSON.parse(s) as { type?: string; result?: string; session_id?: string };
160
+ const obj = JSON.parse(s) as {
161
+ type?: string; subtype?: string; model?: string; result?: string; session_id?: string;
162
+ message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, unknown> }> };
163
+ };
160
164
  if (obj.type) kind = obj.type;
161
165
  // steer(--resume) 용 세션 키 캡처
162
166
  if (obj.type === 'system' && typeof obj.session_id === 'string') {
@@ -164,8 +168,28 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
164
168
  }
165
169
  // result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
166
170
  if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
167
- } catch { /* 비-JSON 로그 라인 */ }
168
- void recordEvent(runId, kind, s.slice(0, 2000));
171
+ // 2000자 초과 이벤트는 자르면 JSON 깨져 잔해가 화면에 노출된다 —
172
+ // 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
173
+ if (s.length > 2000) {
174
+ if (obj.type === 'assistant' && obj.message) {
175
+ const content = (obj.message.content ?? [])
176
+ .filter((c) => c.type === 'text' || c.type === 'tool_use')
177
+ .map((c) => c.type === 'text'
178
+ ? { type: 'text', text: (c.text ?? '').slice(0, 600) }
179
+ : { type: 'tool_use', name: c.name, input: compactInput(c.input) });
180
+ stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
181
+ } else if (obj.type === 'user') {
182
+ stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
183
+ } else if (obj.type === 'system') {
184
+ stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
185
+ } else if (obj.type === 'result') {
186
+ stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
187
+ } else {
188
+ stored = s.slice(0, 2000);
189
+ }
190
+ }
191
+ } catch { stored = s.slice(0, 2000); /* 비-JSON 로그 라인 */ }
192
+ void recordEvent(runId, kind, stored.slice(0, 2000));
169
193
  });
170
194
  }
171
195
  if (child.stderr) {
@@ -192,6 +216,16 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
192
216
  void notifySettle(runId, status, filesChanged, exitSummary);
193
217
  }
194
218
 
219
+ /** tool_use input 을 표시용 핵심 필드만 남긴다(이벤트 압축용). */
220
+ function compactInput(input?: Record<string, unknown>): Record<string, string> {
221
+ const out: Record<string, string> = {};
222
+ if (!input) return out;
223
+ for (const k of ['file_path', 'command', 'path', 'pattern', 'url']) {
224
+ if (typeof input[k] === 'string') out[k] = (input[k] as string).slice(0, 200);
225
+ }
226
+ return out;
227
+ }
228
+
195
229
  /** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
196
230
  async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
197
231
  if (!config.webhookUrl) return;
@@ -379,6 +413,49 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
379
413
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
380
414
  }
381
415
 
416
+ /**
417
+ * Workbench — 인터랙티브 작업방: 격리 worktree + tmux 만 만들고 에이전트는
418
+ * 띄우지 않는다. 사람이 터미널로 들어가(원하면 claude TUI 로) 오래 작업하고,
419
+ * coxpit 은 diff·merge·PR·export 레일만 제공한다. status='open'.
420
+ */
421
+ export async function openWorkbench(repoId: number, title: string): Promise<{
422
+ ok: boolean; detail: string; taskId?: number; runId?: number;
423
+ }> {
424
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
425
+ const repo = rp[0];
426
+ if (!repo) return { ok: false, detail: 'repo not found' };
427
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
428
+ const m = mr[0];
429
+ if (!m) return { ok: false, detail: 'machine not found' };
430
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
431
+
432
+ const tIns = await db.insert(tasks).values({ repoId, title: title || 'Workbench', prompt: '(interactive workbench)' }).returning();
433
+ const task = tIns[0]!;
434
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'workbench', status: 'pending' }).returning();
435
+ const run = rIns[0]!;
436
+ const runId = run.id;
437
+ broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'workbench', branch: '', filesChanged: 0 });
438
+
439
+ const branch = `coxpit/r${runId}`;
440
+ const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
441
+ const wtPath = ppath.join(wtParent, `r${runId}`);
442
+ const session = `coxpit-r${runId}`;
443
+
444
+ const prep = await runShellOn(
445
+ machine,
446
+ `mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
447
+ ` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
448
+ 20000,
449
+ );
450
+ if (!prep.ok) {
451
+ await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'workbench prep failed' });
452
+ return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
453
+ }
454
+ await setRun(runId, { status: 'open', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
455
+ await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench: true }));
456
+ return { ok: true, detail: 'workbench open', taskId: task.id, runId };
457
+ }
458
+
382
459
  /**
383
460
  * Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
384
461
  * 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
@@ -601,7 +678,7 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
601
678
  const run = rr[0];
602
679
  if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
603
680
  if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
604
- if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
681
+ if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
605
682
  const wt = shq(run.worktreePath);
606
683
 
607
684
  // 0) 사전 조건: origin 리모트 + gh CLI
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { db } from './db';
12
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
13
13
  import { BOOKMARKLET_JS } from './design';
14
14
  import { runShellOn, shq } from './exec';
15
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun } from './orchestrator';
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
18
  import { BOARD_HTML } from './board';
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
32
  app.addHook('onRequest', authGate);
33
33
 
34
34
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.0.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.1.0' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -422,6 +422,16 @@ export async function buildServer(): Promise<FastifyInstance> {
422
422
  return res;
423
423
  });
424
424
 
425
+ // Workbench — 인터랙티브 작업방(worktree+tmux, 에이전트 없음).
426
+ app.post('/api/workbench', async (req, reply) => {
427
+ const b = (req.body ?? {}) as { repoId?: number; title?: string };
428
+ const repoId = Number(b.repoId);
429
+ if (!repoId) return reply.code(400).send({ error: 'repoId required' });
430
+ const res = await openWorkbench(repoId, (b.title ?? '').trim());
431
+ if (!res.ok) return reply.code(422).send(res);
432
+ return reply.code(201).send(res);
433
+ });
434
+
425
435
  // Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
426
436
  // (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
427
437
  app.post('/api/plan', async (req, reply) => {
@@ -491,7 +501,7 @@ export async function buildServer(): Promise<FastifyInstance> {
491
501
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
492
502
  app.get('/ws', { websocket: true }, (socket) => {
493
503
  addSink(socket);
494
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.0.0' }));
504
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.1.0' }));
495
505
  socket.on('close', () => removeSink(socket));
496
506
  });
497
507