coxpit 4.6.0 → 4.8.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/src/board.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // 데몬이 서빙하는 단일 페이지 플릿 콘솔(빌드 스텝 0, 자가완결).
2
2
  // /api/fleet 로 하이드레이트 → /ws 구독 델타 → run 상세(타임라인·diff·터미널)·비교/머지.
3
+ import { ICON_SPRITE, ICON_CSS, ICON_JS_HELPER } from './icons.js';
4
+
3
5
  export const BOARD_HTML = /* html */ `<!doctype html>
4
6
  <html lang="en">
5
7
  <head>
@@ -125,6 +127,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
125
127
  .seg-opt.on{background:var(--surface2);color:var(--ink)}
126
128
  .seg-opt[data-real="1"].on{background:var(--brand);color:var(--brand-ink)}
127
129
  .seg-hint{font-family:var(--mono);font-size:9.5px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;opacity:.7}
130
+ /* ── deliverable 계약 칩(Task compose) ── */
131
+ .ochips{display:flex;flex-wrap:wrap;gap:5px}
132
+ .ochip{background:transparent;color:var(--muted);border:1px solid var(--line);cursor:pointer;
133
+ font-family:var(--mono);font-size:11px;padding:4px 10px;border-radius:999px;
134
+ transition:border-color .15s,color .15s,background .15s}
135
+ .ochip:hover{color:var(--ink);border-color:var(--line-hi)}
136
+ .ochip.on{background:var(--brand-dim);color:var(--brand);border-color:rgba(78,201,176,.4)}
128
137
 
129
138
  /* ── buttons ────────────────────────────── */
130
139
  .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;cursor:pointer;
@@ -187,6 +196,64 @@ export const BOARD_HTML = /* html */ `<!doctype html>
187
196
  .conv .turn .msg{color:var(--ink);white-space:pre-wrap;word-break:break-word;line-height:1.55}
188
197
  .conv .turn.you .msg{color:var(--muted)}
189
198
  .conv .turn .ro-note{font-family:var(--mono);font-size:9.5px;color:var(--faint);letter-spacing:.02em}
199
+ /* ── v4.7 converge cockpit — group action bar + per-run decision rows ── */
200
+ .gbar{display:flex;align-items:center;gap:12px;padding:10px 18px;border-bottom:1px solid var(--line);
201
+ background:var(--surface2);font-family:var(--mono);font-size:11.5px}
202
+ .gbar .selc{color:var(--muted)}
203
+ .gbar .sp{flex:1}
204
+ .gbar button{font-family:var(--mono);font-size:11.5px;background:transparent;border:1px solid var(--line-hi);
205
+ color:var(--muted);border-radius:7px;padding:5px 11px;cursor:pointer}
206
+ .gbar button:hover{color:var(--ink);border-color:var(--line-hi)}
207
+ .gbar button.brand{background:var(--brand);border-color:var(--brand);color:var(--brand-ink);font-weight:700}
208
+ .gbar button.brand[disabled]{opacity:.4;cursor:not-allowed}
209
+ .gbar button.danger{color:var(--s-failed);border-color:rgba(226,91,103,.4)}
210
+ .runs{padding:10px 12px;display:flex;flex-direction:column;gap:8px}
211
+ .runs .empty-note{color:var(--faint);font-family:var(--mono);font-size:11.5px;text-align:center;padding:24px 0}
212
+ .run{border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden}
213
+ .run.sel{border-color:var(--brand);box-shadow:inset 0 0 0 1px var(--brand-dim)}
214
+ .run.dim{opacity:.6}
215
+ .run-h{display:flex;align-items:center;gap:11px;padding:11px 13px;cursor:pointer}
216
+ .run-h .cb{width:14px;height:14px;border:1px solid var(--line-hi);border-radius:4px;flex:none;
217
+ display:inline-flex;align-items:center;justify-content:center;color:var(--brand);font-size:10px;cursor:pointer}
218
+ .run.sel .run-h .cb{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
219
+ .run .dot{width:8px;height:8px;border-radius:50%;flex:none}
220
+ .run-id{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--ink);flex:none}
221
+ .run-title{font-size:13.5px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
222
+ .run-meta{font-family:var(--mono);font-size:10.5px;color:var(--faint);flex:none}
223
+ .run-badge{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:999px;flex:none}
224
+ .run-badge.merged{color:var(--s-merged);border:1px solid rgba(78,201,176,.4)}
225
+ .run-badge.running{color:var(--s-running);border:1px solid rgba(85,167,224,.4)}
226
+ .run-h .grow{flex:1}
227
+ .run .acts{display:flex;gap:6px;flex:none}
228
+ .run .acts .b{font-family:var(--sans);font-size:11.5px;font-weight:600;border-radius:7px;padding:5px 11px;
229
+ cursor:pointer;border:1px solid var(--line-hi);background:transparent;color:var(--muted)}
230
+ .run .acts .b:hover{color:var(--ink)}
231
+ .run .acts .b.merge{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
232
+ .run .acts .b.close:hover{color:var(--s-failed);border-color:rgba(226,91,103,.45)}
233
+ .run .acts .b.ghost{color:var(--muted)}
234
+ .run .caret{color:var(--faint);font-family:var(--mono);font-size:12px;flex:none;width:14px;text-align:center;
235
+ transition:transform .16s}
236
+ .run.open .caret{transform:rotate(90deg)}
237
+ .run-b{display:none;border-top:1px solid var(--line);padding:12px 14px 14px;background:#0e1118}
238
+ .run.open .run-b{display:block}
239
+ .run-b .rout{margin-bottom:10px}
240
+ .run-b .rout-cards{display:flex;flex-direction:column;gap:6px}
241
+ .run-b .ocard{padding:8px 11px}
242
+ .run-b .rout-detail{margin-top:8px;border:1px solid var(--line);border-radius:8px;overflow:hidden}
243
+ .run-b .rout-back{font-family:var(--mono);font-size:10.5px;color:var(--muted);cursor:pointer;
244
+ padding:7px 11px;border-bottom:1px solid var(--line);background:var(--surface2)}
245
+ .run-b .rout-back:hover{color:var(--brand)}
246
+ .run-b .rout-body{padding:10px 12px;max-height:300px;overflow:auto}
247
+ .run-b .review{margin:10px 0;display:flex;gap:8px;align-items:flex-start;font-size:12.5px;line-height:1.55}
248
+ .run-b .review .rk{font-family:var(--mono);font-size:10.5px;color:var(--brand);flex:none;padding-top:2px}
249
+ .run-b .review .rt{color:var(--muted)}
250
+ .run-b .fix{margin-top:10px;display:flex;gap:8px}
251
+ .run-b .fix input{flex:1;background:var(--bg);border:1px solid var(--line-hi);border-radius:7px;color:var(--ink);
252
+ font-family:var(--sans);font-size:12.5px;padding:8px 11px;outline:none}
253
+ .run-b .fix input:focus{border-color:var(--brand)}
254
+ .run-b .fix .b{white-space:nowrap;font-family:var(--sans);font-size:11.5px;font-weight:600;border-radius:7px;
255
+ padding:5px 11px;cursor:pointer;border:1px solid var(--line-hi);background:transparent;color:var(--muted)}
256
+ .run-b .fix .b:hover{color:var(--ink);border-color:var(--brand)}
190
257
  .send-only{display:flex;gap:8px;align-items:center}
191
258
  .send-only .ro-hint{flex:1;font-family:var(--mono);font-size:10px;color:var(--faint);letter-spacing:.02em}
192
259
  .composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;flex-direction:column;gap:8px}
@@ -335,7 +402,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
335
402
  .rmt-tbl th{color:var(--faint);font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em}
336
403
  .rmt-tbl code{font-family:var(--mono);font-size:10.5px;color:var(--brand);white-space:nowrap}
337
404
  .rmt-tbl .star{color:var(--brand)}
338
- /* header 🔗 affordance shares the ghost-button look */
405
+ /* header remote-access affordance shares the ghost-button look */
339
406
 
340
407
  /* ── toasts ─────────────────────────────── */
341
408
  .toasts{position:fixed;top:66px;right:18px;z-index:60;display:flex;flex-direction:column;gap:8px;
@@ -436,9 +503,60 @@ export const BOARD_HTML = /* html */ `<!doctype html>
436
503
  .doc-md p{margin:0 0 8px}
437
504
  .doc-frame{width:100%;height:420px;border:1px solid var(--line);border-radius:8px;background:#fff;margin-bottom:16px}
438
505
  .pane-tgl{margin-left:auto;font-size:10px;padding:2px 9px;text-transform:none;letter-spacing:0}
439
- /* run 모달의 diff 라인 = steer 참조 클릭 타깃 */
440
- #mDiffWrap .dl-line{cursor:pointer;border-radius:3px}
441
- #mDiffWrap .dl-line:hover{background:rgba(78,201,176,.09)}
506
+ /* run 모달의 diff 라인 = steer 참조 클릭 타깃(code 카드 상세) */
507
+ #outDetail .dl-line{cursor:pointer;border-radius:3px}
508
+ #outDetail .dl-line:hover{background:rgba(78,201,176,.09)}
509
+
510
+ /* ── v4.7 산출물 계약(contract strip) — 모달 헤더 아래 요청 산출물 요약 ── */
511
+ .contract{display:flex;flex-wrap:wrap;align-items:center;gap:7px;padding:9px 18px;
512
+ border-bottom:1px solid var(--line);background:var(--surface2);
513
+ font-family:var(--mono);font-size:10.5px;color:var(--faint);line-height:1.5}
514
+ .contract[hidden]{display:none}
515
+ .contract .clabel{letter-spacing:.04em;text-transform:none}
516
+ .contract .csep{color:var(--line-hi)}
517
+ .req{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;
518
+ border:1px solid var(--line);color:var(--muted);text-transform:none;letter-spacing:.02em}
519
+ .req.ok{color:var(--s-done);border-color:rgba(88,179,104,.4)}
520
+ .req.warn{color:var(--s-preparing);border-color:rgba(214,162,73,.45)}
521
+ .req.aux{color:var(--faint);border-style:dashed}
522
+ .req .rg{font-size:11px;line-height:1}
523
+
524
+ /* ── v4.7 출력 카드 목록(오른쪽 컬럼) ── */
525
+ #outWrap{display:flex;flex-direction:column;min-height:0;flex:1}
526
+ #outCards{display:flex;flex-direction:column;gap:8px;padding:12px 16px;overflow:auto;flex:1;min-height:0}
527
+ .ocard{display:flex;align-items:center;gap:10px;padding:11px 13px;border:1px solid var(--line);
528
+ border-radius:var(--r-card);background:var(--surface);cursor:pointer;
529
+ transition:border-color .16s,transform .16s}
530
+ .ocard:hover{border-color:#323b4e;transform:translateY(-1px)}
531
+ .ocard.miss{opacity:.72;border-style:dashed;cursor:default}
532
+ .ocard.miss:hover{transform:none;border-color:var(--line)}
533
+ .ocard .og{font-size:15px;line-height:1;flex:0 0 auto;width:20px;text-align:center;color:var(--muted)}
534
+ .ocard.miss .og{color:var(--s-preparing)}
535
+ .ocard .ob{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
536
+ .ocard .ot{font-family:var(--sans);font-size:12.5px;color:var(--ink);font-weight:600;
537
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
538
+ .ocard .om{font-family:var(--mono);font-size:10px;color:var(--faint);
539
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
540
+ .ocard .obadge{font-family:var(--mono);font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;
541
+ padding:2px 7px;border-radius:999px;border:1px solid var(--line);color:var(--faint);flex:0 0 auto}
542
+ .ocard .obadge.req{color:var(--brand);border-color:rgba(78,201,176,.4)}
543
+ .ocard .obadge.warn{color:var(--s-preparing);border-color:rgba(214,162,73,.45)}
544
+ .ocard .oc{color:var(--faint);font-size:13px;flex:0 0 auto}
545
+ .ocards-empty{color:var(--faint);font-family:var(--mono);font-size:11px;padding:22px 4px;text-align:center}
546
+
547
+ /* ── v4.7 출력 카드 상세 뷰어 ── */
548
+ #outDetail{display:none;flex-direction:column;min-height:0;flex:1}
549
+ #outDetail.on{display:flex}
550
+ #outWrap.detailing #outCards{display:none}
551
+ .oback{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;
552
+ color:var(--muted);cursor:pointer;padding:9px 16px;border-bottom:1px solid var(--line);
553
+ background:var(--surface2);flex:0 0 auto}
554
+ .oback:hover{color:var(--brand)}
555
+ .odetail-c{overflow:auto;padding:12px 16px;flex:1;min-height:0}
556
+ .oimg{max-width:100%;border:1px solid var(--line);border-radius:8px;background:#fff}
557
+ .odl{display:inline-block;margin-top:6px;font-family:var(--mono);font-size:11.5px;color:var(--brand);
558
+ text-decoration:none;border:1px solid var(--line);border-radius:var(--r-ctl);padding:6px 11px}
559
+ .odl:hover{border-color:var(--brand)}
442
560
 
443
561
  /* ── terminal ───────────────────────────── */
444
562
  .term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
@@ -488,9 +606,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
488
606
  .cmp-col{min-width:0;border-right:none;border-bottom:1px solid var(--line);flex:0 0 auto;max-height:72vh}
489
607
  }
490
608
  @media (prefers-reduced-motion:reduce){aside{transition:none}}
609
+ ${ICON_CSS}
491
610
  </style>
492
611
  </head>
493
612
  <body>
613
+ ${ICON_SPRITE}
494
614
  <header>
495
615
  <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
496
616
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
@@ -500,8 +620,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
500
620
  <button type="button" class="seg-opt" data-view="archive">Archive <span id="archN" class="seg-hint"></span></button>
501
621
  </div>
502
622
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
503
- <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
504
- <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)">🔗</button>
623
+ <button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
624
+ <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
505
625
  <div class="machines" id="machines"></div>
506
626
  </header>
507
627
  <div class="scrim" id="scrim"></div>
@@ -517,8 +637,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
517
637
  <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
518
638
  <button type="button" class="btn-ghost sm" id="repoNew" style="flex:0 0 auto" title="start a new project — empty folder in, scaffolded repo out">New</button>
519
639
  <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
520
- <button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
521
- <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
640
+ <button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it"><svg class="ic"><use href="#i-branch"/></svg></button>
641
+ <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit"><svg class="ic"><use href="#i-x"/></svg></button>
522
642
  </div>
523
643
  <form id="repoForm" hidden>
524
644
  <div class="row">
@@ -549,6 +669,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
549
669
  <datalist id="modelHist"></datalist>
550
670
  <p class="flabel">design capture · optional</p>
551
671
  <select id="taskCapture"><option value="">no design capture</option></select>
672
+ <p class="flabel">deliverables · optional (계약)</p>
673
+ <div class="ochips" id="taskOutputs" role="group" aria-label="declared deliverables">
674
+ <button type="button" class="ochip" data-out="answer">답변</button>
675
+ <button type="button" class="ochip" data-out="code">코드</button>
676
+ <button type="button" class="ochip" data-out="doc">문서</button>
677
+ <button type="button" class="ochip" data-out="page">페이지</button>
678
+ <button type="button" class="ochip" data-out="file">파일</button>
679
+ </div>
552
680
  </div>
553
681
  <div id="panelGoal" hidden style="flex-direction:column;gap:8px">
554
682
  <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>
@@ -564,7 +692,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
564
692
  <input type="checkbox" id="taskReal" hidden />
565
693
  <div class="row">
566
694
  <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
567
- <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
695
+ <button class="btn" type="submit" id="runFleetBtn"><svg class="ic"><use href="#i-play"/></svg> Run fleet</button>
568
696
  </div>
569
697
  </form>
570
698
  </div>
@@ -590,6 +718,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
590
718
  <div class="arch-bar">
591
719
  <input id="archQ" placeholder="search title…" autocomplete="off" />
592
720
  <select id="archRepo"><option value="">all repos</option></select>
721
+ <button type="button" class="btn-ghost sm" id="reclaimBtn" hidden
722
+ title="remove worktrees left by cleaned/failed runs — reclaims disk (active work untouched)"><svg class="ic"><use href="#i-recycle"/></svg> Reclaim <span id="reclaimHint"></span></button>
593
723
  </div>
594
724
  <div id="archList"></div>
595
725
  <div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
@@ -603,17 +733,23 @@ export const BOARD_HTML = /* html */ `<!doctype html>
603
733
  <span class="rid" id="mRid"></span>
604
734
  <span class="title" id="mTitle"></span>
605
735
  <span class="chip" id="mChip"><i></i><span id="mChipTxt"></span></span>
606
- <button class="x" id="mClose" aria-label="close">×</button>
736
+ <button class="x" id="mClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
607
737
  </div>
738
+ <div class="contract" id="mContract" hidden></div>
608
739
  <div class="modal-b">
609
740
  <div class="pane">
610
741
  <div class="pane-h">Timeline</div>
611
742
  <div class="pane-c tl" id="mTimeline"></div>
612
743
  </div>
613
744
  <div class="pane">
614
- <div class="pane-h" style="display:flex;align-items:center;gap:8px">Diff <span id="mStat" style="text-transform:none;letter-spacing:0"></span>
615
- <button class="btn-ghost sm pane-tgl" id="mDocsTgl" hidden>Rendered</button></div>
616
- <div class="pane-c" id="mDiffWrap"><pre class="diff" id="mDiff">loading…</pre></div>
745
+ <div class="pane-h" style="display:flex;align-items:center;gap:8px">Outputs <span id="mStat" style="text-transform:none;letter-spacing:0"></span></div>
746
+ <div id="outWrap">
747
+ <div id="outCards"><div class="ocards-empty">loading…</div></div>
748
+ <div id="outDetail">
749
+ <div class="oback" id="outBack">‹ 산출물 목록</div>
750
+ <div class="odetail-c" id="outDetailC"></div>
751
+ </div>
752
+ </div>
617
753
  </div>
618
754
  </div>
619
755
  <div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
@@ -622,14 +758,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
622
758
  <button type="button" class="seg-opt" data-mode="ask">Ask</button>
623
759
  </div>
624
760
  <input id="steerInput" placeholder="Next instruction — same session &amp; worktree…" style="flex:1" />
625
- <button class="btn sm" id="steerSend">Send</button>
761
+ <button class="btn sm" id="steerSend"><svg class="ic"><use href="#i-pencil"/></svg> Send</button>
626
762
  </div>
627
763
  <div class="modal-f">
628
- <button class="btn-ghost sm" id="mTerm">Terminal</button>
629
- <button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
764
+ <button class="btn-ghost sm" id="mTerm"><svg class="ic"><use href="#i-terminal"/></svg> Terminal</button>
765
+ <button class="btn-ghost sm" id="mRefreshDiff"><svg class="ic"><use href="#i-refresh"/></svg> Refresh outputs</button>
630
766
  <button class="btn-ghost sm" id="mCompare">Compare runs</button>
631
- <button class="btn-ghost sm" id="mExport">Export files…</button>
632
- <button class="btn-ghost sm" id="mSync">Sync base</button>
767
+ <button class="btn-ghost sm" id="mExport"><svg class="ic"><use href="#i-download"/></svg> Export files…</button>
768
+ <button class="btn-ghost sm" id="mSync"><svg class="ic"><use href="#i-branch"/></svg> Sync base</button>
633
769
  <button class="btn-ghost sm" id="mShare" title="create a read-only share link (no auth, snapshot view)">Share</button>
634
770
  <span class="spacer"></span>
635
771
  <button class="btn-danger sm" id="mStop">Stop</button>
@@ -645,11 +781,18 @@ export const BOARD_HTML = /* html */ `<!doctype html>
645
781
  <span class="rh-glyph">⌒</span>
646
782
  <span class="rh-t" id="roomTitle">Goal</span>
647
783
  <span class="rh-n" id="roomCount"></span>
648
- <button class="x" id="roomClose" aria-label="close">×</button>
784
+ <button class="x" id="roomClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
649
785
  </div>
650
786
  <div class="chips" id="roomChips"></div>
787
+ <div class="gbar" id="roomGbar">
788
+ <span class="selc" id="roomSelC">☑ 0 selected</span>
789
+ <button type="button" class="brand" id="roomIntegrateSel">Integrate 선택 (0)</button>
790
+ <span class="sp"></span>
791
+ <button type="button" id="roomReviewAll">전체 리뷰</button>
792
+ <button type="button" class="danger" id="roomGroupClose">그룹 클로즈</button>
793
+ </div>
651
794
  <div class="body">
652
- <div class="feed" id="roomFeed"></div>
795
+ <div class="runs" id="roomRuns"></div>
653
796
  <div class="conv" id="roomConv" hidden></div>
654
797
  </div>
655
798
  <div class="composer">
@@ -660,7 +803,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
660
803
  <div class="comp-hint" id="roomHint"></div>
661
804
  <textarea id="roomInput" placeholder="New attempt prompt, or a broadcast to the settled runs…"></textarea>
662
805
  <div class="verbs" id="roomVerbs">
663
- <button type="button" class="btn-ghost sm" id="roomNew">+ New attempt</button>
806
+ <button type="button" class="btn-ghost sm" id="roomNew"><svg class="ic"><use href="#i-plus"/></svg> New attempt</button>
664
807
  <button type="button" class="btn-ghost sm" id="roomBroadcast">→ Broadcast</button>
665
808
  <span class="grow"></span>
666
809
  <div class="conv-menu" id="roomConvMenu">
@@ -686,7 +829,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
686
829
  <button class="btn sm" id="cmpAI">AI review</button>
687
830
  <button class="btn-ghost sm" id="cmpDocsTgl">Rendered</button>
688
831
  <button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
689
- <button class="x" id="cmpClose" aria-label="close">×</button>
832
+ <button class="x" id="cmpClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
690
833
  </div>
691
834
  <div class="cmp-review" id="cmpReview" hidden></div>
692
835
  <div class="cmp" id="cmpBody"></div>
@@ -700,7 +843,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
700
843
  <span class="title" id="termTitle">terminal</span>
701
844
  <div class="term-tabs" id="termTabs"></div>
702
845
  <span class="term-hint">tmux session · Ctrl-b d detaches · Esc closes</span>
703
- <button class="x" id="termClose" aria-label="close">×</button>
846
+ <button class="x" id="termClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
704
847
  </div>
705
848
  <div class="term-body"><div id="xterm"></div></div>
706
849
  <div class="term-ibar" id="termIbar">
@@ -722,12 +865,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
722
865
  <button class="btn-ghost sm" id="brwUp">↑ Up</button>
723
866
  <button class="btn-ghost sm" id="brwHome">Home</button>
724
867
  <span class="brw-path" id="brwPath"></span>
725
- <button class="x" id="brwClose" aria-label="close">×</button>
868
+ <button class="x" id="brwClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
726
869
  </div>
727
870
  <div class="brw-list" id="brwList"></div>
728
871
  <div class="brw-f">
729
872
  <span class="hint"><span style="color:var(--brand)">git</span> badge = repo (Register) · empty folder = Start here</span>
730
- <button class="btn-ghost sm" id="brwNewFolder">+ New folder here</button>
873
+ <button class="btn-ghost sm" id="brwNewFolder"><svg class="ic"><use href="#i-plus"/></svg> New folder here</button>
731
874
  <button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
732
875
  </div>
733
876
  <form class="brw-f" id="brwNewForm" hidden>
@@ -845,13 +988,14 @@ let remoteAuthOpen = false; // true = no password → Funnel guard on (from /api
845
988
  const $ = (id) => document.getElementById(id);
846
989
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
847
990
  const escA = (s) => esc(s).replace(/"/g, '&quot;');
991
+ ${ICON_JS_HELPER} // ic('x') → '<svg class="ic"><use href="#i-x"/></svg>' (Lucide 스프라이트)
848
992
  const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
849
993
 
850
994
  /* ── custom toast / confirm (시스템 alert·confirm 대체) ── */
851
995
  function toast(msg, kind){
852
996
  const el = document.createElement('div');
853
997
  el.className = 'toast ' + (kind==='error'?'err':kind==='ok'?'ok':'');
854
- el.innerHTML = '<span class="tk">'+(kind==='error'?'':kind==='ok'?'':'·')+'</span><span>'+esc(msg)+'</span>';
998
+ el.innerHTML = '<span class="tk">'+(kind==='error'?ic('x'):kind==='ok'?ic('check'):'·')+'</span><span>'+esc(msg)+'</span>';
855
999
  $('toasts').appendChild(el);
856
1000
  setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; setTimeout(()=>el.remove(),260); }, 4200);
857
1001
  }
@@ -903,7 +1047,7 @@ async function brwGo(p){
903
1047
  $('brwList').innerHTML =
904
1048
  (d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
905
1049
  + (d.dirs.map(x =>
906
- '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
1050
+ '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">'+ic('folder')+'</span><span class="nm">'+esc(x.name)+'</span>'
907
1051
  + (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
908
1052
  : x.isEmpty ? '<button type="button" class="btn-ghost sm" data-start="'+esc(x.name)+'">Start here</button>' : '')
909
1053
  + '</div>').join('')
@@ -1146,7 +1290,7 @@ function setView(v){
1146
1290
  $('grid').style.display = archive ? 'none' : '';
1147
1291
  $('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
1148
1292
  document.querySelector('.toolbar').style.display = archive ? 'none' : '';
1149
- if (archive){ paintArchRepos(); archFetch(true); }
1293
+ if (archive){ paintArchRepos(); archFetch(true); reclaimRefresh(); }
1150
1294
  else render();
1151
1295
  }
1152
1296
  document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
@@ -1198,6 +1342,32 @@ $('archList').addEventListener('click', async (e)=>{
1198
1342
  }catch{ toast('could not open task', 'error'); }
1199
1343
  });
1200
1344
 
1345
+ /* ── Reclaim orphaned worktrees ── cleaned/failed run worktrees are disk debt
1346
+ (~180MB each — node_modules lives inside). Only shown when there's something
1347
+ to reclaim; active/successful work is never listed by the server. */
1348
+ let reclaimN = 0;
1349
+ async function reclaimRefresh(){
1350
+ try{
1351
+ const j = await fetch('/api/worktrees').then(x=>x.json());
1352
+ reclaimN = (j.items||[]).length;
1353
+ const mb = Math.round((j.totalKb||0)/1024);
1354
+ $('reclaimHint').textContent = reclaimN ? (reclaimN+' · ~'+mb+'MB') : '';
1355
+ $('reclaimBtn').hidden = reclaimN===0;
1356
+ }catch{ $('reclaimBtn').hidden = true; }
1357
+ }
1358
+ $('reclaimBtn').addEventListener('click', async ()=>{
1359
+ if (!reclaimN) return;
1360
+ const ok = await confirmUI('remove '+reclaimN+' cleaned/failed run worktree'+(reclaimN===1?'':'s')+'?', {
1361
+ sub:'active work is untouched — only closed tasks and failed/error/stopped runs are reclaimed',
1362
+ okLabel:'Reclaim', danger:true });
1363
+ if (!ok) return;
1364
+ try{
1365
+ const r = await fetch('/api/worktrees/prune',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({})}).then(x=>x.json());
1366
+ toast('reclaimed '+(r.count||0)+' worktree'+((r.count||0)===1?'':'s'), 'ok');
1367
+ }catch{ toast('reclaim failed', 'error'); }
1368
+ reclaimRefresh();
1369
+ });
1370
+
1201
1371
  /* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
1202
1372
  let readiness = null, probing = false;
1203
1373
  async function probeFirstMachine(){
@@ -1211,7 +1381,7 @@ async function probeFirstMachine(){
1211
1381
  }
1212
1382
  function chkRow(name, ok, val){
1213
1383
  const cls = ok===null ? 'wait' : ok ? 'ok' : 'bad';
1214
- const st = ok===null ? '…' : ok ? '' : '';
1384
+ const st = ok===null ? '…' : ok ? ic('check') : ic('x');
1215
1385
  return '<div class="chk '+cls+'"><span class="st">'+st+'</span><span class="nm">'+esc(name)+'</span>'
1216
1386
  + '<span class="v">'+esc(val||'')+'</span></div>';
1217
1387
  }
@@ -1259,7 +1429,7 @@ function cardHTML(r){
1259
1429
  const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
1260
1430
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
1261
1431
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
1262
- + '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
1432
+ + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)+'</div>'
1263
1433
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
1264
1434
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
1265
1435
  + '<span>'+esc(r.agent||'')+'</span>'
@@ -1268,7 +1438,7 @@ function cardHTML(r){
1268
1438
  + (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
1269
1439
  + (r.sessionId && ['done','failed','stopped'].includes(r.status)
1270
1440
  ? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
1271
- + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
1441
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR '+ic('external-link')+'</a>' : '')
1272
1442
  + '</div>'
1273
1443
  + '<div class="log">'+evs+'</div></div>';
1274
1444
  }
@@ -1371,7 +1541,7 @@ $('repoManual').addEventListener('click', ()=>{
1371
1541
  /* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
1372
1542
  let notifyOn = false;
1373
1543
  try { notifyOn = localStorage.getItem('coxpit.notify') === '1' && Notification.permission === 'granted'; } catch {}
1374
- function paintBell(){ $('bell').textContent = notifyOn ? '🔔' : '🔕'; }
1544
+ function paintBell(){ $('bell').innerHTML = ic(notifyOn ? 'bell' : 'bell-off'); }
1375
1545
  $('bell').addEventListener('click', async ()=>{
1376
1546
  if (!('Notification' in window)){ toast('this browser has no notification support', 'error'); return; }
1377
1547
  if (!notifyOn){
@@ -1434,6 +1604,14 @@ function connectWS(){
1434
1604
 
1435
1605
  /* ── run detail modal ── */
1436
1606
  let openRunId = null;
1607
+ /* 터미널이 붙을 수 없는 사유(있으면 그 문자열, 없으면 '') — 닫힌 task·정리된 worktree.
1608
+ cleanupRun 이 worktreePath 를 비우므로 merged/cleanup/closed 는 모두 여기서 걸린다. */
1609
+ function termUnavailReason(r, task){
1610
+ if (task && task.status==='closed') return 'worktree cleaned — terminal unavailable (task closed)';
1611
+ if (r.status==='merged') return 'worktree cleaned — terminal unavailable (merged)';
1612
+ if ('worktreePath' in r && !r.worktreePath) return 'worktree cleaned — terminal unavailable';
1613
+ return '';
1614
+ }
1437
1615
  function paintModal(){
1438
1616
  if (openRunId==null) return;
1439
1617
  const r = runs.get(openRunId); if(!r) return;
@@ -1448,40 +1626,157 @@ function paintModal(){
1448
1626
  $('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
1449
1627
  // steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
1450
1628
  $('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
1629
+ // 터미널 가드 — 닫힌 task·worktree 정리된 run(merged/cleanup) 은 tmux 세션이 죽어
1630
+ // attach 하면 에러. 버튼을 잠그고 사유를 툴팁으로.
1631
+ const termGuard = termUnavailReason(r, task);
1632
+ const tbtn = $('mTerm');
1633
+ tbtn.disabled = !!termGuard;
1634
+ tbtn.title = termGuard || 'attach a tmux terminal to this run';
1451
1635
  $('mTimeline').innerHTML = humanLines(r.events).map(h =>
1452
1636
  '<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t)+'</span></div>'
1453
1637
  ).join('') || '<span style="color:var(--faint)">no events yet</span>';
1454
1638
  }
1455
- async function loadDiff(){
1456
- if (openRunId==null || docMode) return;
1457
- const pre = $('mDiff'); if (!pre) return;
1639
+ /* ── v4.7 산출물 계약 · 출력 카드 — 오른쪽 컬럼(diff/Rendered 토글을 흡수) ──
1640
+ /outputs 카드 목록을 받아 렌더하고, 클릭하면 타입별 실뷰어를 오른쪽에 띄운다.
1641
+ answer/doc mdLite · page sandbox iframe · code → 기존 diff 렌더러 · file → 이미지/다운로드. */
1642
+ let outCards = []; // 이 run 의 마지막 카드 목록(RunOutputCard[])
1643
+ const OUT_ICON = { answer:'message', code:'code', doc:'file', page:'image', file:'image' };
1644
+ const OUT_LABEL = { answer:'답변', code:'코드', doc:'문서', page:'페이지', file:'파일' };
1645
+ function contractHTML(cards){
1646
+ const declared = cards.filter(c=>c.required);
1647
+ const aux = cards.filter(c=>!c.required && c.present);
1648
+ if (!declared.length && !aux.length) return ''; // 계약도 부수도 없으면 스트립 숨김
1649
+ let h = '<span class="clabel">요청 산출물(계약):</span>';
1650
+ if (declared.length){
1651
+ h += declared.map(c=>{
1652
+ const ok = c.present;
1653
+ return '<span class="req '+(ok?'ok':'warn')+'"><span class="rg">'+ic(ok?'check':'alert-triangle')+'</span>'
1654
+ + esc(OUT_LABEL[c.type]||c.type)+'</span>';
1655
+ }).join('');
1656
+ } else h += '<span class="req aux">없음</span>';
1657
+ if (aux.length){
1658
+ h += '<span class="csep">—</span><span class="clabel">부수:</span>'
1659
+ + aux.map(c=>'<span class="req aux">'+esc(OUT_LABEL[c.type]||c.type)+'</span>').join('');
1660
+ }
1661
+ return h;
1662
+ }
1663
+ function outCardHTML(c, i){
1664
+ const miss = !c.present;
1665
+ const badge = c.required
1666
+ ? '<span class="obadge '+(c.present?'req':'warn')+'">요청됨</span>'
1667
+ : '<span class="obadge">부수</span>';
1668
+ const gname = miss ? 'alert-triangle' : (OUT_ICON[c.type] || 'circle');
1669
+ const meta = miss ? '산출물 미충족 — '+esc(c.meta||'') : esc(c.meta||'');
1670
+ return '<div class="ocard'+(miss?' miss':'')+'" data-oi="'+i+'">'
1671
+ + '<span class="og">'+ic(gname)+'</span>'
1672
+ + '<span class="ob"><span class="ot">'+esc(c.title||c.type)+'</span>'
1673
+ + '<span class="om">'+meta+'</span></span>'
1674
+ + badge
1675
+ + (miss?'':'<span class="oc">'+ic('chevron')+'</span>')
1676
+ + '</div>';
1677
+ }
1678
+ /* run 형태로 기본 카드 선택 — 코드 변경 없고 answer/doc 있으면 그걸, 코드 위주면 code,
1679
+ 아니면 첫 present 선언 카드(없으면 첫 present 카드). 미충족 카드는 절대 자동 오픈 안 함. */
1680
+ function pickDefaultCard(cards){
1681
+ const present = cards.filter(c=>c.present);
1682
+ if (!present.length) return -1;
1683
+ const hasCode = present.some(c=>c.type==='code');
1684
+ const docish = present.find(c=>c.type==='answer' || c.type==='doc');
1685
+ if (!hasCode && docish) return cards.indexOf(docish);
1686
+ if (hasCode){
1687
+ const codeCard = present.find(c=>c.type==='code');
1688
+ // 코드 위주라도 선언된 문서/답변이 있으면 그 계약을 우선(코드는 언제나 카드로 접근 가능)
1689
+ const declaredDoc = present.find(c=>c.required && (c.type==='answer'||c.type==='doc'||c.type==='page'));
1690
+ if (declaredDoc) return cards.indexOf(declaredDoc);
1691
+ return cards.indexOf(codeCard);
1692
+ }
1693
+ const declared = present.find(c=>c.required);
1694
+ return cards.indexOf(declared || present[0]);
1695
+ }
1696
+ async function loadDiff(){ // 이름은 유지(모달 refresh·정착 이벤트가 호출) — 이제 outputs 를 로드
1697
+ if (openRunId==null) return;
1458
1698
  const rid = openRunId;
1459
- pre.textContent = 'loading…'; $('mStat').textContent='';
1699
+ $('outCards').innerHTML = '<div class="ocards-empty">loading…</div>';
1700
+ $('mStat').textContent='';
1701
+ showOutCards(); // 상세에서 목록으로
1460
1702
  try{
1461
- const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
1462
- if (!d.ok){
1463
- pre.textContent = d.stat||'no worktree';
1464
- // worktree 는 없지만 스냅샷 문서가 있으면 Rendered 토글 노출(머지·Close 후 뷰어)
1465
- maybeShowDocsToggle(rid);
1466
- return;
1467
- }
1468
- const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
1469
- $('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
1470
- pre.innerHTML = diffHTML(d.diff||'');
1471
- // 변경분에 문서(md/html)가 있으면 즉시 노출, 없으면 스냅샷 확인
1472
- if (/\\.(md|markdown|html?|htm)$/im.test(d.stat||'')) $('mDocsTgl').hidden = false;
1473
- else maybeShowDocsToggle(rid);
1474
- }catch{ pre.textContent = 'diff failed'; }
1475
- }
1476
- /* worktree 에 라이브 문서가 없을 때만 — 스냅샷이라도 있으면 토글을 켠다 */
1477
- async function maybeShowDocsToggle(rid){
1703
+ const j = await fetch('/api/runs/'+rid+'/outputs').then(x=>x.json());
1704
+ if (rid !== openRunId) return;
1705
+ outCards = (j.outputs)||[];
1706
+ $('mContract').innerHTML = contractHTML(outCards);
1707
+ $('mContract').hidden = !$('mContract').innerHTML;
1708
+ const nPresent = outCards.filter(c=>c.present).length;
1709
+ $('mStat').textContent = nPresent ? '· '+nPresent+' output'+(nPresent>1?'s':'') : '· none';
1710
+ if (!outCards.length){ $('outCards').innerHTML = '<div class="ocards-empty">no outputs yet</div>'; return; }
1711
+ $('outCards').innerHTML = outCards.map(outCardHTML).join('');
1712
+ const def = pickDefaultCard(outCards);
1713
+ if (def>=0) openOutCard(def);
1714
+ }catch{ $('outCards').innerHTML = '<div class="ocards-empty">outputs failed</div>'; }
1715
+ }
1716
+ function showOutCards(){
1717
+ $('outWrap').classList.remove('detailing');
1718
+ $('outDetail').classList.remove('on');
1719
+ }
1720
+ function showOutDetail(){
1721
+ $('outWrap').classList.add('detailing');
1722
+ $('outDetail').classList.add('on');
1723
+ }
1724
+ /* 카드 상세 렌더러(재사용) — 모달 오른쪽 컬럼과 워크룸 펼침 peek 이 공유.
1725
+ rid = 대상 run, c = 카드, box = 그릴 곳, alive() = 아직 이 뷰가 유효한지(스테일 가드). */
1726
+ async function renderOutCardInto(rid, c, box, alive){
1727
+ if (!c || !c.present) return;
1728
+ const ok = alive || (()=>true);
1729
+ box.innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11px">rendering…</span>';
1478
1730
  try{
1479
- const j = await fetch('/api/runs/'+rid+'/docs').then(x=>x.json());
1480
- if (rid===openRunId && (j.docs||[]).length) $('mDocsTgl').hidden = false;
1481
- }catch{}
1731
+ if (c.type==='answer' || c.type==='doc'){
1732
+ const q = '/api/runs/'+rid+'/output?type='+c.type+(c.path?'&path='+encodeURIComponent(c.path):'');
1733
+ const j = await fetch(q).then(x=>x.json());
1734
+ if (!ok()) return;
1735
+ box.innerHTML = (c.path?'<div class="doc-h">'+esc(c.path)+'</div>':'')
1736
+ + '<div class="doc-md">'+mdLite(j.content||'')+'</div>';
1737
+ } else if (c.type==='page'){
1738
+ const q = '/api/runs/'+rid+'/output?type=page'+(c.path?'&path='+encodeURIComponent(c.path):'');
1739
+ const j = await fetch(q).then(x=>x.json());
1740
+ if (!ok()) return;
1741
+ box.innerHTML = (c.path?'<div class="doc-h">'+esc(c.path)+'</div>':'')
1742
+ + '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(j.content||'')+'"></iframe>';
1743
+ } else if (c.type==='code'){
1744
+ box.innerHTML = '<pre class="diff">loading…</pre>';
1745
+ const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
1746
+ if (!ok()) return;
1747
+ const pre = box.querySelector('pre.diff'); if (!pre) return;
1748
+ if (!d.ok){ pre.textContent = d.stat||'no worktree — diff unavailable'; return; }
1749
+ pre.innerHTML = diffHTML(d.diff||'');
1750
+ } else if (c.type==='file'){
1751
+ const raw = '/api/runs/'+rid+'/file?path='+encodeURIComponent(c.path||'');
1752
+ const isImg = /\\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(c.path||'');
1753
+ if (isImg){
1754
+ box.innerHTML = '<div class="doc-h">'+esc(c.path||'')+'</div>'
1755
+ + '<img class="oimg" src="'+escA(raw)+'" alt="'+escA(c.path||'')+'">';
1756
+ } else {
1757
+ const isText = /\\.(txt|md|json|csv|log|ya?ml|ini|cfg|xml|ts|js|py|sh)$/i.test(c.path||'');
1758
+ let extra = '';
1759
+ if (isText){
1760
+ try{
1761
+ const r = await fetch(raw);
1762
+ if (ok() && r.ok){
1763
+ const t = await r.text();
1764
+ extra = '<pre class="diff" style="margin-top:8px">'+esc(t.slice(0,20000))+'</pre>';
1765
+ }
1766
+ }catch{}
1767
+ }
1768
+ box.innerHTML = '<div class="doc-h">'+esc(c.path||'')+'</div>'
1769
+ + '<a class="odl" href="'+escA(raw)+'" download>↓ download</a>' + extra;
1770
+ }
1771
+ }
1772
+ }catch{ box.innerHTML = '<span style="color:var(--faint)">viewer failed</span>'; }
1773
+ }
1774
+ async function openOutCard(i){
1775
+ const c = outCards[i]; if (!c || !c.present) return;
1776
+ const rid = openRunId;
1777
+ showOutDetail();
1778
+ await renderOutCardInto(rid, c, $('outDetailC'), ()=>rid===openRunId);
1482
1779
  }
1483
- /* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
1484
- let docMode = false;
1485
1780
  function docsHTML(docs){
1486
1781
  if (!docs.length) return '<span style="color:var(--faint)">no changed docs (md/html) in this worktree</span>';
1487
1782
  return docs.map(d=>'<div class="doc-h">'+esc(d.path)+'</div>'
@@ -1489,25 +1784,15 @@ function docsHTML(docs){
1489
1784
  ? '<div class="doc-md">'+mdLite(d.content)+'</div>'
1490
1785
  : '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(d.content)+'"></iframe>')).join('');
1491
1786
  }
1492
- async function paintDocs(){
1493
- if (openRunId==null) return;
1494
- $('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
1495
- try{
1496
- const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
1497
- const src = d.source==='snapshot'
1498
- ? '<div class="doc-src">worktree gone — snapshot taken at settle</div>' : '';
1499
- $('mDiffWrap').innerHTML = src + docsHTML(d.docs||[]);
1500
- }catch{ $('mDiffWrap').textContent = 'docs failed'; }
1501
- }
1502
- function setDocMode(on){
1503
- docMode = on;
1504
- $('mDocsTgl').textContent = on ? 'Diff' : 'Rendered';
1505
- if (on) paintDocs();
1506
- else { $('mDiffWrap').innerHTML = '<pre class="diff" id="mDiff">loading…</pre>'; loadDiff(); }
1507
- }
1508
- $('mDocsTgl').addEventListener('click', ()=>setDocMode(!docMode));
1509
- /* diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 — steerRow 표시 중일 때) */
1510
- $('mDiffWrap').addEventListener('click', (e)=>{
1787
+ /* 출력 카드 클릭 → 상세 뷰어 */
1788
+ $('outCards').addEventListener('click', (e)=>{
1789
+ const card = e.target.closest ? e.target.closest('.ocard') : null;
1790
+ if (!card || card.classList.contains('miss')) return;
1791
+ openOutCard(Number(card.dataset.oi));
1792
+ });
1793
+ $('outBack').addEventListener('click', showOutCards);
1794
+ /* code 카드 diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 steerRow 표시 중일 때) */
1795
+ $('outDetail').addEventListener('click', (e)=>{
1511
1796
  const t = e.target.closest ? e.target.closest('.dl-line') : null;
1512
1797
  if (!t) return;
1513
1798
  if ($('steerRow').style.display === 'none') return;
@@ -1518,9 +1803,11 @@ $('mDiffWrap').addEventListener('click', (e)=>{
1518
1803
  });
1519
1804
  async function openModal(id){
1520
1805
  openRunId = id;
1521
- if (docMode){ docMode=false; $('mDocsTgl').textContent='Rendered'; $('mDiffWrap').innerHTML='<pre class="diff" id="mDiff">loading…</pre>'; }
1522
- $('mDocsTgl').hidden = true;
1523
- paintModal(); $('overlay').classList.add('open'); loadDiff();
1806
+ outCards = [];
1807
+ showOutCards();
1808
+ $('mContract').hidden = true; $('mContract').innerHTML = '';
1809
+ $('outCards').innerHTML = '<div class="ocards-empty">loading…</div>';
1810
+ paintModal(); $('overlay').classList.add('open'); loadDiff(); // loadDiff = /outputs 로드
1524
1811
  // fleet 는 run 당 최근 40 이벤트만 내림 — 모달은 전체 타임라인을 다시 가져온다.
1525
1812
  try{
1526
1813
  const j = await fetch('/api/runs/'+id).then(x=>x.json());
@@ -1740,7 +2027,7 @@ async function paintCompare(){
1740
2027
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
1741
2028
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
1742
2029
  + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
1743
- + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
2030
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+ic('external-link')+' '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
1744
2031
  + '</span>'
1745
2032
  + (merged
1746
2033
  ? chipHTML('merged')
@@ -1826,7 +2113,6 @@ $('mCompare').addEventListener('click', ()=>{
1826
2113
  let roomGroupId = null; // 열려있는 그룹 id (null = 닫힘)
1827
2114
  const roomRunSet = new Set(); // 이 그룹에 속한 runId — WS 필터용
1828
2115
  let roomRuns = []; // 마지막 aggregate 의 runs (chips/scope hint)
1829
- const roomExtra = []; // goal-level 마커(spawn/broadcast) — 피드에 섞음
1830
2116
  const roomTurns = []; // Ask 대화 턴 [{role:'you'|'coord', text}] — 읽기 전용
1831
2117
  let roomMode = 'work'; // 'work' | 'ask' — 방 composer 모드(스틸모드와 무관, 방 전용)
1832
2118
  /* run → 상태색 (chip / who) */
@@ -1845,27 +2131,103 @@ function roomRenderChips(){
1845
2131
  ? roomRuns.map(roomChipHTML).join('')
1846
2132
  : '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">no runs yet</span>';
1847
2133
  }
1848
- function roomFeedLine(who, whoStatus, text, mark){
1849
- return '<div class="fl'+(mark?' mark':'')+'"><span class="who" style="color:'
1850
- + (mark?'var(--brand)':roomStatusColor(whoStatus))+'">'+esc(who)+'</span>'
1851
- + '<span class="ft">'+esc(text)+'</span></div>';
1852
- }
1853
- function roomRenderFeed(events){
1854
- // run 이벤트 + goal-level 마커를 시간(id/순서)대로. 간단히: 이벤트 먼저, 마커는 append.
1855
- const statusOf = (rid)=>{ const r = roomRuns.find(x=>x.runId===rid); return r? r.status : 'pending'; };
1856
- const rows = [];
1857
- for (const e of (events||[])){
1858
- const h = humanize({ kind:e.kind, payload:e.payload });
1859
- if (!h) continue;
1860
- const list = Array.isArray(h) ? h : [h];
1861
- for (const x of list) rows.push(roomFeedLine('r'+e.runId, statusOf(e.runId), x.k+' · '+x.t, false));
2134
+ /* ── 수렴 콕핏(Work body) run 하나당 결정 행. 서버 엔드포인트만 재사용(merge/steer/review/close). ──
2135
+ 접힘 head = 체크박스·상태점·rN·제목·메타·인라인[리뷰][수정][머지][클로즈]·caret.
2136
+ 펼침 body = P2 출력 카드(리뷰 peek, renderRunOutputsInto 재사용) + 수정(steer) 입력. */
2137
+ const roomSel = new Set(); // Integrate 선택된 runId
2138
+ const roomOpen = new Set(); // 펼쳐진 runId (WS 재렌더에도 유지)
2139
+ function roomDone(s){ return ['done','failed','stopped'].includes(s); }
2140
+ function roomRunRowHTML(r){
2141
+ const s = r.status||'pending';
2142
+ const merged = s==='merged';
2143
+ const running = r.live || s==='running' || s==='preparing' || s==='pending';
2144
+ const sel = roomSel.has(r.runId);
2145
+ const open = roomOpen.has(r.runId);
2146
+ const dotCls = merged?'merged':(running?'running':(s==='failed'?'failed':'done'));
2147
+ const meta = (r.agent?esc(r.agent)+' · ':'') + (r.filesChanged? '+'+r.filesChanged+'f' : 'no changes');
2148
+ let acts;
2149
+ if (merged){
2150
+ acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">열기</button></div>';
2151
+ } else if (running){
2152
+ acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">열기 · 정착 후 결정</button></div>';
2153
+ } else {
2154
+ acts = '<div class="acts">'
2155
+ + '<button class="b ghost" data-ract="review" data-rrid="'+r.runId+'">리뷰</button>'
2156
+ + '<button class="b" data-ract="fix" data-rrid="'+r.runId+'">수정</button>'
2157
+ + '<button class="b merge" data-ract="merge" data-rrid="'+r.runId+'">머지</button>'
2158
+ + '<button class="b close" data-ract="close" data-rrid="'+r.runId+'">클로즈</button>'
2159
+ + '</div>';
1862
2160
  }
1863
- for (const m of roomExtra){
1864
- rows.push(roomFeedLine(m.who, 'pending', m.text, true));
2161
+ const badge = merged
2162
+ ? '<span class="run-badge merged">merged → base</span>'
2163
+ : (running ? '<span class="run-badge running">running</span>' : '');
2164
+ // running·merged 는 체크박스로 선택 불가(정착·미머지만 Integrate 대상)
2165
+ const selectable = !merged && !running && (r.filesChanged||0)>0;
2166
+ const cb = '<span class="cb" data-rcb="'+(selectable?r.runId:'')+'"'+(selectable?'':' style="opacity:.4;cursor:default"')+'>'+(sel?ic('check'):'')+'</span>';
2167
+ return '<div class="run'+(sel?' sel':'')+(merged?' dim':'')+(open?' open':'')+'" data-rrun="'+r.runId+'">'
2168
+ + '<div class="run-h">'+cb
2169
+ + '<span class="dot '+dotCls+'"></span>'
2170
+ + '<span class="run-id">r'+r.runId+'</span>'
2171
+ + '<span class="run-title">'+esc(r.title||'task '+r.taskId)+'</span>'
2172
+ + '<span class="grow"></span>'
2173
+ + badge
2174
+ + '<span class="run-meta">'+meta+'</span>'
2175
+ + acts
2176
+ + '<span class="caret">›</span>'
2177
+ + '</div>'
2178
+ + '<div class="run-b" data-rbody="'+r.runId+'">'
2179
+ + '<div class="rout" data-rout="'+r.runId+'"><div class="rout-cards"><div class="ocards-empty">…</div></div></div>'
2180
+ + (roomDone(s) && r.steerable
2181
+ ? '<div class="fix"><input data-rfix="'+r.runId+'" placeholder="수정 지시 — 같은 세션·worktree 이어서…" />'
2182
+ + '<button class="b" data-ract="fixsend" data-rrid="'+r.runId+'">수정 지시 → 세션 이어서</button></div>'
2183
+ : '<div class="review"><span class="rk">·</span><span class="rt">이 run 은 steer 불가('
2184
+ + (running?'아직 실행 중':(roomDone(s)?'세션 없음 — dry/정리됨':'상태 '+esc(s)))+').</span></div>')
2185
+ + '</div></div>';
2186
+ }
2187
+ function roomRenderRuns(){
2188
+ const el = $('roomRuns');
2189
+ if (!roomRuns.length){
2190
+ el.innerHTML = '<div class="empty-note">no runs yet — spawn an attempt or wait for the plan to fan out</div>';
2191
+ roomUpdateGbar(); return;
2192
+ }
2193
+ el.innerHTML = roomRuns.map(roomRunRowHTML).join('');
2194
+ // 펼쳐진 행의 출력 카드 재하이드레이트
2195
+ for (const r of roomRuns){ if (roomOpen.has(r.runId)) roomLoadRunOutputs(r.runId); }
2196
+ roomUpdateGbar();
2197
+ }
2198
+ function roomUpdateGbar(){
2199
+ const n = roomSel.size;
2200
+ $('roomSelC').textContent = '☑ '+n+' selected';
2201
+ $('roomIntegrateSel').textContent = 'Integrate 선택 ('+n+')';
2202
+ $('roomIntegrateSel').disabled = n===0;
2203
+ }
2204
+ /* 펼친 행의 출력 카드 로드 — 모달과 같은 /outputs + renderOutCardInto 재사용(중복 없음). */
2205
+ async function roomLoadRunOutputs(rid){
2206
+ const wrap = document.querySelector('.rout[data-rout="'+rid+'"]'); if (!wrap) return;
2207
+ const cards = wrap.querySelector('.rout-cards');
2208
+ cards.innerHTML = '<div class="ocards-empty">loading…</div>';
2209
+ try{
2210
+ const j = await fetch('/api/runs/'+rid+'/outputs').then(x=>x.json());
2211
+ if (!roomOpen.has(rid)) return;
2212
+ const list = j.outputs||[];
2213
+ if (!list.length){ cards.innerHTML = '<div class="ocards-empty">no outputs yet</div>'; return; }
2214
+ wrap.dataset.cards = JSON.stringify(list);
2215
+ cards.innerHTML = list.map(outCardHTML).join('');
2216
+ }catch{ cards.innerHTML = '<div class="ocards-empty">outputs failed</div>'; }
2217
+ }
2218
+ /* 워크룸 출력 카드 클릭 → 그 행 안에서 상세 뷰어(모달과 동일 렌더러). */
2219
+ function roomOpenCard(rid, i){
2220
+ const wrap = document.querySelector('.rout[data-rout="'+rid+'"]'); if (!wrap) return;
2221
+ const list = wrap.dataset.cards ? JSON.parse(wrap.dataset.cards) : [];
2222
+ const c = list[i]; if (!c || !c.present) return;
2223
+ let det = wrap.querySelector('.rout-detail');
2224
+ if (!det){
2225
+ det = document.createElement('div'); det.className='rout-detail';
2226
+ det.innerHTML = '<div class="rout-back">‹ 산출물 목록</div><div class="rout-body"></div>';
2227
+ wrap.appendChild(det);
1865
2228
  }
1866
- $('roomFeed').innerHTML = rows.length ? rows.join('')
1867
- : '<div class="empty-note">no activity yet — spawn an attempt or wait for runs to report</div>';
1868
- $('roomFeed').scrollTop = $('roomFeed').scrollHeight;
2229
+ det.style.display='';
2230
+ renderOutCardInto(rid, c, det.querySelector('.rout-body'), ()=>roomOpen.has(rid));
1869
2231
  }
1870
2232
  /* Ask 대화(읽기 전용) — you / coordinator 턴. 코디네이터 답 아래 read-only 노트. */
1871
2233
  function roomRenderConv(){
@@ -1901,10 +2263,11 @@ function roomRenderHint(){
1901
2263
  const steerable = roomRuns.filter(r=>r.steerable).length;
1902
2264
  $('roomHint').textContent = steerable+' steerable now · '+running+' still running';
1903
2265
  }
1904
- /* feed↔conv, verbs↔send-only 를 roomMode 로 스왑. */
2266
+ /* runs(수렴 콕핏)↔conv, verbs↔send-only 를 roomMode 로 스왑. Work=결정 패널·Ask=읽기전용. */
1905
2267
  function roomApplyMode(){
1906
2268
  const ask = roomMode==='ask';
1907
- $('roomFeed').hidden = ask;
2269
+ $('roomRuns').hidden = ask;
2270
+ $('roomGbar').hidden = ask; // 그룹 액션 바는 Work 에서만
1908
2271
  $('roomConv').hidden = !ask;
1909
2272
  $('roomVerbs').hidden = ask;
1910
2273
  $('roomSend').hidden = !ask;
@@ -1917,15 +2280,17 @@ function roomApplyMode(){
1917
2280
  }
1918
2281
  async function openRoom(groupId){
1919
2282
  roomGroupId = groupId;
1920
- roomExtra.length = 0; roomRunSet.clear(); roomRuns = []; roomTurns.length = 0;
2283
+ roomRunSet.clear(); roomRuns = []; roomTurns.length = 0;
2284
+ roomSel.clear(); roomOpen.clear();
1921
2285
  roomMode = 'work';
1922
2286
  $('roomInput').value = '';
1923
2287
  $('roomChips').innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">loading…</span>';
1924
- $('roomFeed').innerHTML = '';
2288
+ $('roomRuns').innerHTML = '';
1925
2289
  $('roomConv').innerHTML = '';
1926
2290
  $('roomTitle').textContent = 'Goal';
1927
2291
  $('roomCount').textContent = '';
1928
2292
  $('roomHint').textContent = '';
2293
+ roomUpdateGbar();
1929
2294
  roomApplyMode();
1930
2295
  $('groupRoomOverlay').classList.add('open');
1931
2296
  await roomLoad();
@@ -1940,9 +2305,12 @@ async function roomLoad(){
1940
2305
  roomRuns = j.runs||[];
1941
2306
  roomRunSet.clear();
1942
2307
  roomRuns.forEach(r=>roomRunSet.add(r.runId));
2308
+ // 사라진 run 은 선택/펼침에서 정리
2309
+ for (const id of [...roomSel]) if (!roomRunSet.has(id)) roomSel.delete(id);
2310
+ for (const id of [...roomOpen]) if (!roomRunSet.has(id)) roomOpen.delete(id);
1943
2311
  roomRenderChips();
1944
2312
  roomRenderHeaderAndHint();
1945
- roomRenderFeed(j.events||[]);
2313
+ roomRenderRuns();
1946
2314
  }
1947
2315
  function closeRoom(){ roomGroupId = null; $('groupRoomOverlay').classList.remove('open'); }
1948
2316
  /* chip 클릭 → 그 run 을 기존 run 모달로 */
@@ -1957,6 +2325,151 @@ $('roomChips').addEventListener('click',(e)=>{
1957
2325
  });
1958
2326
  $('roomClose').addEventListener('click', closeRoom);
1959
2327
  $('groupRoomOverlay').addEventListener('click',(e)=>{ if(e.target===$('groupRoomOverlay')) closeRoom(); });
2328
+
2329
+ /* ── 수렴 콕핏 상호작용 — 행 펼침·체크박스·인라인 액션(전부 기존 엔드포인트 재사용) ── */
2330
+ $('roomRuns').addEventListener('click', async (e)=>{
2331
+ // 출력 카드 클릭 → 행 안 상세 뷰어
2332
+ const card = e.target.closest ? e.target.closest('.ocard') : null;
2333
+ if (card && !card.classList.contains('miss')){
2334
+ const wrap = card.closest('.rout'); if (wrap){ roomOpenCard(Number(wrap.dataset.rout), Number(card.dataset.oi)); return; }
2335
+ }
2336
+ const back = e.target.closest ? e.target.closest('.rout-back') : null;
2337
+ if (back){ const det = back.closest('.rout-detail'); if (det) det.style.display='none'; return; }
2338
+ // 체크박스 → Integrate 선택 토글
2339
+ const cb = e.target.closest ? e.target.closest('.cb') : null;
2340
+ if (cb){
2341
+ e.stopPropagation();
2342
+ const id = Number(cb.dataset.rcb); if (!id) return;
2343
+ if (roomSel.has(id)) roomSel.delete(id); else roomSel.add(id);
2344
+ cb.innerHTML = roomSel.has(id) ? ic('check') : '';
2345
+ cb.closest('.run').classList.toggle('sel', roomSel.has(id));
2346
+ roomUpdateGbar();
2347
+ return;
2348
+ }
2349
+ // 인라인 액션 버튼
2350
+ const ab = e.target.closest ? e.target.closest('[data-ract]') : null;
2351
+ if (ab){ e.stopPropagation(); await roomRunAction(ab.dataset.ract, Number(ab.dataset.rrid)); return; }
2352
+ // head 클릭 → 펼침 토글
2353
+ const head = e.target.closest ? e.target.closest('.run-h') : null;
2354
+ if (head){
2355
+ const rid = Number(head.parentElement.dataset.rrun);
2356
+ if (roomOpen.has(rid)){ roomOpen.delete(rid); head.parentElement.classList.remove('open'); }
2357
+ else { roomOpen.add(rid); head.parentElement.classList.add('open'); roomLoadRunOutputs(rid); }
2358
+ }
2359
+ });
2360
+ /* 행 액션 디스패치 — merge/steer/review/close 서버 엔드포인트만. 결정 로직 재구현 없음. */
2361
+ async function roomRunAction(act, rid){
2362
+ const r = roomRuns.find(x=>x.runId===rid); if(!r) return;
2363
+ if (act==='open'){
2364
+ closeRoom();
2365
+ if (runs.has(rid)) openModal(rid);
2366
+ else fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid,{...d.run,events:d.events||[]}); openModal(rid); } }).catch(()=>{});
2367
+ return;
2368
+ }
2369
+ if (act==='fix'){ // 펼치고 steer 입력에 포커스
2370
+ if (!roomOpen.has(rid)){ roomOpen.add(rid); const row=document.querySelector('.run[data-rrun="'+rid+'"]'); if(row){ row.classList.add('open'); roomLoadRunOutputs(rid); } }
2371
+ const inp = document.querySelector('[data-rfix="'+rid+'"]'); if (inp) inp.focus();
2372
+ else toast('r'+rid+': steer 불가(정착·세션 필요)', 'error');
2373
+ return;
2374
+ }
2375
+ if (act==='fixsend'){
2376
+ const inp = document.querySelector('[data-rfix="'+rid+'"]'); const msg = inp ? inp.value.trim() : '';
2377
+ if (!msg){ if(inp) inp.focus(); return; }
2378
+ const res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',
2379
+ headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg, mode:'work'})});
2380
+ if (res.ok){ if(inp) inp.value=''; toast('r'+rid+': 수정 지시 — 같은 세션 이어서', 'ok'); }
2381
+ else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
2382
+ return;
2383
+ }
2384
+ if (act==='review'){
2385
+ await reviewOneRun(r);
2386
+ return;
2387
+ }
2388
+ if (act==='merge'){
2389
+ const yes = await confirmUI('Merge r'+rid+' into the base branch?',
2390
+ { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
2391
+ if (!yes) return;
2392
+ const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
2393
+ const j = await res.json().catch(()=>({detail:'merge failed'}));
2394
+ if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await roomLoad(); hydrate(); }
2395
+ else toast('merge: '+(j.detail||res.status), 'error');
2396
+ return;
2397
+ }
2398
+ if (act==='close'){
2399
+ const yes = await confirmUI('Close r'+rid+' task?',
2400
+ { sub: 'Stops any live runs and removes every worktree and branch of the task.', danger:true, okLabel:'Close task' });
2401
+ if (!yes) return;
2402
+ const close = (force)=>fetch('/api/tasks/'+r.taskId+'/close',{method:'POST',
2403
+ headers:{'content-type':'application/json'}, body:JSON.stringify({force})});
2404
+ let res = await close(false);
2405
+ if (res.status===409){
2406
+ const j = await res.json().catch(()=>({}));
2407
+ const risk = (j.atRisk||[]).map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
2408
+ const ok = await confirmUI('Close and delete unmerged output?',
2409
+ { danger:true, sub: risk+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
2410
+ if (!ok) return;
2411
+ res = await close(true);
2412
+ }
2413
+ if (!res.ok){ toast('close failed ('+res.status+')', 'error'); return; }
2414
+ toast('r'+rid+' task closed', 'ok');
2415
+ await roomLoad(); hydrate();
2416
+ return;
2417
+ }
2418
+ }
2419
+ /* [리뷰] — 그 run 의 태스크에 reviewTask(/tasks/:id/review) 를 돌려 요약을 행 안에 인라인 표시. */
2420
+ async function reviewOneRun(r){
2421
+ const row = document.querySelector('.run[data-rrun="'+r.runId+'"]'); if(!row) return;
2422
+ if (!roomOpen.has(r.runId)){ roomOpen.add(r.runId); row.classList.add('open'); roomLoadRunOutputs(r.runId); }
2423
+ const body = row.querySelector('.run-b');
2424
+ let rv = body.querySelector('.review.airev');
2425
+ if (!rv){ rv = document.createElement('div'); rv.className='review airev'; body.insertBefore(rv, body.querySelector('.fix')||null); }
2426
+ const real = $('taskReal').checked;
2427
+ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">reviewing… ('+(real?'real':'dry')+')</span>';
2428
+ try{
2429
+ const res = await fetch('/api/tasks/'+r.taskId+'/review',{method:'POST',
2430
+ headers:{'content-type':'application/json'}, body:JSON.stringify({real})});
2431
+ const j = await res.json().catch(()=>({}));
2432
+ if (res.ok){ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">'+mdLite(j.review||'(no summary)')+'</span>'; }
2433
+ else { rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">review 실패 — '+esc(j.detail||String(res.status))+'</span>'; toast('review: '+(j.detail||res.status), 'error'); }
2434
+ }catch{ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">review 요청 실패</span>'; }
2435
+ }
2436
+ /* steer 입력 Enter → 전송 */
2437
+ $('roomRuns').addEventListener('keydown', (e)=>{
2438
+ const inp = e.target.closest ? e.target.closest('[data-rfix]') : null;
2439
+ if (inp && e.key==='Enter'){ e.preventDefault(); roomRunAction('fixsend', Number(inp.dataset.rfix)); }
2440
+ });
2441
+ /* ── 그룹 액션 바 — Integrate 선택 · 전체 리뷰 · 그룹 클로즈(전부 기존 흐름 재사용) ── */
2442
+ $('roomIntegrateSel').addEventListener('click', async ()=>{
2443
+ if (!roomSel.size){ toast('select settled runs with changes first', 'error'); return; }
2444
+ // 기존 select mode + selbar + /api/integrate 재사용 — 방을 닫고 선택을 밴드 select 로 옮긴다.
2445
+ const ids = [...roomSel].sort((a,b)=>a-b);
2446
+ closeRoom();
2447
+ if (!selectMode) setSelectMode(true);
2448
+ selected.clear(); selOrder.length = 0;
2449
+ for (const id of ids){
2450
+ const r = runs.get(id);
2451
+ const ok = r && ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
2452
+ if (ok){ selected.add(id); selOrder.push(id); }
2453
+ }
2454
+ $('selCnt').textContent = selected.size + ' selected';
2455
+ render();
2456
+ if (!selected.size) toast('selected runs are no longer integratable', 'error');
2457
+ else toast(selected.size+' run(s) staged — press Integrate in the bar', 'ok');
2458
+ });
2459
+ $('roomReviewAll').addEventListener('click', async ()=>{
2460
+ // 정착·변경 있는 run 을 위→아래로 리뷰(reviewTask 루프). dry/real 은 전역 토글.
2461
+ const targets = roomRuns.filter(r=>roomDone(r.status) && (r.filesChanged||0)>0 && r.status!=='merged');
2462
+ if (!targets.length){ toast('no settled runs with changes to review', 'error'); return; }
2463
+ const btn = $('roomReviewAll'); btn.disabled = true;
2464
+ try{ for (const r of targets) await reviewOneRun(r); }
2465
+ finally { btn.disabled = false; }
2466
+ });
2467
+ $('roomGroupClose').addEventListener('click', async ()=>{
2468
+ if (roomGroupId==null) return;
2469
+ const g = roomGroupId;
2470
+ await closeGroup(g); // 기존 그룹 클로즈(guard·atRisk 집계 재사용)
2471
+ if (roomGroupId===g) await roomLoad();
2472
+ });
1960
2473
  /* + New attempt — 같은 그룹에 새 시도 발사 (현재 dry/real 토글 반영) */
1961
2474
  $('roomNew').addEventListener('click', async ()=>{
1962
2475
  if (roomGroupId==null) return;
@@ -1970,9 +2483,9 @@ $('roomNew').addEventListener('click', async ()=>{
1970
2483
  const j = await res.json().catch(()=>({}));
1971
2484
  if (res.ok){
1972
2485
  $('roomInput').value='';
1973
- (j.tasks||[]).forEach(t=>{ roomExtra.push({ who:'goal +', text:'new attempt r'+t.runId+' — '+prompt.slice(0,60) }); roomRunSet.add(t.runId); });
2486
+ (j.tasks||[]).forEach(t=>{ roomRunSet.add(t.runId); });
1974
2487
  toast((j.tasks||[]).length+' attempt(s) launched'+(real?'':' (dry)'), 'ok');
1975
- roomRenderFeed([]); await roomLoad(); hydrate();
2488
+ await roomLoad(); hydrate();
1976
2489
  } else toast('spawn: '+(j.error||res.status), 'error');
1977
2490
  } finally { btn.disabled = false; }
1978
2491
  });
@@ -1988,9 +2501,8 @@ $('roomBroadcast').addEventListener('click', async ()=>{
1988
2501
  const j = await res.json().catch(()=>({}));
1989
2502
  if (res.ok){
1990
2503
  $('roomInput').value='';
1991
- roomExtra.push({ who:'goal →', text:message.slice(0,80)+' ('+j.detail+')' });
1992
2504
  toast('broadcast: '+j.detail, (j.skipped&&j.skipped.length)?undefined:'ok');
1993
- roomRenderFeed([]); await roomLoad();
2505
+ await roomLoad();
1994
2506
  } else toast('broadcast: '+(j.error||res.status), 'error');
1995
2507
  } finally { btn.disabled = false; }
1996
2508
  });
@@ -2056,18 +2568,9 @@ function roomOnWS(ev){
2056
2568
  if (ev.type==='run'){
2057
2569
  const rid = ev.runId ?? ev.id;
2058
2570
  if (!roomRunSet.has(rid)) return; // 이 방 소속만
2059
- roomLoad(); // 상태·steerable 재계산은 aggregate (정확)
2060
- } else if (ev.type==='event'){
2061
- if (!roomRunSet.has(ev.runId)) return;
2062
- const h = humanize({ kind:ev.kind, payload:ev.payload });
2063
- if (h){
2064
- const r = roomRuns.find(x=>x.runId===ev.runId);
2065
- const list = Array.isArray(h) ? h : [h];
2066
- const frag = list.map(x=>roomFeedLine('r'+ev.runId, r?r.status:'pending', x.k+' · '+x.t, false)).join('');
2067
- $('roomFeed').insertAdjacentHTML('beforeend', frag);
2068
- $('roomFeed').scrollTop = $('roomFeed').scrollHeight;
2069
- }
2571
+ roomLoad(); // 상태·steerable 재계산 결정 행 재렌더(aggregate, 정확)
2070
2572
  }
2573
+ // event 티커는 수렴 콕핏에선 행 안 출력 카드로 대체 — 상태 변화는 run 이벤트가 재렌더한다.
2071
2574
  }
2072
2575
 
2073
2576
  /* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
@@ -2269,15 +2772,29 @@ $('taskForm').addEventListener('submit', async (e)=>{
2269
2772
  const title = $('taskTitle').value.trim();
2270
2773
  if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
2271
2774
  const capId = Number($('taskCapture').value) || undefined;
2775
+ const outputs = selectedOutputs();
2272
2776
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
2273
- body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
2777
+ body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId,outputs})}).then(x=>x.json());
2274
2778
  if (!t.ok){ toast('task create failed', 'error'); return; }
2275
2779
  tasks.set(t.task.id, t.task);
2276
2780
  const model = $('taskModel').value.trim();
2277
2781
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
2278
2782
  body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
2279
2783
  if (model) rememberModel(model);
2280
- $('taskTitle').value=''; $('taskPrompt').value='';
2784
+ $('taskTitle').value=''; $('taskPrompt').value=''; clearOutputs();
2785
+ });
2786
+ /* ── deliverable 계약 칩 — 선택된 타입 배열을 tasks.outputs 로 보낸다(기본=빈=오늘의 동작) ── */
2787
+ const OUTPUT_ORDER = ['answer','code','doc','page','file'];
2788
+ function selectedOutputs(){
2789
+ const on = new Set(Array.from(document.querySelectorAll('#taskOutputs .ochip.on')).map(b=>b.dataset.out));
2790
+ return OUTPUT_ORDER.filter(t=>on.has(t));
2791
+ }
2792
+ function clearOutputs(){ document.querySelectorAll('#taskOutputs .ochip.on').forEach(b=>b.classList.remove('on')); }
2793
+ document.querySelectorAll('#taskOutputs .ochip').forEach(b=>{
2794
+ b.addEventListener('click', ()=>{
2795
+ const on = b.classList.toggle('on');
2796
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
2797
+ });
2281
2798
  });
2282
2799
 
2283
2800
  /* ── model 최근값 기억(기기별, 최대 5) ── */