coxpit 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "6.1.0",
3
+ "version": "6.3.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": "SEE LICENSE IN LICENSE.md",
package/src/board.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // 데몬이 서빙하는 단일 페이지 플릿 콘솔(빌드 스텝 0, 자가완결).
2
2
  // /api/fleet 로 하이드레이트 → /ws 구독 델타 → run 상세(타임라인·diff·터미널)·비교/머지.
3
3
  import { ICON_SPRITE, ICON_CSS, ICON_JS_HELPER } from './icons.js';
4
+ import { HUMANIZE_JS } from './humanize';
4
5
 
5
6
  export const BOARD_HTML = /* html */ `<!doctype html>
6
7
  <html lang="en">
@@ -553,6 +554,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
553
554
  .chip.noop{color:#c9922e;border-color:rgba(201,146,46,.45)}
554
555
  .chip.noop.blk{color:#e0955a;border-color:rgba(224,149,90,.6)}
555
556
  .card-h .chip.noop::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:1px}
557
+ /* v5.28 H2 — dry(모의) run 표식. 낱말 하나를 조용한 톤으로: 색은 새로 만들지 않고
558
+ --faint 글자 + --line-hi 테두리만 쓴다. 진짜 run 에는 아무 칩도 붙지 않는다. */
559
+ .dryc{display:inline-flex;align-items:center;font-family:var(--mono);font-size:9.5px;line-height:1.6;
560
+ letter-spacing:.06em;padding:2px 6px;border-radius:999px;color:var(--faint);border:1px solid var(--line-hi);
561
+ background:transparent;flex:none}
556
562
  .meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
557
563
  .meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
558
564
  .meta .resumable{color:var(--brand);opacity:.75}
@@ -727,6 +733,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
727
733
  .cmp-h .files{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
728
734
  .cmp-meta{font-family:var(--mono);font-size:11px;color:var(--faint);padding:8px 14px;border-bottom:1px solid var(--line);
729
735
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:var(--surface2)}
736
+ /* 머지 직후의 base 검증 한 줄 (v5.28 J2). 판정보다 안내가 길어서 여기만 줄바꿈한다 —
737
+ 잘린 안내는 안내가 아니다. 색은 기존 상태 토큰만 쓰고 새 색은 없다. */
738
+ .cmp-mv{font-family:var(--mono);font-size:11px;color:var(--muted);padding:7px 14px;
739
+ border-bottom:1px solid var(--line);background:var(--surface2);line-height:1.55}
740
+ .cmp-mv .mvout{cursor:pointer}
741
+ .cmp-mv a{color:var(--brand)}
730
742
  .cmp-diff{overflow:auto;padding:11px 14px;flex:1;min-height:0}
731
743
  .cmp-f{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center}
732
744
  .cmp-f .msg{font-family:var(--mono);font-size:11px;color:var(--muted);flex:1;
@@ -1327,6 +1339,18 @@ const runs = new Map(); // runId -> run object
1327
1339
  const tasks = new Map(); // taskId -> task
1328
1340
  const groups = new Map(); // groupId -> {id, kind, title}
1329
1341
  let repos = [], machines = [], captures = [];
1342
+ /* v5.28 I — 보드는 에이전트 작업의 열람실이다. 자유 터미널 세션은 run 이 아니므로
1343
+ 여기 서지 않는다(세션의 집은 코크핏이고, 거기선 그대로 산다). sessions 버킷
1344
+ (repo.kind==='sessions') 에 속한 repo·task·run 은 보드 상태에 아예 들이지 않는다 —
1345
+ 하이드레이트·WS 델타·단건 fetch 전부 같은 문을 지난다. */
1346
+ let sessionRepoIds = new Set(); // kind==='sessions' 인 repo id (머신마다 하나)
1347
+ let sessionRepoNames = new Set(); // 아카이브 행엔 repoId 가 없다 — 거기선 이름이 유일한 열쇠
1348
+ let sessionTaskIds = new Set(); // 그 버킷에 담긴 task id
1349
+ let sessionRunIds = new Set(); // 그 task 들의 run id (taskId 없는 상태 패치 델타용)
1350
+ const isSessionRepo = (repoId) => sessionRepoIds.has(repoId);
1351
+ const isSessionTask = (taskId) => sessionTaskIds.has(taskId);
1352
+ // run 객체와 WS 델타를 모두 받는다(델타는 runId, 객체는 id).
1353
+ const isSessionRun = (r) => !!r && (sessionRunIds.has(r.runId ?? r.id) || isSessionTask(r.taskId));
1330
1354
  // v5.0 rail — 선택된 repo 로 보드 스코프(client-side). null = All repositories.
1331
1355
  let selectedRepo = null;
1332
1356
  try { const s = localStorage.getItem('coxpit.repo'); selectedRepo = s ? Number(s) : null; } catch {}
@@ -1479,64 +1503,7 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
1479
1503
  $('cfmCancel').addEventListener('click', ()=>cfmClose(false));
1480
1504
  $('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
1481
1505
 
1482
- /* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
1483
- function humanize(e){
1484
- const kind = e.kind, payload = e.payload;
1485
- if (kind === 'rate_limit_event') return null;
1486
- if (kind === 'steer') return { k:'steer', t:'→ '+payload };
1487
- if (kind === 'ask') return { k:'ask', t:'? '+payload };
1488
- if (kind === 'sync') return { k:'sync', t:payload };
1489
- if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
1490
- if (kind === 'pr') return { k:'pr', t:payload };
1491
- if (kind === 'stderr') return { k:'stderr', t:payload };
1492
- try{
1493
- const o = JSON.parse(payload);
1494
- if (o.type === 'system'){
1495
- if (o.subtype === 'init' || !o.subtype) return { k:'session',
1496
- t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
1497
- .replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
1498
- if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
1499
- return null; // thinking_tokens 등 스트림 잡음
1500
- }
1501
- if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
1502
- if (o.type === 'assistant' && o.message){
1503
- const parts = [];
1504
- for (const x of (o.message.content||[])){
1505
- if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
1506
- else if (x.type === 'tool_use'){
1507
- const i = x.input || {};
1508
- const arg = i.file_path || i.command || i.path || i.pattern || '';
1509
- parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
1510
- }
1511
- }
1512
- return parts.length ? parts : null;
1513
- }
1514
- if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
1515
- if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
1516
- if (kind === 'meta' && o.subtask) return { k:'swarm', t:'↳ spawned task #'+o.subtask+' — '+String(o.title||'').slice(0,60)+' ('+((o.runs||[]).map(x=>'r'+x).join(' '))+')' };
1517
- if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
1518
- return { k:kind, t:payload.slice(0,140) };
1519
- }catch{
1520
- // 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
1521
- // text 조각만 구제하고, 없으면 생략.
1522
- if (payload.trim().startsWith('{')){
1523
- const texts = [];
1524
- const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
1525
- while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
1526
- return texts.length ? { k:'said', t:texts.join(' · ') } : null;
1527
- }
1528
- return { k:kind, t:payload };
1529
- }
1530
- }
1531
- function humanLines(events){
1532
- const out = [];
1533
- for (const e of (events||[])){
1534
- const h = humanize(e);
1535
- if (!h) continue;
1536
- if (Array.isArray(h)) out.push(...h); else out.push(h);
1537
- }
1538
- return out;
1539
- }
1506
+ ${HUMANIZE_JS}
1540
1507
  function summarize(kind, payload){
1541
1508
  const h = humanize({kind, payload});
1542
1509
  if (!h) return '';
@@ -1569,6 +1536,42 @@ function chipHTML(status){
1569
1536
  return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
1570
1537
  + '<i></i>'+esc(s)+'</span>';
1571
1538
  }
1539
+ /* ── dry(모의) run (v5.28 H) ──
1540
+ 판정은 real===false 하나뿐이다. undefined(이 컬럼 이전의 run, 아직 안 온 델타)는 **모르는 것**이지
1541
+ dry 가 아니다 — 모르는 것을 모의라 부르면 거짓 경보가 된다. dry 를 배지하되, dry 를 추측하지 않는다. */
1542
+ function isDryRun(r){ return !!r && r.real===false; }
1543
+ function dryChip(r){
1544
+ return isDryRun(r)
1545
+ ? '<span class="dryc" title="dry run — a mock stream made these changes (not a real agent)">dry</span>' : '';
1546
+ }
1547
+ /* ── 머지 직후의 base 검증 (v5.28 J2) ──
1548
+ 승자가 내려앉은 그 호흡의 판정을, 머지를 누른 그 자리에 남긴다. 서버가 머지 응답에
1549
+ 실어 보낸 것(verify:{status,output})만 그린다 — 클라이언트는 아무것도 추측하지 않는다.
1550
+ 색은 기존 상태 토큰 그대로(pass=done · fail/error=failed), 새 색 없음. */
1551
+ const mergeVerifyByRun = {};
1552
+ function mergeVerifyHTML(rid){
1553
+ const v = mergeVerifyByRun[rid]; if (!v) return '';
1554
+ if (!v.status){
1555
+ return '<div class="cmp-mv">merged · 검증 명령이 없어요 — repo 설정에서 verifyCmd 를 지정하면 승자를 자동 검증합니다 '
1556
+ + '<a href="/cockpit" target="_blank" rel="noopener">verifyCmd 설정</a></div>';
1557
+ }
1558
+ const col = statusColor(v.status==='pass' ? 'done' : 'failed');
1559
+ return '<div class="cmp-mv"><span class="mvout" data-mvout="'+rid+'" style="color:'+col+'"'
1560
+ + ' title="click to see the verify output">merged · verify: '+esc(v.status)+'</span>'
1561
+ + (v.status==='pass' ? '' : ' 머지는 됐지만 검증 실패 — 로그 확인') + '</div>';
1562
+ }
1563
+ /* 검증은 보고일 뿐 — 실패해도 머지는 서 있다. 토스트의 어조만 달라진다. */
1564
+ function mergeVerifyToast(v){
1565
+ if (!v || !v.status) return { suffix: ' · 검증 명령 없음 — repo 의 verifyCmd 를 지정하면 자동 검증', level: 'ok' };
1566
+ return { suffix: ' · verify: '+v.status, level: v.status==='pass' ? 'ok' : 'error' };
1567
+ }
1568
+ /* 모의를 진짜처럼 base 로 넘기지 않게 — 아는 dry 에만 한 번 묻는다. */
1569
+ function confirmDryMerge(n){
1570
+ return confirmUI('이 run 은 dry (모의)입니다 — 정말 머지할까요?', {
1571
+ sub: (n>1 ? '고른 '+n+'개가 dry 입니다. ' : '') + 'dry run 의 변경은 모의 스트림이 만든 것입니다 — 진짜 작업이 아닙니다.',
1572
+ danger: true, okLabel: '그래도 머지',
1573
+ });
1574
+ }
1572
1575
  /* 같은 태스크에 run 이 여럿이면 "run i/n" — 같은 제목 카드가 중복이 아니라 시도로 읽히게 */
1573
1576
  function attemptHTML(r){
1574
1577
  const sib = [...runs.values()].filter(x=>x.taskId===r.taskId).sort((a,b)=>a.id-b.id);
@@ -1748,7 +1751,7 @@ $('dbList').addEventListener('click', (e)=>{
1748
1751
  // documents include closed/archived runs not in the live map — seed a minimal run+task so the modal renders
1749
1752
  if (!runs.has(id)){
1750
1753
  const dr = (dbData||[]).find(r=>r.runId===id);
1751
- if (dr){
1754
+ if (dr && !isSessionTask(dr.taskId)){ // v5.28 I
1752
1755
  runs.set(id, { id, taskId: dr.taskId, status: dr.status, branch:'', filesChanged:0, events:[], prUrl: dr.prUrl });
1753
1756
  if (!tasks.has(dr.taskId)) tasks.set(dr.taskId, { id: dr.taskId, title: dr.title, status:'closed', repoId:0 });
1754
1757
  }
@@ -1905,7 +1908,9 @@ async function archFetch(reset){
1905
1908
  try{
1906
1909
  const j = await fetch(url).then(x=>x.json());
1907
1910
  archTotal = j.total||0;
1908
- const rows = (j.rows||[]).map(archRowHTML).join('');
1911
+ // v5.28 I — 닫힌 세션도 보드의 아카이브엔 서지 않는다. 행엔 repoId 가 없어 이름으로 거른다
1912
+ // (페이지 진행은 서버가 준 행 수 그대로 — 거른 것 때문에 다음 장을 놓치지 않게).
1913
+ const rows = (j.rows||[]).filter(row => !sessionRepoNames.has(row.repoName)).map(archRowHTML).join('');
1909
1914
  if (reset) $('archList').innerHTML = rows || '<div class="arch-empty">no closed tasks match</div>';
1910
1915
  else $('archList').insertAdjacentHTML('beforeend', rows);
1911
1916
  archOffset += (j.rows||[]).length;
@@ -1922,6 +1927,7 @@ $('archList').addEventListener('click', async (e)=>{
1922
1927
  try{
1923
1928
  const j = await fetch('/api/tasks/'+tid).then(x=>x.json());
1924
1929
  if (!j.task || !(j.runs||[]).length){ toast('task has no runs', 'error'); return; }
1930
+ if (isSessionRepo(j.task.repoId)) return; // v5.28 I — 세션은 보드가 열지 않는다
1925
1931
  tasks.set(j.task.id, j.task);
1926
1932
  j.runs.forEach(rn => { if(!runs.has(rn.id)) runs.set(rn.id, { ...rn, events: [] }); });
1927
1933
  openModal(j.runs.map(r=>r.id).sort((a,b)=>a-b)[0]);
@@ -2029,7 +2035,7 @@ function cardHTML(r){
2029
2035
  const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
2030
2036
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
2031
2037
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
2032
- + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)
2038
+ + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)+dryChip(r)
2033
2039
  + (r.noop ? '<span class="chip noop'+(r.noopReason==='blocked'?' blk':'')+'" title="'
2034
2040
  +(r.noopReason==='blocked'?'settled without making the change it was asked for — likely stalled on approval':'this run changed no files — did it actually do the work?')
2035
2041
  +'">'+(r.noopReason==='blocked'?'blocked':'no changes')+'</span>' : '')
@@ -2049,6 +2055,7 @@ function cardHTML(r){
2049
2055
  function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
2050
2056
 
2051
2057
  function upsertRun(patch){
2058
+ if (isSessionRun(patch)) return; // v5.28 I — 세션은 보드 상태에 들어오지 않는다(마지막 문)
2052
2059
  const cur = runs.get(patch.runId ?? patch.id) || { id: patch.runId ?? patch.id, events: [] };
2053
2060
  Object.assign(cur, patch, { id: cur.id, events: cur.events });
2054
2061
  if (patch.runId) cur.id = patch.runId;
@@ -2057,14 +2064,21 @@ function upsertRun(patch){
2057
2064
 
2058
2065
  async function hydrate(){
2059
2066
  const r = await fetch('/api/fleet?view=active').then(x=>x.json());
2060
- machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
2067
+ // v5.28 I 거르는 기준부터 다시 세운다(repo 목록이 바뀌면 세션 집합도 같이 바뀐다).
2068
+ const allRepos = r.repos||[];
2069
+ const sessionRepos = allRepos.filter(x => x.kind === 'sessions');
2070
+ sessionRepoIds = new Set(sessionRepos.map(x => x.id));
2071
+ sessionRepoNames = new Set(sessionRepos.map(x => x.name));
2072
+ sessionTaskIds = new Set((r.tasks||[]).filter(t => isSessionRepo(t.repoId)).map(t => t.id));
2073
+ sessionRunIds = new Set((r.runs||[]).filter(rn => isSessionTask(rn.taskId)).map(rn => rn.id));
2074
+ machines = r.machines||[]; repos = allRepos.filter(x => !isSessionRepo(x.id)); captures = r.captures||[];
2061
2075
  if (r.counts){ const n = r.counts.closedTasks||0; $('navArchiveN').textContent = n ? String(n) : ''; }
2062
2076
  tasks.clear();
2063
- (r.tasks||[]).forEach(t => tasks.set(t.id, t));
2077
+ (r.tasks||[]).forEach(t => { if (!isSessionTask(t.id)) tasks.set(t.id, t); });
2064
2078
  groups.clear();
2065
2079
  (r.groups||[]).forEach(g => groups.set(g.id, g));
2066
2080
  runs.clear();
2067
- (r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
2081
+ (r.runs||[]).forEach(rn => { if (!isSessionRun(rn)) runs.set(rn.id, { ...rn, events: rn.events||[] }); });
2068
2082
  if (r.daemon) {
2069
2083
  const d = r.daemon;
2070
2084
  if (d.port) daemonPort = d.port;
@@ -2137,6 +2151,7 @@ function railCounts(){
2137
2151
  // repoId → { active, attn } — /api/fleet active runs 를 client-side 로 그룹
2138
2152
  const m = new Map();
2139
2153
  for (const r of runs.values()){
2154
+ if (isSessionRun(r)) continue; // v5.28 I — 세션은 세지 않는다(배지도 스코프도)
2140
2155
  const t = tasks.get(r.taskId); if (!t) continue;
2141
2156
  const rec = m.get(t.repoId) || { active:0, attn:false };
2142
2157
  rec.active++;
@@ -2292,6 +2307,11 @@ function connectWS(){
2292
2307
  const known = runs.has(rid);
2293
2308
  // 아카이브된(맵에 없는) run 의 뒤늦은 echo 는 무시 — 신규는 항상 pending 으로 먼저 온다.
2294
2309
  if (!known && ev.status && ev.status!=='pending') return;
2310
+ // v5.28 I — 보드가 열려 있는 사이 시작된 Scratch 세션은 여기서 멈춘다.
2311
+ if (isSessionRun(ev)) return;
2312
+ // 정체를 모르는 새 task 의 첫 델타는 넣지 않고 하이드레이트에 맡긴다 — 세션이면 그대로
2313
+ // 걸러지고, 에이전트 run 이면 같은 하이드레이트가 곧 세운다(잠깐 떴다 사라지지 않게).
2314
+ if (!known && ev.taskId!=null && !tasks.has(ev.taskId)){ hydrate(); return; }
2295
2315
  upsertRun(ev);
2296
2316
  if (!known && ev.taskId==null){ hydrate(); return; }
2297
2317
  if (ev.taskId!=null && !tasks.has(ev.taskId)) hydrate(); // 통합 태스크 등 신규 태스크 동기화
@@ -2304,6 +2324,7 @@ function connectWS(){
2304
2324
  r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
2305
2325
  render(); flash(ev.runId); paintModal();
2306
2326
  } else if (ev.type==='task'){
2327
+ if (isSessionTask(ev.taskId) || isSessionRepo(ev.repoId)) return; // v5.28 I
2307
2328
  const t = tasks.get(ev.taskId);
2308
2329
  // repoId 는 v6.0 S2 승격(재부모화)으로 바뀐다 — 안 받으면 repo 스코프가 다음 하이드레이트까지 어긋난다.
2309
2330
  if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; if (ev.repoId!=null) t.repoId = ev.repoId; render(); paintModal(); } else { hydrate(); }
@@ -2542,6 +2563,9 @@ $('selToggle').addEventListener('click', ()=>setSelectMode(!selectMode));
2542
2563
  $('selCancel').addEventListener('click', ()=>setSelectMode(false));
2543
2564
  $('selGo').addEventListener('click', async ()=>{
2544
2565
  if (!selOrder.length){ toast('select settled runs with changes first', 'error'); return; }
2566
+ // 묶어서 넘기는 길이 가장 조용하다 — 고른 것 중 아는 dry 가 하나라도 있으면 먼저 묻는다(H3).
2567
+ const dryN = selOrder.filter(id=>isDryRun(runs.get(id))).length;
2568
+ if (dryN && !(await confirmDryMerge(dryN))) return;
2545
2569
  const yes = await confirmUI('Integrate '+selOrder.length+' run(s) into the base branch?',
2546
2570
  { sub: 'Merged one by one in selection order. A run that conflicts spawns an integration agent (real, spends credits) to resolve it.', okLabel: 'Integrate' });
2547
2571
  if (!yes) return;
@@ -2736,9 +2760,11 @@ async function paintCompare(){
2736
2760
  const mergeable = ['done','failed','stopped'].includes(r.status) && files>0;
2737
2761
  const merged = r.status==='merged';
2738
2762
  return '<div class="cmp-col" data-run="'+r.id+'">'
2739
- + '<div class="cmp-h"><span class="rid">r'+r.id+'</span>'+chipHTML(r.status)
2763
+ + '<div class="cmp-h"><span class="rid">r'+r.id+'</span>'+chipHTML(r.status)+dryChip(r)
2740
2764
  + '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
2741
2765
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
2766
+ // 머지한 열은 그 판정을 계속 달고 있다 — 다시 그려도 지워지지 않게 여기서 그린다(J2).
2767
+ + mergeVerifyHTML(r.id)
2742
2768
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
2743
2769
  + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
2744
2770
  + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+ic('external-link')+' '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
@@ -2746,7 +2772,7 @@ async function paintCompare(){
2746
2772
  + (merged
2747
2773
  ? chipHTML('merged')
2748
2774
  : (r.prUrl ? '' : '<button class="btn-ghost sm" data-pr="'+r.id+'"'+(mergeable?'':' disabled')+'>Open PR</button>')
2749
- + '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
2775
+ + '<button class="btn sm" data-merge="'+r.id+'"'+(isDryRun(r)?' data-dry="1"':'')+(mergeable?'':' disabled')+'>Merge this</button>')
2750
2776
  + '</div></div>';
2751
2777
  }).join('');
2752
2778
  // doc 모드 — 각 열의 diff 를 렌더된 문서로 치환 (열별 비동기, 도착 순)
@@ -2776,6 +2802,13 @@ async function driftNote(rid){
2776
2802
  }catch(e){ return ''; }
2777
2803
  }
2778
2804
  $('cmpBody').addEventListener('click', async (e)=>{
2805
+ // 머지 검증 꼬리 — 기존 verify-output 보기와 같은 방식(전문은 토스트로)
2806
+ const mvo = e.target.closest('[data-mvout]');
2807
+ if (mvo){
2808
+ const v = mergeVerifyByRun[mvo.dataset.mvout];
2809
+ toast((v && v.output) ? v.output.slice(-600) : 'no verify output');
2810
+ return;
2811
+ }
2779
2812
  const prBtn = e.target.closest('button[data-pr]');
2780
2813
  if (prBtn){
2781
2814
  const rid = Number(prBtn.dataset.pr);
@@ -2803,6 +2836,8 @@ $('cmpBody').addEventListener('click', async (e)=>{
2803
2836
  }
2804
2837
  const btn = e.target.closest('button[data-merge]'); if(!btn) return;
2805
2838
  const rid = Number(btn.dataset.merge);
2839
+ // dry 열은 여기서 한 번 더 붙잡는다 — 나란히 놓고 고르는 자리가 실수가 나는 자리였다(H3).
2840
+ if (btn.dataset.dry==='1' && !(await confirmDryMerge(1))) return;
2806
2841
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
2807
2842
  { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.'+(await driftNote(rid)), okLabel: 'Merge' });
2808
2843
  if (!yes) return;
@@ -2811,7 +2846,13 @@ $('cmpBody').addEventListener('click', async (e)=>{
2811
2846
  const j = await res.json().catch(()=>({detail:'merge failed'}));
2812
2847
  const msg = $('cmpMsg-'+rid);
2813
2848
  if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
2814
- if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await paintCompare(); hydrate(); }
2849
+ if (res.ok){
2850
+ // 머지는 이미 섰다 — verify 는 그 위에 얹는 판정이지 되돌리는 신호가 아니다(J1/J3).
2851
+ mergeVerifyByRun[rid] = j.verify || { status:'', output:'' };
2852
+ const vt = mergeVerifyToast(j.verify);
2853
+ toast('r'+rid+' merged to base'+vt.suffix, vt.level);
2854
+ await paintCompare(); hydrate();
2855
+ }
2815
2856
  else { toast('merge: '+(j.detail||res.status), 'error'); btn.disabled = false; }
2816
2857
  });
2817
2858
  $('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
@@ -3074,7 +3115,7 @@ $('roomChips').addEventListener('click',(e)=>{
3074
3115
  closeRoom();
3075
3116
  if (runs.has(rid)) openModal(rid);
3076
3117
  else { // 아카이브 등 맵에 없는 run — 태스크로 하이드레이트 후 연다
3077
- fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{});
3118
+ fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run && !isSessionRun(d.run)){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{}); // v5.28 I
3078
3119
  }
3079
3120
  });
3080
3121
  $('roomClose').addEventListener('click', closeRoom);
@@ -3117,7 +3158,7 @@ async function roomRunAction(act, rid){
3117
3158
  if (act==='open'){
3118
3159
  closeRoom();
3119
3160
  if (runs.has(rid)) openModal(rid);
3120
- 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(()=>{});
3161
+ else fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run && !isSessionRun(d.run)){ runs.set(rid,{...d.run,events:d.events||[]}); openModal(rid); } }).catch(()=>{}); // v5.28 I
3121
3162
  return;
3122
3163
  }
3123
3164
  if (act==='fix'){ // 펼치고 steer 입력에 포커스
@@ -3140,12 +3181,18 @@ async function roomRunAction(act, rid){
3140
3181
  return;
3141
3182
  }
3142
3183
  if (act==='merge'){
3184
+ if (isDryRun(r) && !(await confirmDryMerge(1))) return;
3143
3185
  const yes = await confirmUI('Merge r'+rid+' into the base branch?',
3144
3186
  { sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
3145
3187
  if (!yes) return;
3146
3188
  const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
3147
3189
  const j = await res.json().catch(()=>({detail:'merge failed'}));
3148
- if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await roomLoad(); hydrate(); }
3190
+ if (res.ok){
3191
+ mergeVerifyByRun[rid] = j.verify || { status:'', output:'' };
3192
+ const vt = mergeVerifyToast(j.verify);
3193
+ toast('r'+rid+' merged to base'+vt.suffix, vt.level);
3194
+ await roomLoad(); hydrate();
3195
+ }
3149
3196
  else toast('merge: '+(j.detail||res.status), 'error');
3150
3197
  return;
3151
3198
  }
@@ -3545,7 +3592,7 @@ $('taskForm').addEventListener('submit', async (e)=>{
3545
3592
  const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
3546
3593
  body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId,outputs})}).then(x=>x.json());
3547
3594
  if (!t.ok){ toast('task create failed', 'error'); return; }
3548
- tasks.set(t.task.id, t.task);
3595
+ if (!isSessionRepo(t.task.repoId)) tasks.set(t.task.id, t.task); // v5.28 I
3549
3596
  const model = $('taskModel').value.trim();
3550
3597
  await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
3551
3598
  body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});