coxpit 3.0.1 → 3.2.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,16 +1,40 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.0.1",
3
+ "version": "3.2.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",
7
- "repository": { "type": "git", "url": "git+https://github.com/hanmariyang/coxpit-oss.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hanmariyang/coxpit-oss.git"
10
+ },
8
11
  "homepage": "https://hanmariyang.github.io/coxpit-oss/",
9
- "bugs": { "url": "https://github.com/hanmariyang/coxpit-oss/issues" },
10
- "keywords": ["ai-agents", "claude-code", "git-worktree", "tmux", "self-hosted", "fleet", "coding-agents", "cockpit"],
11
- "engines": { "node": ">=20" },
12
- "bin": { "coxpit": "bin/coxpit.js" },
13
- "files": ["bin", "src", "README.md", "LICENSE", ".env.example"],
12
+ "bugs": {
13
+ "url": "https://github.com/hanmariyang/coxpit-oss/issues"
14
+ },
15
+ "keywords": [
16
+ "ai-agents",
17
+ "claude-code",
18
+ "git-worktree",
19
+ "tmux",
20
+ "self-hosted",
21
+ "fleet",
22
+ "coding-agents",
23
+ "cockpit"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "bin": {
29
+ "coxpit": "bin/coxpit.js"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "README.md",
35
+ "LICENSE",
36
+ ".env.example"
37
+ ],
14
38
  "scripts": {
15
39
  "dev": "tsx watch src/index.ts",
16
40
  "start": "tsx src/index.ts",
@@ -21,6 +45,7 @@
21
45
  "@fastify/websocket": "^11",
22
46
  "@libsql/client": "^0.14",
23
47
  "@xterm/addon-fit": "^0.11.0",
48
+ "@xterm/addon-unicode11": "^0.9.0",
24
49
  "@xterm/xterm": "^6.0.0",
25
50
  "dotenv": "^16",
26
51
  "drizzle-orm": "^0.38",
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}
@@ -228,7 +229,17 @@ export const BOARD_HTML = /* html */ `<!doctype html>
228
229
  .modal{width:min(1020px,100%);max-height:92vh;background:var(--surface);border:1px solid var(--line);
229
230
  border-radius:14px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--shadow)}
230
231
  .modal.wide{width:min(1460px,100%)}
231
- .modal.term{width:min(1200px,100%);height:min(760px,92vh)}
232
+ /* terminal = 전면 뷰 (모달 아님) — 개인 coxpit 터미널 페이지 패리티 */
233
+ #termOverlay.open{padding:0}
234
+ .modal.term{width:100%;height:100%;max-height:none;border:none;border-radius:0}
235
+ .modal.term .modal-h{padding:9px 14px;background:var(--surface2)}
236
+ .modal.term .modal-h .title{flex:0 1 auto;max-width:240px}
237
+ .term-tabs{display:flex;gap:6px;overflow-x:auto;flex:1;min-width:0;scrollbar-width:none}
238
+ .term-tabs::-webkit-scrollbar{display:none}
239
+ .ttab{font-family:var(--mono);font-size:11px;color:var(--muted);background:var(--surface);
240
+ border:1px solid var(--line);border-radius:6px;padding:3px 10px;cursor:pointer;white-space:nowrap;flex:0 0 auto}
241
+ .ttab:hover{color:var(--ink)}
242
+ .ttab.on{color:var(--ink);border-color:var(--brand);background:rgba(78,201,176,.08)}
232
243
  .modal-h{display:flex;align-items:center;gap:10px;padding:13px 18px;border-bottom:1px solid var(--line)}
233
244
  .modal-h .rid{font-family:var(--mono);font-size:12px;color:var(--faint)}
234
245
  .modal-h .title{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -298,60 +309,65 @@ export const BOARD_HTML = /* html */ `<!doctype html>
298
309
  <div class="layout">
299
310
  <aside>
300
311
  <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>
312
+ <p class="sect-label">Context</p>
313
+ <p class="flabel">machine</p>
314
+ <select id="repoMachine"></select>
315
+ <p class="flabel">repository</p>
316
+ <select id="taskRepo"></select>
317
+ <div class="row">
318
+ <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
319
+ <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
320
+ <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
321
+ </div>
322
+ <form id="repoForm" hidden>
308
323
  <div class="row">
309
- <input id="repoPath" placeholder="or type an absolute path" />
324
+ <input id="repoPath" placeholder="/abs/path/to/repo" />
310
325
  <button class="btn-ghost sm" type="submit" style="flex:0 0 auto">Register</button>
311
326
  </div>
312
327
  </form>
313
328
  </div>
329
+
314
330
  <div class="sect">
315
- <p class="sect-label">Launch agents</p>
331
+ <p class="sect-label">Start</p>
332
+ <div class="seg" id="launchTabs">
333
+ <button type="button" class="seg-opt on" data-tab="task">Task</button>
334
+ <button type="button" class="seg-opt" data-tab="goal">Goal</button>
335
+ <button type="button" class="seg-opt" data-tab="bench">Workbench</button>
336
+ </div>
316
337
  <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>
338
+ <div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
339
+ <input id="taskTitle" placeholder="Task title" />
340
+ <textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
341
+ <p class="flabel">design capture · optional</p>
342
+ <select id="taskCapture"><option value="">no design capture</option></select>
343
+ </div>
344
+ <div id="panelGoal" hidden style="flex-direction:column;gap:8px">
345
+ <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>
346
+ </div>
347
+ <div id="panelBench" hidden style="flex-direction:column;gap:8px">
348
+ <input id="benchTitle" placeholder="Workbench name · optional" />
349
+ <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>
350
+ </div>
325
351
  <div class="seg" id="modeSeg" role="group" aria-label="agent mode">
326
352
  <button type="button" class="seg-opt" data-real="0">Dry run</button>
327
353
  <button type="button" class="seg-opt" data-real="1">Real agent<span class="seg-hint">spends credits</span></button>
328
354
  </div>
329
355
  <input type="checkbox" id="taskReal" hidden />
330
356
  <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" />
357
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
332
358
  <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
333
359
  </div>
334
360
  </form>
335
361
  </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"
362
+
363
+ <details class="sect" id="libraryBox">
364
+ <summary class="sect-label" style="cursor:pointer;list-style:none">Library · design captures ▾</summary>
365
+ <div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
366
+ <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
351
367
  title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
352
368
  <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
353
369
  With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
354
- </div>
370
+ </details>
355
371
  </aside>
356
372
  <main>
357
373
  <div class="toolbar"><button class="btn-ghost sm" id="selToggle">Select runs</button></div>
@@ -422,7 +438,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
422
438
  <div class="modal-h">
423
439
  <span class="rid" id="termRid"></span>
424
440
  <span class="title" id="termTitle">terminal</span>
425
- <span class="term-hint">tmux session · Ctrl-b d detaches</span>
441
+ <div class="term-tabs" id="termTabs"></div>
442
+ <span class="term-hint">tmux session · Ctrl-b d detaches · Esc closes</span>
426
443
  <button class="x" id="termClose" aria-label="close">×</button>
427
444
  </div>
428
445
  <div class="term-body"><div id="xterm"></div></div>
@@ -481,6 +498,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
481
498
 
482
499
  <script src="/vendor/xterm.js"></script>
483
500
  <script src="/vendor/addon-fit.js"></script>
501
+ <script src="/vendor/addon-unicode11.js"></script>
484
502
  <script>
485
503
  const runs = new Map(); // runId -> run object
486
504
  const tasks = new Map(); // taskId -> task
@@ -680,6 +698,7 @@ function render(){
680
698
  $('empty').style.display = list.length ? 'none' : 'flex';
681
699
  if (!list.length) paintOnboarding();
682
700
  $('grid').innerHTML = list.map(cardHTML).join('');
701
+ if (termRunId!=null) termTabsRender(); // 터미널 열려있으면 세션 탭도 동기화
683
702
  }
684
703
 
685
704
  /* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
@@ -767,23 +786,19 @@ async function hydrate(){
767
786
  function paintSidebar(){
768
787
  $('machines').innerHTML = machines.map(m =>
769
788
  '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
770
- $('repos').innerHTML = repos.map(r =>
771
- '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
772
- + '<button class="x" data-delrepo="'+r.id+'" title="remove repo" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
773
- + '<div class="path">'+esc(r.path)+'</div></div>').join('')
774
- || '<div class="repo" style="color:var(--faint)">none registered</div>';
775
789
  $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
790
+ const prevRepo = $('taskRepo').value;
776
791
  $('taskRepo').innerHTML = repos.length
777
- ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('')
778
- : '<option value="">register a repo first ↑</option>';
792
+ ? repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+' · '+esc(r.defaultBranch)+'</option>').join('')
793
+ : '<option value="">register a repo Browse… ↓</option>';
794
+ if (prevRepo && repos.some(r=>String(r.id)===prevRepo)) $('taskRepo').value = prevRepo;
779
795
  $('runFleetBtn').disabled = !repos.length;
780
796
  const capSel = $('taskCapture');
781
797
  const cur = capSel.value;
782
798
  capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
783
799
  '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
784
800
  capSel.value = cur;
785
- $('planRepo').innerHTML = $('taskRepo').innerHTML;
786
- ['repoMachine','taskRepo','taskCapture','planRepo'].forEach(id => { dressSelect(id); syncSelect(id); });
801
+ ['repoMachine','taskRepo','taskCapture'].forEach(id => { dressSelect(id); syncSelect(id); });
787
802
  $('captures').innerHTML = captures.map(c=>
788
803
  '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
789
804
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
@@ -797,16 +812,21 @@ $('captures').addEventListener('click', async (e)=>{
797
812
  await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
798
813
  hydrate();
799
814
  });
800
- $('repos').addEventListener('click', async (e)=>{
801
- const b = e.target.closest('button[data-delrepo]'); if(!b) return;
802
- const yes = await confirmUI('Remove this repository from coxpit?',
815
+ $('repoRemove').addEventListener('click', async ()=>{
816
+ const rid = $('taskRepo').value;
817
+ if (!rid){ toast('no repository selected', 'error'); return; }
818
+ const yes = await confirmUI('Remove the selected repository from coxpit?',
803
819
  { sub: 'The repo itself is untouched — only the registration is removed. Refused while it has open tasks.', danger: true, okLabel: 'Remove' });
804
820
  if (!yes) return;
805
- const res = await fetch('/api/repos/'+b.dataset.delrepo,{method:'DELETE'});
821
+ const res = await fetch('/api/repos/'+rid,{method:'DELETE'});
806
822
  const j = await res.json().catch(()=>({}));
807
823
  if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
808
824
  else toast('remove: '+(j.detail||res.status), 'error');
809
825
  });
826
+ $('repoManual').addEventListener('click', ()=>{
827
+ const f = $('repoForm'); f.hidden = !f.hidden;
828
+ if (!f.hidden) $('repoPath').focus();
829
+ });
810
830
 
811
831
  /* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
812
832
  let notifyOn = false;
@@ -1117,38 +1137,79 @@ $('mCompare').addEventListener('click', ()=>{
1117
1137
  closeModal(); openCompare(r.taskId);
1118
1138
  });
1119
1139
 
1120
- /* ── terminal ── */
1140
+ /* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
1121
1141
  let termWS = null, termObj = null, fitAddon = null, termResizeObs = null;
1142
+ let termRunId = null, termClosing = false, termRetry = 0, termRetryTimer = null;
1143
+ function termConnect(){
1144
+ if (termRunId==null || termClosing || !termObj) return;
1145
+ const proto = location.protocol==='https:'?'wss':'ws';
1146
+ termWS = new WebSocket(proto+'://'+location.host+'/ws/term/'+termRunId
1147
+ +'?cols='+termObj.cols+'&rows='+termObj.rows);
1148
+ termWS.onopen = ()=>{
1149
+ termRetry = 0;
1150
+ $('termTitle').textContent = ((runs.get(termRunId)||{}).tmuxWindow||'terminal');
1151
+ termObj.focus();
1152
+ };
1153
+ termWS.onmessage = (m)=>{
1154
+ try{
1155
+ const d = JSON.parse(m.data);
1156
+ if (d.t==='o') termObj.write(d.d);
1157
+ else if (d.t==='err') termObj.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
1158
+ else if (d.t==='exit') termObj.write('\\r\\n\\x1b[90m[session ended — reconnecting will revive it]\\x1b[0m\\r\\n');
1159
+ }catch{}
1160
+ };
1161
+ termWS.onclose = ()=>{
1162
+ if (termClosing || termRunId==null) return;
1163
+ // 예기치 않은 끊김 — 백오프 재연결(서버가 죽은 세션도 소생시킴)
1164
+ const delay = Math.min(8000, 800 * Math.pow(2, termRetry++));
1165
+ $('termTitle').textContent = 'reconnecting…';
1166
+ termRetryTimer = setTimeout(termConnect, delay);
1167
+ };
1168
+ }
1169
+ /* 세션 탭 — 살아있는 세션(running·open) 사이를 터미널 안에서 바로 전환 */
1170
+ function termTabsRender(){
1171
+ const el = $('termTabs');
1172
+ const list = [...runs.values()].filter(r=>r.status==='running'||r.status==='open').sort((a,b)=>a.id-b.id);
1173
+ el.innerHTML = list.map(r=>{
1174
+ const kind = r.agent==='workbench' ? 'bench' : (r.agent||'agent');
1175
+ return '<button class="ttab'+(r.id===termRunId?' on':'')+'" data-id="'+r.id+'">r'+r.id+' · '+esc(kind)+'</button>';
1176
+ }).join('');
1177
+ el.querySelectorAll('.ttab').forEach(b=>b.addEventListener('click',()=>termSwitch(+b.dataset.id)));
1178
+ }
1179
+ function termSwitch(id){
1180
+ if (id===termRunId || !termObj) return;
1181
+ if (termRetryTimer){ clearTimeout(termRetryTimer); termRetryTimer=null; }
1182
+ if (termWS){ termClosing=true; try{ termWS.close(); }catch{} termWS=null; }
1183
+ termClosing = false; termRetry = 0; termRunId = id;
1184
+ $('termRid').textContent = 'r'+id;
1185
+ $('termTitle').textContent = ((runs.get(id)||{}).tmuxWindow||'terminal');
1186
+ termObj.reset();
1187
+ termTabsRender();
1188
+ termConnect();
1189
+ }
1122
1190
  function openTerm(runId){
1123
1191
  const r = runs.get(runId); if(!r) return;
1192
+ termRunId = runId; termClosing = false; termRetry = 0;
1124
1193
  $('termRid').textContent = 'r'+runId;
1125
1194
  $('termTitle').textContent = (r.tmuxWindow||'terminal');
1195
+ termTabsRender();
1126
1196
  $('termOverlay').classList.add('open');
1127
1197
  const el = $('xterm'); el.innerHTML = '';
1128
1198
  termObj = new window.Terminal({
1129
- fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
1130
- fontSize: 12.5, cursorBlink: true,
1199
+ fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', monospace",
1200
+ fontSize: 12.5, cursorBlink: true, allowProposedApi: true,
1131
1201
  theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0',
1132
1202
  selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
1133
1203
  });
1134
1204
  fitAddon = new window.FitAddon.FitAddon();
1135
1205
  termObj.loadAddon(fitAddon);
1206
+ try{ // 이모지·CJK 폭 보정 — TUI(claude) 줄 밀림 방지
1207
+ termObj.loadAddon(new window.Unicode11Addon.Unicode11Addon());
1208
+ termObj.unicode.activeVersion = '11';
1209
+ }catch{}
1136
1210
  termObj.open(el);
1137
- fitAddon.fit();
1138
- const proto = location.protocol==='https:'?'wss':'ws';
1139
- termWS = new WebSocket(proto+'://'+location.host+'/ws/term/'+runId);
1140
- termWS.onopen = ()=>{
1141
- termWS.send(JSON.stringify({t:'r',cols:termObj.cols,rows:termObj.rows}));
1142
- termObj.focus();
1143
- };
1144
- termWS.onmessage = (m)=>{
1145
- try{
1146
- const d = JSON.parse(m.data);
1147
- if (d.t==='o') termObj.write(d.d);
1148
- else if (d.t==='err') termObj.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
1149
- else if (d.t==='exit') termObj.write('\\r\\n\\x1b[90m[detached]\\x1b[0m\\r\\n');
1150
- }catch{}
1151
- };
1211
+ fitAddon.fit(); // 접속 전에 크기 확정 → 80x24 경유 없이 바로 정사이즈 attach
1212
+ termConnect();
1152
1213
  termObj.onData((d)=>{ if(termWS && termWS.readyState===1) termWS.send(JSON.stringify({t:'i',d})); });
1153
1214
  termResizeObs = new ResizeObserver(()=>{
1154
1215
  if (!fitAddon || !termObj) return;
@@ -1158,6 +1219,8 @@ function openTerm(runId){
1158
1219
  termResizeObs.observe(el);
1159
1220
  }
1160
1221
  function closeTerm(){
1222
+ termClosing = true; termRunId = null;
1223
+ if (termRetryTimer){ clearTimeout(termRetryTimer); termRetryTimer=null; }
1161
1224
  $('termOverlay').classList.remove('open');
1162
1225
  if (termResizeObs){ termResizeObs.disconnect(); termResizeObs=null; }
1163
1226
  if (termWS){ try{ termWS.close(); }catch{} termWS=null; }
@@ -1172,28 +1235,53 @@ $('mTerm').addEventListener('click', ()=>{
1172
1235
  });
1173
1236
 
1174
1237
  /* ── forms ── */
1175
- $('planForm').addEventListener('submit', async (e)=>{
1176
- e.preventDefault();
1177
- const repoId = Number($('planRepo').value);
1238
+ /* ── 런처 탭 — 하나의 시작점: Task | Goal | Workbench ── */
1239
+ let lTab = 'task';
1240
+ const L_LABEL = { task: 'Run fleet', goal: 'Plan & fan out', bench: 'Open workbench' };
1241
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(b=>{
1242
+ b.addEventListener('click', ()=>{
1243
+ lTab = b.dataset.tab;
1244
+ document.querySelectorAll('#launchTabs .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
1245
+ $('panelTask').style.display = lTab==='task' ? 'flex' : 'none';
1246
+ $('panelGoal').hidden = lTab!=='goal';
1247
+ $('panelGoal').style.display = lTab==='goal' ? 'flex' : 'none';
1248
+ $('panelBench').hidden = lTab!=='bench';
1249
+ $('panelBench').style.display = lTab==='bench' ? 'flex' : 'none';
1250
+ $('modeSeg').style.display = lTab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
1251
+ $('taskCount').style.display = lTab==='task' ? '' : 'none';
1252
+ $('runFleetBtn').textContent = L_LABEL[lTab];
1253
+ });
1254
+ });
1255
+
1256
+ async function submitGoal(){
1257
+ const repoId = Number($('taskRepo').value);
1178
1258
  const goal = $('planGoal').value.trim();
1179
1259
  if (!repoId){ toast('register a repo first', 'error'); return; }
1180
1260
  if (!goal){ toast('write a goal first', 'error'); return; }
1181
1261
  const real = $('taskReal').checked;
1182
- const btn = $('planGo');
1262
+ const btn = $('runFleetBtn');
1183
1263
  btn.disabled = true; btn.textContent = real ? 'Planning… (1–3 min)' : 'Planning…';
1184
1264
  try{
1185
1265
  const res = await fetch('/api/plan',{method:'POST',headers:{'content-type':'application/json'},
1186
1266
  body:JSON.stringify({repoId, goal, real})});
1187
1267
  const j = await res.json().catch(()=>({}));
1188
- if (res.ok){
1189
- toast(j.tasks.length+' task(s) planned & launched', 'ok');
1190
- $('planGoal').value='';
1191
- hydrate();
1192
- } else toast('plan: '+(j.detail||j.error||res.status), 'error');
1193
- } finally {
1194
- btn.disabled = false; btn.textContent = 'Plan & fan out';
1195
- }
1196
- });
1268
+ if (res.ok){ toast(j.tasks.length+' task(s) planned & launched', 'ok'); $('planGoal').value=''; hydrate(); }
1269
+ else toast('plan: '+(j.detail||j.error||res.status), 'error');
1270
+ } finally { btn.disabled = false; btn.textContent = L_LABEL[lTab]; }
1271
+ }
1272
+
1273
+ async function submitBench(){
1274
+ const repoId = Number($('taskRepo').value);
1275
+ if (!repoId){ toast('register a repo first', 'error'); return; }
1276
+ const res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
1277
+ body:JSON.stringify({repoId, title: $('benchTitle').value.trim()})});
1278
+ const j = await res.json().catch(()=>({}));
1279
+ if (!res.ok){ toast('workbench: '+(j.detail||j.error||res.status), 'error'); return; }
1280
+ toast('workbench open — terminal attached', 'ok');
1281
+ $('benchTitle').value='';
1282
+ await hydrate();
1283
+ openTerm(j.runId); // 바로 터미널로
1284
+ }
1197
1285
 
1198
1286
  $('repoForm').addEventListener('submit', async (e)=>{
1199
1287
  e.preventDefault();
@@ -1205,9 +1293,11 @@ $('repoForm').addEventListener('submit', async (e)=>{
1205
1293
  });
1206
1294
  $('taskForm').addEventListener('submit', async (e)=>{
1207
1295
  e.preventDefault();
1296
+ if (lTab === 'goal') return submitGoal();
1297
+ if (lTab === 'bench') return submitBench();
1208
1298
  const repoId = Number($('taskRepo').value);
1209
1299
  const title = $('taskTitle').value.trim();
1210
- if (!repoId || !title) return;
1300
+ if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
1211
1301
  const capId = Number($('taskCapture').value) || undefined;
1212
1302
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
1213
1303
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
@@ -122,8 +122,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
122
122
  return;
123
123
  }
124
124
 
125
- // 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort
126
- await runShellOn(ctx.machine, `tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
125
+ // 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치)
126
+ await runShellOn(ctx.machine,
127
+ `tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
127
128
 
128
129
  await setRun(runId, { status: 'running' });
129
130
  await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
@@ -413,6 +414,51 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
413
414
  return { ok: true, detail: mg.stdout.trim().slice(0, 300) };
414
415
  }
415
416
 
417
+ /**
418
+ * Workbench — 인터랙티브 작업방: 격리 worktree + tmux 만 만들고 에이전트는
419
+ * 띄우지 않는다. 사람이 터미널로 들어가(원하면 claude TUI 로) 오래 작업하고,
420
+ * coxpit 은 diff·merge·PR·export 레일만 제공한다. status='open'.
421
+ */
422
+ export async function openWorkbench(repoId: number, title: string): Promise<{
423
+ ok: boolean; detail: string; taskId?: number; runId?: number;
424
+ }> {
425
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
426
+ const repo = rp[0];
427
+ if (!repo) return { ok: false, detail: 'repo not found' };
428
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
429
+ const m = mr[0];
430
+ if (!m) return { ok: false, detail: 'machine not found' };
431
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
432
+
433
+ const tIns = await db.insert(tasks).values({ repoId, title: title || 'Workbench', prompt: '(interactive workbench)' }).returning();
434
+ const task = tIns[0]!;
435
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'workbench', status: 'pending' }).returning();
436
+ const run = rIns[0]!;
437
+ const runId = run.id;
438
+ broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'workbench', branch: '', filesChanged: 0 });
439
+
440
+ const branch = `coxpit/r${runId}`;
441
+ const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
442
+ const wtPath = ppath.join(wtParent, `r${runId}`);
443
+ const session = `coxpit-r${runId}`;
444
+
445
+ const prep = await runShellOn(
446
+ machine,
447
+ // 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만
448
+ `mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
449
+ ` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
450
+ ` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
451
+ 20000,
452
+ );
453
+ if (!prep.ok) {
454
+ await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'workbench prep failed' });
455
+ return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
456
+ }
457
+ await setRun(runId, { status: 'open', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
458
+ await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench: true }));
459
+ return { ok: true, detail: 'workbench open', taskId: task.id, runId };
460
+ }
461
+
416
462
  /**
417
463
  * Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
418
464
  * 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
@@ -635,7 +681,7 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
635
681
  const run = rr[0];
636
682
  if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
637
683
  if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
638
- if (!['done', 'failed', 'stopped'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
684
+ if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
639
685
  const wt = shq(run.worktreePath);
640
686
 
641
687
  // 0) 사전 조건: origin 리모트 + gh CLI
@@ -676,7 +722,7 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
676
722
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
677
723
  const run = rr[0];
678
724
  if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
679
- await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
725
+ await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`=coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
680
726
  const rm = await runShellOn(
681
727
  ctx.machine,
682
728
  `git -C ${shq(ctx.repoPath)} worktree remove --force ${shq(run.worktreePath)} 2>&1` +
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';
@@ -24,6 +24,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
24
24
  'xterm.js': { pkg: '@xterm/xterm/package.json', rel: 'lib/xterm.js', type: 'text/javascript' },
25
25
  'xterm.css': { pkg: '@xterm/xterm/package.json', rel: 'css/xterm.css', type: 'text/css' },
26
26
  'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
27
+ 'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
27
28
  };
28
29
 
29
30
  export async function buildServer(): Promise<FastifyInstance> {
@@ -32,7 +33,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
33
  app.addHook('onRequest', authGate);
33
34
 
34
35
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.0.1' }));
36
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.2.0' }));
36
37
 
37
38
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
39
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -422,6 +423,16 @@ export async function buildServer(): Promise<FastifyInstance> {
422
423
  return res;
423
424
  });
424
425
 
426
+ // Workbench — 인터랙티브 작업방(worktree+tmux, 에이전트 없음).
427
+ app.post('/api/workbench', async (req, reply) => {
428
+ const b = (req.body ?? {}) as { repoId?: number; title?: string };
429
+ const repoId = Number(b.repoId);
430
+ if (!repoId) return reply.code(400).send({ error: 'repoId required' });
431
+ const res = await openWorkbench(repoId, (b.title ?? '').trim());
432
+ if (!res.ok) return reply.code(422).send(res);
433
+ return reply.code(201).send(res);
434
+ });
435
+
425
436
  // Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
426
437
  // (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
427
438
  app.post('/api/plan', async (req, reply) => {
@@ -491,7 +502,7 @@ export async function buildServer(): Promise<FastifyInstance> {
491
502
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
492
503
  app.get('/ws', { websocket: true }, (socket) => {
493
504
  addSink(socket);
494
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.0.1' }));
505
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.2.0' }));
495
506
  socket.on('close', () => removeSink(socket));
496
507
  });
497
508
 
@@ -507,30 +518,54 @@ export async function buildServer(): Promise<FastifyInstance> {
507
518
 
508
519
  // run 터미널 — tmux 세션에 PTY attach, WS 로 중계.
509
520
  // client → {t:'i',d:string} 입력 · {t:'r',cols,rows} 리사이즈 / server → {t:'o',d} 출력 · {t:'exit'}
521
+ // 하드닝: ?cols&rows 초기 크기(80x24 경유 제거) · 세션 자동 소생 · keepalive · 백프레셔.
510
522
  app.get('/ws/term/:id', { websocket: true }, async (socket, req) => {
511
523
  const id = Number((req.params as { id: string }).id);
524
+ const q = (req.query ?? {}) as { cols?: string; rows?: string };
525
+ const cols = Math.max(20, Math.min(500, Number(q.cols) || 80));
526
+ const rows = Math.max(5, Math.min(200, Number(q.rows) || 24));
512
527
  const info = await getRunTermInfo(id);
513
528
  if (!info) {
514
529
  socket.send(JSON.stringify({ t: 'err', d: 'run or tmux session not found' }));
515
530
  socket.close();
516
531
  return;
517
532
  }
518
- // 세션 생존 확인(정리됐거나 머신 재부팅이면 attach바로 죽는다)
519
- const has = await runShellOn(info.machine, `tmux has-session -t ${shq(info.session)} 2>&1`, 8000);
533
+ // 세션 자동 소생 exit 등으로 죽었어도 worktree 살아있으면 그 자리에서 재생성
534
+ const has = await runShellOn(info.machine, `tmux has-session -t ${shq('=' + info.session)} 2>&1`, 8000);
520
535
  if (!has.ok) {
521
- socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' not available` }));
522
- socket.close();
523
- return;
536
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
537
+ const wt = rr[0]?.worktreePath ?? '';
538
+ const revive = wt
539
+ ? await runShellOn(info.machine, `test -d ${shq(wt)} && tmux new-session -d -s ${shq(info.session)} -c ${shq(wt)}`, 10000)
540
+ : { ok: false } as { ok: boolean };
541
+ if (!revive.ok) {
542
+ socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' gone and could not be revived (worktree missing?)` }));
543
+ socket.close();
544
+ return;
545
+ }
546
+ socket.send(JSON.stringify({ t: 'o', d: '\r\n\x1b[90m[coxpit] session revived in the worktree\x1b[0m\r\n' }));
524
547
  }
525
548
  let term;
526
549
  try {
527
- term = openTerm(info.machine, info.session, 80, 24);
550
+ term = openTerm(info.machine, info.session, cols, rows);
528
551
  } catch (e) {
529
552
  socket.send(JSON.stringify({ t: 'err', d: 'pty spawn failed: ' + String(e).slice(0, 200) }));
530
553
  socket.close();
531
554
  return;
532
555
  }
533
- term.onData((d) => { try { socket.send(JSON.stringify({ t: 'o', d })); } catch { /* closed */ } });
556
+ // 백프레셔 WS 송신 버퍼가 차면 pty 잠시 멈춰 폭주 방지
557
+ let paused = false;
558
+ term.onData((d) => {
559
+ try {
560
+ socket.send(JSON.stringify({ t: 'o', d }));
561
+ if (!paused && socket.bufferedAmount > 800_000) { paused = true; try { term.pause(); } catch { /* n/a */ } }
562
+ } catch { /* closed */ }
563
+ });
564
+ const drain = setInterval(() => {
565
+ if (paused && socket.bufferedAmount < 100_000) { paused = false; try { term.resume(); } catch { /* n/a */ } }
566
+ }, 200);
567
+ const keepalive = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, 30_000);
568
+
534
569
  term.onExit(() => { try { socket.send(JSON.stringify({ t: 'exit' })); socket.close(); } catch { /* closed */ } });
535
570
  socket.on('message', (raw: Buffer) => {
536
571
  try {
@@ -539,7 +574,10 @@ export async function buildServer(): Promise<FastifyInstance> {
539
574
  else if (m.t === 'r' && m.cols && m.rows) term.resize(Math.max(20, Math.min(500, m.cols)), Math.max(5, Math.min(200, m.rows)));
540
575
  } catch { /* ignore */ }
541
576
  });
542
- socket.on('close', () => { try { term.kill(); } catch { /* gone */ } });
577
+ socket.on('close', () => {
578
+ clearInterval(drain); clearInterval(keepalive);
579
+ try { term.kill(); } catch { /* gone */ }
580
+ });
543
581
  });
544
582
 
545
583
  return app;
package/src/term.ts CHANGED
@@ -44,7 +44,7 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
44
44
  env: { ...process.env, TERM: 'xterm-256color' } as Record<string, string>,
45
45
  };
46
46
  if (isLocal(m)) {
47
- return pty().spawn('tmux', ['attach-session', '-t', session], opts);
47
+ return pty().spawn('tmux', ['attach-session', '-t', '=' + session], opts);
48
48
  }
49
49
  const args: string[] = [
50
50
  '-tt',
@@ -55,6 +55,6 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
55
55
  if (config.sshKey) args.push('-i', config.sshKey);
56
56
  const target = m.sshUser ? `${m.sshUser}@${m.address}` : m.address;
57
57
  // 세션명은 우리가 만든 coxpit-rN 형식이라 셸 주입 여지 없음 — 그래도 인용.
58
- args.push(target, `tmux attach-session -t '${session.replace(/'/g, "'\\''")}'`);
58
+ args.push(target, `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`);
59
59
  return pty().spawn('ssh', args, opts);
60
60
  }