coxpit 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "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">
@@ -1479,64 +1480,7 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
1479
1480
  $('cfmCancel').addEventListener('click', ()=>cfmClose(false));
1480
1481
  $('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
1481
1482
 
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
- }
1483
+ ${HUMANIZE_JS}
1540
1484
  function summarize(kind, payload){
1541
1485
  const h = humanize({kind, payload});
1542
1486
  if (!h) return '';
package/src/cockpit.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Cockpit — 터미널 우선 셸 (병행 개발, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
2
2
  // 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
3
3
  // Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
4
+ import { HUMANIZE_JS } from './humanize';
5
+
4
6
  export const COCKPIT_HTML = /* html */ `<!doctype html>
5
7
  <html lang="en">
6
8
  <head>
@@ -293,6 +295,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
293
295
  .pick-row .ic{width:14px;text-align:center;color:var(--faint)}
294
296
  .pick-row.up .ic{color:var(--muted)}
295
297
  .pick-row .rp{margin-left:auto;font-size:9px;color:var(--brand);border:1px solid rgba(78,201,176,.3);border-radius:999px;padding:0 6px}
298
+ /* v5.28 G5 — 폴더 행 안의 인라인 액션(Register · Start here). 보드의 .btn 을 끌어오지 않는다(토큰만). */
299
+ .pick-row .pbtn{margin-left:auto;flex:none;font-family:var(--mono);font-size:10.5px;line-height:1;color:var(--muted);
300
+ background:none;border:1px solid var(--line-hi);border-radius:5px;padding:4px 7px;cursor:pointer}
301
+ .pick-row .rp+.pbtn{margin-left:8px}
302
+ .pick-row .pbtn:hover{color:var(--brand);background:var(--surface2)}
296
303
  .pick-f{display:flex;align-items:center;gap:10px;padding:12px 15px;border-top:1px solid var(--line)}
297
304
  .pick-f .go{margin-left:auto;font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 15px;cursor:pointer}
298
305
  .pick-f .home{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:7px;padding:7px 11px;cursor:pointer}
@@ -335,23 +342,77 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
335
342
  .leaf-h .qr{display:inline-flex;align-items:center;gap:3px;flex:none}
336
343
  .leaf-h .qr:empty{display:none}
337
344
  /* 빠른 답은 대기의 색(--blocked)을 빌린다 — 칩·미머지 표식과 같은 주의색, 새 색 없음 */
338
- .leaf-h .qbtn{font-family:var(--mono);font-size:10px;color:var(--blocked);background:none;
345
+ /* (v5.28 E — 같은 알약이 활동 페인 액션바에도 그대로 선다. 규칙은 안 바꾸고 선택자만 넓힌다) */
346
+ .act-bar .qbtn,.leaf-h .qbtn{font-family:var(--mono);font-size:10px;color:var(--blocked);background:none;
339
347
  border:1px solid var(--line-hi);border-radius:999px;padding:1px 7px;cursor:pointer;white-space:nowrap}
340
- .leaf-h .qbtn:hover,.leaf-h .qbtn:focus-visible{color:var(--ink);border-color:var(--blocked)}
341
- .leaf-h .qbtn.qadd{color:var(--faint);padding:1px 6px}
348
+ .act-bar .qbtn:hover,.act-bar .qbtn:focus-visible,.leaf-h .qbtn:hover,.leaf-h .qbtn:focus-visible{color:var(--ink);border-color:var(--blocked)}
349
+ .act-bar .qbtn.qadd,.leaf-h .qbtn.qadd{color:var(--faint);padding:1px 6px}
342
350
  /* 시작·이어서는 부름이 아니라 제안이라 더 조용하다(--muted 위 --line) */
343
351
  .leaf-h .sbtn{font-family:var(--mono);font-size:10px;color:var(--muted);background:none;
344
352
  border:1px solid var(--line);border-radius:999px;padding:1px 7px;cursor:pointer;white-space:nowrap}
345
353
  .leaf-h .sbtn:hover,.leaf-h .sbtn:focus-visible{color:var(--brand);border-color:var(--line-hi)}
354
+ /* ── v5.28 E — 활동 페인(Activity) ──
355
+ 에이전트 run 을 누르면 빈 worktree 셸이 아니라 **지금 하고 있는 일**이 열린다.
356
+ 읽는 자리라 조용하다: 색을 갖는 곳은 상태 칩과 "지금" 한 줄뿐이고, 둘 다 이미 있는 토큰이다.
357
+ 터미널은 사라지지 않는다 — 액션바 첫 버튼이 그 자리로 되돌린다(E4). */
358
+ .act-host{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column;background:var(--bg);font-family:var(--mono);font-size:11.5px}
359
+ .act-h{display:flex;align-items:center;gap:8px;flex:none;padding:9px 12px 8px;border-bottom:1px solid var(--line);flex-wrap:wrap}
360
+ .act-h .anm{color:var(--ink);font-size:12.5px;white-space:nowrap}
361
+ .act-h .apath{color:var(--faint);flex:1;min-width:60px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
362
+ .act-h .ameta{color:var(--faint);font-size:10.5px;white-space:nowrap}
363
+ /* 상태 칩 — Part A 의 네 상태를 **같은 토큰**으로(working/waiting/idle/exited). 새 색 없음.
364
+ 상태가 없으면 :empty 가 자리를 지운다 — 없는 상태를 지어내지 않는다는 규칙의 표면 쪽 절반. */
365
+ .act-chip{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 7px;border-radius:999px;
366
+ border:1px solid currentColor;white-space:nowrap}
367
+ .act-chip:empty{display:none}
368
+ .act-chip.as-working{color:var(--running)}
369
+ .act-chip.as-waiting{color:var(--blocked)}
370
+ .act-chip.as-idle{color:var(--faint)}
371
+ .act-chip.as-exited{color:var(--done)}
372
+ /* "지금" 한 줄 — 가장 알고 싶은 답이라 이 페인에서 제일 크다 */
373
+ .act-now{flex:none;padding:10px 12px;color:var(--brand);font-size:15px;border-bottom:1px solid var(--line);
374
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
375
+ .act-tlwrap{flex:1;min-height:0;position:relative;display:flex}
376
+ .act-tl{flex:1;min-height:0;overflow:auto;padding:8px 12px;line-height:1.55}
377
+ .act-l{display:flex;gap:9px;padding:1px 0}
378
+ .act-l .ak{color:var(--faint);flex:none;min-width:44px;text-align:right}
379
+ .act-l .at{color:var(--muted);white-space:pre-wrap;word-break:break-word;min-width:0}
380
+ .act-empty{color:var(--faint);padding:8px 12px}
381
+ /* 꼬리에 붙어 있을 때만 따라 내려간다 — 위로 올렸으면 멈추고, 돌아갈 길을 하나 띄운다 */
382
+ .act-jump{position:absolute;right:16px;bottom:10px;font-family:var(--mono);font-size:10px;
383
+ color:var(--brand-ink);background:var(--brand);border:none;border-radius:999px;padding:3px 10px;cursor:pointer}
384
+ .act-jump[hidden]{display:none}
385
+ .act-chg{flex:none;border-top:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
386
+ .act-chg-h{display:flex;align-items:center;gap:8px;padding:6px 12px;color:var(--muted)}
387
+ .act-chg-h .afiles{color:var(--faint)}
388
+ .act-chg .rv-diff{max-height:210px;border-top:1px solid var(--line)}
389
+ .act-bar{flex:none;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 12px;
390
+ border-top:1px solid var(--line);background:var(--surface)}
391
+ /* .abtn = 활동 뷰 버튼. 툴바 .tc-btn 과 같은 치수(radius 6·pad 3/8)로 맞춘다. 셀렉터는 bare —
392
+ .act-chg-h 의 '변경' 버튼도 같은 규칙을 받아야 한다(전엔 .act-bar 스코프라 그 버튼이 흰 네이티브였다). */
393
+ .abtn{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;
394
+ border:1px solid var(--line);border-radius:6px;padding:3px 8px;cursor:pointer;white-space:nowrap}
395
+ .abtn:hover:not([disabled]),.abtn:focus-visible{color:var(--ink);border-color:var(--line-hi)}
396
+ .abtn.prim{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
397
+ .act-bar .qr{display:inline-flex;align-items:center;gap:3px}
398
+ .act-bar .qr:empty{display:none}
399
+ /* Activity | Terminal — 요청바의 .modes/.mode 세그를 페인 헤더 크기로만 줄여 쓴다(새 컴포넌트 없음) */
400
+ .leaf-h .modes{padding:1px;border-radius:6px;flex:none}
401
+ .leaf-h .mode{font-size:9.5px;padding:1px 6px}
346
402
  .leaf-h .bsel{display:none;color:var(--faint);border:none;background:none;cursor:pointer;font-size:11px;padding:0 2px}
347
403
  body.bcastmode .leaf-h .bsel{display:inline-block} /* 브로드캐스트 모드에서만 대상 선택 토글 노출 */
348
404
  .leaf-h .bsel.on{color:var(--open)}
349
405
  .leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
350
406
  .leaf-h .lock:hover{color:var(--brand)}
351
407
  .leaf-h .sendkey{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:1px 6px;width:150px;outline:none}
352
- .lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
353
- /* 섹션 라벨에 액션이 이상이면 묶음으로 양쪽 끝으로 흩어지지 않게(.tact+.tact 같은 이유) */
354
- .lbl .lacts{display:flex;align-items:center;gap:10px}
408
+ /* 섹션 라벨에 액션이 둘 이상이면 한 묶음으로 — 양쪽 끝으로 흩어지지 않게(.tact+.tact 와 같은 이유).
409
+ min-width:0 flex 아이템이 필요하면 줄어들 있게(넘칠 라벨은 nowrap 이라 통째로 유지) */
410
+ .lbl .lacts{display:flex;align-items:center;gap:10px;min-width:0}
411
+ /* v5.28 F1/G — 섹션 헤더의 ⋯. repo 행 .tmore 와 같은 모양·같은 메뉴(#rowMenu)를 쓴다.
412
+ 헤더는 이름과 ⋯ 하나뿐이고(Workspace·Scratch·Projects 모두), 액션은 그 뒤에 산다 */
413
+ .lbl .tmore{font-family:var(--mono);font-size:10.5px;line-height:1;color:var(--faint);
414
+ background:none;border:none;border-radius:5px;padding:3px 5px;cursor:pointer}
415
+ .lbl .tmore:hover,.lbl .tmore[aria-expanded="true"]{color:var(--brand);background:var(--surface2)}
355
416
  /* v6.0 S1 — Scratch 안내문은 제거함(의뢰자 요청 2026-09-17). .tree-note 미사용 */
356
417
  .tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
357
418
  .tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
@@ -524,8 +585,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
524
585
  <div class="right">
525
586
  <span class="ver" id="ver" title="로드된 cockpit 버전 (캐시 확인용)">v__COXPIT_VER__</span>
526
587
  <button type="button" class="waitchip" id="waitChip" hidden title="입력을 기다리는 에이전트 — 클릭하면 차례로 그 터미널로">◔ <span id="waitN">0</span></button>
588
+ <span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
527
589
  <div class="apwrap" id="apWrap">
528
- <button type="button" class="toggle" id="attnBtn" aria-haspopup="true" aria-expanded="false" title="알림소리 · 브라우저 알림 · 울릴 전이">◎</button>
590
+ <button type="button" class="toggle" id="topMore" data-menu="top" aria-haspopup="menu" aria-expanded="false" title=" 보기 시크릿(env 주입) · 알림">⋯</button>
529
591
  <div class="apop" id="attnPop" hidden role="group" aria-label="알림">
530
592
  <div class="lbl"><span>알림</span></div>
531
593
  <label class="rchk" title="대기·완료 전이에 짧은 신호음"><input type="checkbox" id="attnSound" /> 소리</label>
@@ -538,8 +600,6 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
538
600
  <div class="apop-note">전부 기본 꺼짐. 보고 있는 탭은 자신을 울리지 않습니다.</div>
539
601
  </div>
540
602
  </div>
541
- <span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
542
- <button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">∗<span class="b-txt"> Secrets</span></button>
543
603
  <a class="toggle" href="/" title="보드(모니터) 뷰로">←<span class="b-txt"> Board</span></a>
544
604
  </div>
545
605
  </header>
@@ -547,7 +607,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
547
607
  <div class="scrim" id="scrim"></div>
548
608
  <div class="layout" id="layout">
549
609
  <aside class="rail" id="rail">
550
- <div class="lbl"><span>Workspace</span><span class="lacts"><span class="lnk" id="wtBtn" title="worktree 회수 — 끝난 run 이 남긴 .coxpit-worktrees 폴더의 총량을 보고 되찾습니다(미머지 산출물은 표시만 하고 미리 고르지 않습니다)">▤ worktree</span><span class="lnk" id="reapBtn" title="고아 터미널 run 기록이 없는 coxpit-r* tmux 세션을 찾아 정리합니다(유지보수: 보드의 Reclaim 과 같은 가족)">↻ 고아 터미널</span><span id="machName" style="color:var(--faint)">local</span></span></div>
610
+ <div class="lbl"><span>Workspace</span><span class="lacts"><button type="button" class="tmore" id="wsMore" data-menu="ws" aria-haspopup="menu" aria-expanded="false" title="유지보수worktree 회수 · 고아 tmux 세션 정리">⋯</button></span></div>
551
611
  <div id="tree"></div>
552
612
  <div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
553
613
  </aside>
@@ -556,13 +616,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
556
616
  <div class="tabbar">
557
617
  <div class="tabs" id="tabs"></div>
558
618
  <div class="tabctl">
559
- <button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐ split</button>
560
- <button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
619
+ <button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐<span class="b-txt"> Split</span></button>
620
+ <button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬<span class="b-txt"> Split</span></button>
561
621
  <button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
562
622
  <button class="tc-btn" id="fileBtn" title="파일 보기 — md·html·pdf·이미지·텍스트 뷰어(터미널 옆 페인)">▤<span class="b-txt"> File</span></button>
563
- <button class="tc-btn" id="attachBtn" title="파일 첨부 — 포커스한 터미널 폴더로 업로드 + 경로 삽입 (드롭도 가능)">↥<span class="b-txt"> 첨부</span></button>
623
+ <button class="tc-btn" id="attachBtn" title="파일 첨부 — 포커스한 터미널 폴더로 업로드 + 경로 삽입 (드롭도 가능)">↥<span class="b-txt"> Attach</span></button>
564
624
  <button class="tc-btn" id="focusBtn" title="포커스 모드 — 트리·요청바 숨기고 페인만 (⌘.)">◱<span class="b-txt"> Focus</span></button>
565
- <button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> pane</span></button>
625
+ <button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> Pane</span></button>
566
626
  </div>
567
627
  </div>
568
628
  <div class="panes" id="panes"></div>
@@ -655,6 +715,21 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
655
715
  </div>
656
716
  </div>
657
717
 
718
+ <div class="modal" id="projModal">
719
+ <div class="pick" style="width:min(560px,92vw)">
720
+ <div class="pick-h"><span class="t">+ Add project</span><button class="x" id="projClose" title="닫기">×</button></div>
721
+ <div class="pick-path" id="projPath">…</div>
722
+ <div class="sheet-note">폴더를 눌러 들어갑니다 — <b>경로를 칠 일은 없습니다</b>. git 폴더는 <b>Register</b>(목록에 올리기만 — 디스크의 파일은 손대지 않습니다), <b>빈 폴더</b>는 <b>Start here</b>(빈 초기 커밋 하나를 심고 등록합니다). <b>파일이 있는 폴더에는 아무것도 심지 않습니다.</b></div>
723
+ <div class="pick-list" id="projList"></div>
724
+ <div class="sheet-err" id="projErr" hidden></div>
725
+ <div class="pick-f">
726
+ <button class="home" id="projHome" title="홈으로">⌂ home</button>
727
+ <span id="projHint" style="flex:1;font-size:11px;color:var(--faint)">…</span>
728
+ <button class="go" id="projRegHere" hidden>Register this folder</button>
729
+ </div>
730
+ </div>
731
+ </div>
732
+
658
733
  <div class="modal" id="workModal">
659
734
  <div class="pick" style="width:min(460px,92vw)">
660
735
  <div class="pick-h"><span class="t">새 작업</span><button class="x" id="workClose" title="닫기">×</button></div>
@@ -756,7 +831,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
756
831
 
757
832
  <div class="modal" id="reapModal">
758
833
  <div class="pick" style="width:min(560px,92vw)">
759
- <div class="pick-h"><span class="t">↻ 고아 터미널</span><button class="x" id="reapClose" title="닫기">×</button></div>
834
+ <div class="pick-h"><span class="t">↻ Orphans</span><button class="x" id="reapClose" title="닫기">×</button></div>
760
835
  <div class="sheet-note">run 기록이 사라졌는데 남아 있는 <b>coxpit-r* tmux 세션</b>들입니다 — <b>살아 있는 run 의 세션은 여기 오르지 않습니다</b>. 빈 셸만 미리 체크했고, <b>무언가 돌고 있는 세션은 표시만 하고 절대 미리 고르지 않습니다</b>. 종료되는 것은 터미널뿐, 폴더·파일은 그대로입니다.</div>
761
836
  <div class="pick-list" id="reapList"></div>
762
837
  <div class="pick-f">
@@ -768,7 +843,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
768
843
 
769
844
  <div class="modal" id="wtModal">
770
845
  <div class="pick" style="width:min(600px,92vw)">
771
- <div class="pick-h"><span class="t">▤ worktree 회수</span><button class="x" id="wtClose" title="닫기">×</button></div>
846
+ <div class="pick-h"><span class="t">▤ Reclaim worktrees</span><button class="x" id="wtClose" title="닫기">×</button></div>
772
847
  <div class="pick-path" id="wtTotal">…</div>
773
848
  <div class="sheet-note">끝난 run 이 남긴 <b>격리 worktree</b>입니다 — 하나에 node_modules 가 통째로 들어 있어 수백 MB씩 쌓입니다. <b>돌고 있는 run 은 여기 오르지 않습니다.</b> 머지됐거나 export·PR 로 빠져나간 것은 미리 체크했고, <b>아직 아무 데도 없는 변경(미머지)은 표시만 하고 절대 미리 고르지 않습니다</b> — 그 worktree 가 <b>유일한 사본</b>이라 지우면 그 변경은 사라집니다. repo 체크아웃과 그 파일은 그대로입니다.</div>
774
849
  <div class="pick-list" id="wtList"></div>
@@ -942,6 +1017,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
942
1017
  var row=$('tree').querySelector('.tnode[data-run="'+runId+'"] .st');
943
1018
  if (row){ var r=runById[runId]; row.className='st '+((r&&r.status)||'')+(cls?' '+cls:''); }
944
1019
  paintPaneStrip(runId); // 페인 헤더 스트립도 같은 표적 칠하기 — waiting 이면 빠른 답, 없으면 시작/이어서(C1·C2)
1020
+ paintActivity(runId); // 열려 있는 활동 페인의 상태 칩·"지금" 줄·타임라인도 그 자리만(E3)
945
1021
  updateWaitChip();
946
1022
  raiseAttention(runId, prev, state); // 점·칩은 항상 켜져 있고, 소리/알림만 취향을 탄다
947
1023
  }
@@ -1025,16 +1101,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1025
1101
  $('attnSound').checked = attn.sound;
1026
1102
  $('attnNotify').checked = attn.notify;
1027
1103
  Object.keys(AT_ON).forEach(function(k){ $(AT_ON[k]).classList.toggle('on', attn.on===k); });
1028
- $('attnBtn').classList.toggle('on', attn.sound||attn.notify);
1104
+ // v5.28 G4 — 팝오버의 주인은 이제 상단바 ⋯ 다. 무언가 켜져 있으면 그 ⋯ 가 액센트를 든다(켜짐은 헤더에서 보여야 한다).
1105
+ $('topMore').classList.toggle('on', attn.sound||attn.notify);
1029
1106
  }
1107
+ // 판 자체는 그대로다(체크박스·세그·닫기 배선 전부) — 여는 손잡이만 상단바 ⋯ 메뉴로 옮겼다.
1108
+ // #apWrap 안에 ⋯ 가 그대로 있으므로 앵커(position:absolute)도, 바깥클릭 가드도 예전 그대로 성립한다.
1030
1109
  function setAttnPop(open){
1031
1110
  $('attnPop').hidden = !open;
1032
- $('attnBtn').setAttribute('aria-expanded', open?'true':'false');
1111
+ $('topMore').setAttribute('aria-expanded', open?'true':'false');
1033
1112
  if (open) $('attnSound').focus(); // 키보드로 열어도 바로 첫 행에 선다
1034
1113
  }
1035
- $('attnBtn').addEventListener('click', function(e){ e.stopPropagation(); setAttnPop($('attnPop').hidden); });
1036
1114
  document.addEventListener('click', function(e){ if(!$('attnPop').hidden && !$('apWrap').contains(e.target)) setAttnPop(false); });
1037
- document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('attnPop').hidden){ e.preventDefault(); setAttnPop(false); $('attnBtn').focus(); } });
1115
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('attnPop').hidden){ e.preventDefault(); setAttnPop(false); $('topMore').focus(); } });
1038
1116
  $('apWrap').addEventListener('focusout', function(){
1039
1117
  setTimeout(function(){ if(!$('apWrap').contains(document.activeElement)) setAttnPop(false); }, 0);
1040
1118
  });
@@ -1064,7 +1142,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1064
1142
  (d.runs||[]).forEach(function(r){ runById[r.id]=r; });
1065
1143
  (d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
1066
1144
  (d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
1067
- if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
1145
+ // 기계 이름은 (#mach) 곳에만 산다 v5.28 F1 에서 Workspace 헤더의 사본은 걷어냈다
1146
+ if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; }
1068
1147
  // 에이전트 상태 씨앗 — 갓 뜬(또는 재연결한) 코크핏이 다음 델타를 기다리지 않게. 맵은 서버가 준 것으로 통째 교체한다
1069
1148
  // (터미널이 떨어진 run 은 서버 맵에서 빠지므로 여기서 자연히 사라진다).
1070
1149
  agentState={}; var asm=d.agentStates||{};
@@ -1128,9 +1207,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1128
1207
  // ── SCRATCH (자유 세션 = 아직 프로젝트가 아닌 것들) ──
1129
1208
  // v6.0 S1: 데이터는 그대로(kind='sessions') — 바뀐 건 이름과 어포던스다. 여기서 바로
1130
1209
  // 정리(S1b)하고, 여기서 바로 프로젝트로 졸업(S2)시킨다. 어지러워지는 자리에서 치울 수 있어야 한다.
1210
+ // v5.28 G2: 헤더는 이름과 ⋯ 하나다 — 두 액션(+ Session · Tidy…)은 그 뒤에 살고, 하는 일은 그대로다.
1131
1211
  html += '<div class="lbl"><span>Scratch</span><span class="lacts">'
1132
- + '<span class="lnk" data-scrub="1" title="정리 — 터미널이 없는 세션을 한 번에 지웁니다(폴더는 보존)">정리…</span>'
1133
- + '<span class="lnk" data-newsession="1">+세션</span></span></div>';
1212
+ + '<button type="button" class="tmore" data-menu="scratch" aria-haspopup="menu" aria-expanded="false"'
1213
+ + ' title="Scratch 세션(폴더 지정) · 정리(터미널 없는 세션 삭제, 폴더는 보존)">⋯</button></span></div>';
1134
1214
  if (sessRuns.length){
1135
1215
  sessRuns.forEach(function(s){
1136
1216
  var r=s.run; var open = tabs[r.id] ? ' open' : '';
@@ -1143,11 +1223,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1143
1223
  + '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
1144
1224
  });
1145
1225
  } else {
1146
- html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
1226
+ html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — ⋯ 에서 + Session</div>';
1147
1227
  }
1148
1228
  html += '<div class="tree-sep"></div>';
1149
- html += '<div class="lbl"><span>Projects</span></div>';
1150
- if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; }
1229
+ // v5.28 G3: 같은 모양 — 이름과 ⋯ 하나. 프로젝트는 이제 보드에 가지 않고 여기서 태어난다.
1230
+ html += '<div class="lbl"><span>Projects</span><span class="lacts">'
1231
+ + '<button type="button" class="tmore" data-menu="projects" aria-haspopup="menu" aria-expanded="false"'
1232
+ + ' title="프로젝트 — 폴더를 골라 등록하거나 빈 폴더에서 새로 시작">⋯</button></span></div>';
1233
+ if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — ⋯ 에서 + Add project</div>'; }
1151
1234
  // 프로젝트(repo) 하나 = 섹션 하나. 그 안에 작업(task) 들이 있고, 작업 아래에 세션(run) 이 달린다.
1152
1235
  realRepos.forEach(function(repo){
1153
1236
  var rk = 'repo'+repo.id;
@@ -1178,10 +1261,17 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1178
1261
  el.innerHTML = html;
1179
1262
  // 열려 있던 ⋯ 메뉴는 트리가 다시 그려져도 살아 있어야 한다(델타마다 닫히면 못 누른다) —
1180
1263
  // 같은 repo 의 새 버튼으로 주인을 옮기고, 그 행이 사라졌으면 그때 닫는다.
1181
- if (rowMenuOwner){
1264
+ // (Workspace 헤더의 ⋯(#wsMore)는 트리 밖 정적 마크업이라 다시 그려지지 않는다 — 건드리지 않는다.)
1265
+ if (rowMenuOwner && rowMenuOwner.hasAttribute('data-more')){
1182
1266
  var again = el.querySelector('[data-more="'+rowMenuOwner.getAttribute('data-more')+'"]');
1183
1267
  if (again){ again.setAttribute('aria-expanded','true'); rowMenuOwner=again; } else closeRowMenu();
1184
1268
  }
1269
+ // v5.28 G — 섹션 헤더의 ⋯(Scratch·Projects)도 트리 안에 산다. 같은 규칙으로 주인을 옮긴다.
1270
+ // (트리 밖 정적 ⋯ — #wsMore·#topMore — 는 document 에 그대로 있으므로 여기 걸리지 않는다.)
1271
+ else if (rowMenuOwner && rowMenuOwner.hasAttribute('data-menu') && !document.contains(rowMenuOwner)){
1272
+ var back = el.querySelector('[data-menu="'+rowMenuOwner.getAttribute('data-menu')+'"]');
1273
+ if (back){ back.setAttribute('aria-expanded','true'); rowMenuOwner=back; } else closeRowMenu();
1274
+ }
1185
1275
  }
1186
1276
  // 작업(task) 한 줄 + 그 아래 세션(run) 들. 이름은 작업이 갖고, run 은 역할로 읽힌다.
1187
1277
  function taskHTML(t, rns){
@@ -1204,8 +1294,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1204
1294
  return s;
1205
1295
  }
1206
1296
  $('tree').addEventListener('click', function(e){
1207
- if (e.target.closest('[data-newsession]')){ openSession(); return; }
1208
- if (e.target.closest('[data-scrub]')){ openScrub(); return; }
1297
+ // 섹션 헤더의 ⋯ (Scratch·Projects) 트리는 델타마다 다시 그려지므로 위임으로 받는다.
1298
+ var sm = e.target.closest('[data-menu]');
1299
+ if (sm){ e.stopPropagation(); openSecMenu(sm); return; }
1209
1300
  var pm = e.target.closest('[data-promote]');
1210
1301
  if (pm){ e.stopPropagation(); openPromote(+pm.dataset.promote); return; }
1211
1302
  // 노드 액션은 접기(data-fold)·열기(data-run) 보다 먼저 가로챈다 — 같은 행 안에 있으므로.
@@ -1233,22 +1324,51 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1233
1324
  { act:'tidy', label:'정리…' },
1234
1325
  { act:'unreg', label:'등록 해제' }
1235
1326
  ];
1236
- function openRowMenu(btn, repoId){
1327
+ // 여는 자리는 하나다(#rowMenu) — repo 행이든 어느 섹션 헤더든 같은 판을 같은 규칙으로 띄운다.
1328
+ function paintRowMenu(btn, html){
1237
1329
  var m=$('rowMenu');
1238
- if (rowMenuOwner===btn && !m.hidden){ closeRowMenu(); return; }
1239
- m.innerHTML = ROW_MENU_ACTS.map(function(a){
1240
- return '<button type="button" role="menuitem" data-act="'+a.act+'" data-repo="'+repoId+'">'+esc(a.label)+'</button>';
1241
- }).join('');
1330
+ m.innerHTML = html;
1242
1331
  m.hidden=false;
1243
1332
  // 버튼 아래 왼쪽 정렬, 화면 밖으로 나가면 안쪽으로 당긴다(fixed 좌표라 레일 스크롤과 무관).
1244
1333
  var r=btn.getBoundingClientRect();
1245
1334
  var w=m.offsetWidth, h=m.offsetHeight;
1246
1335
  m.style.left = Math.max(6, Math.min(r.left, window.innerWidth - w - 6)) + 'px';
1247
1336
  m.style.top = ((r.bottom + h + 6 > window.innerHeight) ? Math.max(6, r.top - h - 4) : r.bottom + 4) + 'px';
1337
+ // ⋯ 가 넷이 됐으므로(repo 행·Workspace·섹션·상단바) 주인을 넘길 때 옛 주인의 표시를 반드시 내린다 —
1338
+ // 안 그러면 닫힌 ⋯ 가 aria-expanded="true" 인 채 남아 브랜드색으로 떠 있는다.
1339
+ if (rowMenuOwner && rowMenuOwner!==btn) rowMenuOwner.setAttribute('aria-expanded','false');
1248
1340
  btn.setAttribute('aria-expanded','true');
1249
1341
  rowMenuOwner=btn;
1250
1342
  var first=m.querySelector('button'); if(first) first.focus();
1251
1343
  }
1344
+ // v5.28 G1 — ⋯ 는 여럿, 판은 하나. 어느 ⋯ 를 눌러도 같은 판이 같은 규칙으로 뜨고,
1345
+ // 다른 것은 **항목 목록뿐**이다. 두 번째 메뉴 시스템은 만들지 않는다.
1346
+ function openMenuAt(btn, acts, repoId){
1347
+ if (rowMenuOwner===btn && !$('rowMenu').hidden){ closeRowMenu(); return; } // 같은 ⋯ 를 또 누르면 토글
1348
+ paintRowMenu(btn, acts.map(function(a){
1349
+ return '<button type="button" role="menuitem" data-act="'+a.act+'"'
1350
+ + (repoId==null?'':' data-repo="'+repoId+'"')+'>'+esc(a.label)+'</button>';
1351
+ }).join(''));
1352
+ }
1353
+ function openRowMenu(btn, repoId){ openMenuAt(btn, ROW_MENU_ACTS, repoId); }
1354
+ // ── Workspace 헤더 넘침 메뉴 (v5.28 F1) ──
1355
+ // 헤더는 이름과 ⋯ 하나만 이고, 유지보수 둘은 그 뒤에 산다. 하는 일은 하나도 안 바뀐다 —
1356
+ // 같은 핸들러(openWt·openReap)를 그대로 부른다. 기계 이름은 위 칩(#mach)에 한 번만 선다.
1357
+ var WS_MENU_ACTS = [
1358
+ { act:'wt', label:'▤ Worktrees' },
1359
+ { act:'reap', label:'↻ Orphans' }
1360
+ ];
1361
+ function openWsMenu(btn){ openMenuAt(btn, WS_MENU_ACTS); }
1362
+ $('wsMore').addEventListener('click', function(e){ e.stopPropagation(); openWsMenu(this); });
1363
+ // ── 섹션·상단바 헤더 넘침 메뉴 (v5.28 G2·G3·G4) ──
1364
+ // 같은 모양을 끝까지 민다: 헤더는 이름(또는 상태) 하나와 ⋯ 하나. 항목은 전부 **이미 있는 핸들러**를 부른다.
1365
+ var SEC_MENU_ACTS = {
1366
+ scratch: [ { act:'newsession', label:'+ Session' }, { act:'scrub', label:'Tidy…' } ],
1367
+ projects: [ { act:'addproject', label:'+ Add project…' } ],
1368
+ top: [ { act:'secrets', label:'∗ Secrets' }, { act:'attn', label:'◎ Notifications' } ]
1369
+ };
1370
+ function openSecMenu(btn){ openMenuAt(btn, SEC_MENU_ACTS[btn.getAttribute('data-menu')]||[]); }
1371
+ $('topMore').addEventListener('click', function(e){ e.stopPropagation(); openSecMenu(this); });
1252
1372
  function closeRowMenu(){
1253
1373
  var m=$('rowMenu'); if(m.hidden) return;
1254
1374
  m.hidden=true; m.innerHTML='';
@@ -1258,19 +1378,31 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1258
1378
  var b=e.target.closest('button[data-act]'); if(!b) return;
1259
1379
  var id=+b.dataset.repo, act=b.dataset.act;
1260
1380
  closeRowMenu();
1261
- if (act==='newwork') openNewWork(id);
1381
+ if (act==='wt') openWt();
1382
+ else if (act==='reap') openReap();
1383
+ else if (act==='newwork') openNewWork(id);
1262
1384
  else if (act==='tidy') openTidy(id);
1263
1385
  else if (act==='unreg') openUnreg(id);
1386
+ else if (act==='newsession') openSession();
1387
+ else if (act==='scrub') openScrub();
1388
+ else if (act==='addproject') openAddProject();
1389
+ else if (act==='secrets') openSecrets();
1390
+ // 알림은 판을 다시 짓지 않는다 — 있던 팝오버를 그대로 연다. 한 틱 미루는 이유: 이 클릭이
1391
+ // document 까지 올라가 "바깥 클릭"으로 읽히면 방금 연 판이 도로 닫힌다.
1392
+ else if (act==='attn') setTimeout(function(){ setAttnPop(true); }, 0);
1264
1393
  });
1265
1394
  // 닫기 배선은 주의 팝오버(#attnPop)와 같은 모양이다 — 바깥 클릭 · Escape(포커스 되돌림) · 포커스 이탈.
1266
- document.addEventListener('click', function(e){ if(!$('rowMenu').hidden && !$('rowMenu').contains(e.target) && !e.target.closest('[data-more]')) closeRowMenu(); });
1395
+ // 여는 버튼은 repo 행의 ([data-more]) 헤더들의 ⋯([data-menu] — Workspace·Scratch·Projects·상단바).
1396
+ // 그중 어느 것을 누른 것도 "바깥"이 아니다(누르자마자 닫히면 토글이 성립하지 않는다).
1397
+ var MENU_BTN_SEL = '[data-more],[data-menu]';
1398
+ document.addEventListener('click', function(e){ if(!$('rowMenu').hidden && !$('rowMenu').contains(e.target) && !e.target.closest(MENU_BTN_SEL)) closeRowMenu(); });
1267
1399
  document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('rowMenu').hidden){ e.preventDefault(); var o=rowMenuOwner; closeRowMenu(); if(o) o.focus(); } });
1268
1400
  $('rowMenu').addEventListener('focusout', function(){
1269
1401
  // ⋯ 로 포커스가 돌아간 경우는 닫지 않는다 — 안 그러면 두 번째 클릭이 토글이 아니라 재개방이 된다
1270
1402
  // (mousedown 이 먼저 포커스를 옮기고, 그 focusout 이 닫아버린 뒤 click 이 다시 연다).
1271
1403
  setTimeout(function(){
1272
1404
  var a=document.activeElement;
1273
- if(!$('rowMenu').hidden && !$('rowMenu').contains(a) && !(a && a.closest && a.closest('[data-more]'))) closeRowMenu();
1405
+ if(!$('rowMenu').hidden && !$('rowMenu').contains(a) && !(a && a.closest && a.closest(MENU_BTN_SEL))) closeRowMenu();
1274
1406
  }, 0);
1275
1407
  });
1276
1408
  window.addEventListener('resize', closeRowMenu);
@@ -1317,14 +1449,29 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1317
1449
  // 탭 라벨 = 역할 하나(작업 이름은 트리가 이미 보여준다).
1318
1450
  function tabName(runId){ return runLabel(runId); }
1319
1451
 
1452
+ // 탭은 runId 하나로 키를 잡는다 — 한 run 이 두 탭이 될 수 없으니, 대신 **모드**를 갖는다(v5.28 E1).
1453
+ // 에이전트 run 은 활동 페인으로 열리고, 날 PTY 는 사람이 Terminal 로 넘길 때 비로소 만들어진다.
1320
1454
  function ensureTab(runId){
1321
1455
  if (tabs[runId]) return tabs[runId];
1456
+ var t={ runId:runId, name:tabName(runId), mode:defaultTabMode(runId), term:null, fit:null, ws:null, retry:0,
1457
+ closing:false, host:null, ro:null, opened:false, connected:false, search:null, actHost:null };
1458
+ tabs[runId]=t; tabOrder.push(runId);
1459
+ if (t.mode==='terminal') ensureTerm(t);
1460
+ return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
1461
+ }
1462
+ // 터미널 실체(호스트 DOM + xterm + 애드온). 활동 페인에서 넘어오면 여기서 처음 생기고, 그 뒤로는 유지된다.
1463
+ function ensureTerm(t){
1464
+ if (t.term) return t;
1465
+ var runId=t.runId;
1322
1466
  var host=document.createElement('div'); host.className='term-host';
1323
1467
  var term=new window.Terminal({
1324
1468
  fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
1325
1469
  fontSize: isMobile() ? 11 : 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
1326
1470
  macOptionClickForcesSelection: true, // ⌥+드래그 = 로컬 선택(마우스모드 앱 안에서도) → mouseup 복사가 됨
1327
1471
  rightClickSelectsWord: true,
1472
+ // 검은 바탕에 검은 글자(ANSI black=SGR30) 안 보이던 것 방지 — 배경과 대비가 이 비율 미만인
1473
+ // 전경색은 xterm 이 자동으로 끌어올린다. 3 = 안 보이던 것만 구제하고 의도된 어두운 톤은 대체로 보존(2026-09-18).
1474
+ minimumContrastRatio: 3,
1328
1475
  theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
1329
1476
  });
1330
1477
  var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
@@ -1395,9 +1542,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1395
1542
  return true;
1396
1543
  });
1397
1544
  // term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
1398
- var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false, search:search };
1545
+ t.term=term; t.fit=fit; t.host=host; t.search=search; t.opened=false; t.connected=false;
1399
1546
  term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
1400
- tabs[runId]=t; tabOrder.push(runId);
1401
1547
  return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
1402
1548
  }
1403
1549
 
@@ -1575,7 +1721,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1575
1721
  }
1576
1722
 
1577
1723
  function fitTab(t){ if(!t||!t.fit||!t.term) return; try{ t.fit.fit(); if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'r',cols:t.term.cols,rows:t.term.rows})); }catch(e){} }
1578
- function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].kind!=='viewer' && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
1724
+ function fitAllVisible(){ eachLeaf(layout,function(l){ var t=l.tab!=null?tabs[l.tab]:null; if(t && t.kind!=='viewer' && t.term && t.host && t.host.isConnected) fitTab(t); }); }
1579
1725
 
1580
1726
  // ── 탭 바 렌더(터미널 없음 — 언제든 안전) ──
1581
1727
  function renderTabs(){
@@ -1612,6 +1758,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1612
1758
  ? '<span class="st vdoc">▤</span><span class="nm">'+esc(t.name)+'</span>'
1613
1759
  + zoomBtn + '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
1614
1760
  : '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
1761
+ // v5.28 E1 — 이 페인이 무엇을 보여줄지. 에이전트 run 에만 뜬다(손 세션·워크벤치는 터미널 그대로).
1762
+ + (runIsAgent(node.tab)
1763
+ ? '<span class="modes acttog" data-role="mtog">'
1764
+ + '<button type="button" class="mode'+(t.mode==='activity'?' on':'')+'" data-pmode="activity" data-prun="'+node.tab+'" title="이 에이전트가 지금 하고 있는 일 (이벤트 스트림)">Activity</button>'
1765
+ + '<button type="button" class="mode'+(t.mode!=='activity'?' on':'')+'" data-pmode="terminal" data-prun="'+node.tab+'" title="이 run 의 worktree 셸 (날 터미널)">Terminal</button></span>'
1766
+ : '')
1615
1767
  + '<span data-role="act" class="pane-act">'+esc(latestActivity(node.tab))+'</span>'
1616
1768
  // v5.28 C1·C2 — 이 페인의 에이전트 상태가 정하는 스트립. 상태가 바뀌면 이 자리만 다시 칠한다.
1617
1769
  + '<span data-role="qr" class="qr" data-qrun="'+node.tab+'">'+paneStripHTML(node.tab)+'</span>'
@@ -1637,8 +1789,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1637
1789
  el.appendChild(a); el.appendChild(g); el.appendChild(b); return el;
1638
1790
  }
1639
1791
  // host 를 DOM 에 붙이고, 최초 1회 open → connect(Phase 2 순서). fit 은 render 의 rAF 에서.
1640
- function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return; body.appendChild(t.host);
1641
- if(t.kind==='viewer') return; // 뷰어는 DOM 만 붙이면 끝(터미널 open/connect 없음)
1792
+ function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return;
1793
+ if(t.kind==='viewer'){ body.appendChild(t.host); return; } // 뷰어는 DOM 만 붙이면 끝(터미널 open/connect 없음)
1794
+ // 활동 페인(v5.28 E) — PTY 를 만들지 않는다. 터미널은 사람이 Terminal 을 누를 때 생긴다.
1795
+ if(t.mode==='activity'){ body.appendChild(actHostOf(t)); renderActivity(t); return; }
1796
+ ensureTerm(t); body.appendChild(t.host);
1642
1797
  if(!t.opened){ try{ t.term.open(t.host); t.opened=true; }catch(e){} }
1643
1798
  if(!t.connected){ t.connected=true; connectTab(t); }
1644
1799
  }); }
@@ -1809,6 +1964,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1809
1964
  var chip=head.querySelector('[data-role=chip]'); if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; }
1810
1965
  var vslot=head.querySelector('[data-role=vslot]'); if(vslot) vslot.innerHTML=vbadge(r.verifyStatus);
1811
1966
  var act=head.querySelector('[data-role=act]'); if(act) act.textContent=latestActivity(l.tab);
1967
+ // 이벤트 델타가 실린 하이드레이트 — 열려 있는 활동 페인은 **새 줄만 이어붙인다**(통째로 다시 그리지 않는다, E3)
1968
+ paintActivity(l.tab);
1812
1969
  });
1813
1970
  }
1814
1971
 
@@ -1885,6 +2042,22 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1885
2042
  if (injBtn){ e.stopPropagation(); var il=findLeaf(injBtn.getAttribute('data-inject')); if(il&&il.tab!=null){ setLeafFocus(il.id); openInjMenu(injBtn, il.tab); } return; }
1886
2043
  var lockBtn=e.target.closest('[data-lock]');
1887
2044
  if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
2045
+ // v5.28 E — 활동 페인. 모드 전환(Activity|Terminal) + 페인 안의 읽기/행동 버튼들.
2046
+ var pmBtn=e.target.closest('[data-pmode]');
2047
+ if (pmBtn){ e.stopPropagation(); setTabMode(tabKeyOf(pmBtn.getAttribute('data-prun')), pmBtn.getAttribute('data-pmode')); return; }
2048
+ var atBtn=e.target.closest('[data-actterm]');
2049
+ if (atBtn){ e.stopPropagation(); setTabMode(tabKeyOf(atBtn.getAttribute('data-actterm')), 'terminal'); return; }
2050
+ var asteerBtn=e.target.closest('[data-actsteer]');
2051
+ if (asteerBtn){ e.stopPropagation(); actSteer(tabKeyOf(asteerBtn.getAttribute('data-actsteer'))); return; }
2052
+ var arevBtn=e.target.closest('[data-actreview]');
2053
+ if (arevBtn){ e.stopPropagation(); gotoBoardRun(arevBtn.getAttribute('data-actreview')); return; }
2054
+ var adiffBtn=e.target.closest('[data-actdiff]');
2055
+ if (adiffBtn){ e.stopPropagation(); actToggleDiff(tabKeyOf(adiffBtn.getAttribute('data-actdiff'))); return; }
2056
+ var awsBtn=e.target.closest('[data-actws]');
2057
+ if (awsBtn){ e.stopPropagation(); actToggleWs(tabKeyOf(awsBtn.getAttribute('data-actws'))); return; }
2058
+ var ajBtn=e.target.closest('[data-actjump]');
2059
+ if (ajBtn){ e.stopPropagation(); var jt=tabs[tabKeyOf(ajBtn.getAttribute('data-actjump'))];
2060
+ if(jt && jt.actHost){ var jel=jt.actHost.querySelector('[data-role=acttl]'); if(jel){ jt._tlPinned=true; jel.scrollTop=jel.scrollHeight; } ajBtn.hidden=true; } return; }
1888
2061
  var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
1889
2062
  if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
1890
2063
  setLeafFocus(leaf.dataset.leaf);
@@ -2062,7 +2235,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
2062
2235
  // ── 모바일 터미널 입력바 — 조합 완료 텍스트를 통째로 포커스 탭의 PTY 로(IME 안전) ──
2063
2236
  function focusedWs(){ var rid=focusedRunId(); return (rid!=null && tabs[rid]) ? tabs[rid].ws : null; }
2064
2237
  function termSendRaw(d){ var ws=focusedWs(); if(ws && ws.readyState===1) ws.send(JSON.stringify({t:'i',d:d})); }
2065
- function termSendLine(){ var inp=$('termInput'); var v=inp.value; if(!v){ termSendRaw('\\r'); return; } termSendRaw(v+'\\r'); inp.value=''; inp.focus(); }
2238
+ function termSendLine(){ var inp=$('termInput'); var v=inp.value;
2239
+ // 활동 페인에는 아직 PTY 가 없다 — 친 글을 조용히 삼키지 않고 길을 알려 준다(v5.28 E4).
2240
+ var frid=focusedRunId(); var ft=(frid!=null)?tabs[frid]:null;
2241
+ if(ft && ft.kind!=='viewer' && ft.mode==='activity'){ toast('이 페인은 Activity 입니다 — Open terminal 을 누르면 입력할 수 있어요'); return; }
2242
+ if(!v){ termSendRaw('\\r'); return; } termSendRaw(v+'\\r'); inp.value=''; inp.focus(); }
2066
2243
  $('termSend').addEventListener('click', termSendLine);
2067
2244
  $('termInput').addEventListener('keydown', function(e){ if(e.isComposing) return; if(e.key==='Enter'){ e.preventDefault(); termSendLine(); } });
2068
2245
  // 멀티라인 붙여넣기(D-io) — 한 줄 <input> 은 개행을 삼킨다. 개행이 있으면 기본동작을 막고
@@ -2470,7 +2647,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
2470
2647
  function reapCount(){ var n=0; Object.keys(reapSel).forEach(function(k){ if(reapSel[k]) n++; }); return n; }
2471
2648
  function renderReap(){
2472
2649
  $('reapList').innerHTML = reapRows.length ? reapRows.map(reapRowHTML).join('')
2473
- : '<div class="pick-row" style="cursor:default;color:var(--faint)">고아 터미널이 없습니다 남은 tmux 세션은 모두 아는 run 것입니다</div>';
2650
+ : '<div class="pick-row" style="cursor:default;color:var(--faint)">No orphansevery remaining tmux session belongs to a known run</div>';
2474
2651
  var n=reapCount(), busy=0;
2475
2652
  reapRows.forEach(function(x){ if(!x.idle) busy++; });
2476
2653
  $('reapHint').textContent = reapRows.length
@@ -2502,12 +2679,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
2502
2679
  body:JSON.stringify({sessions:names})});
2503
2680
  var j=await res.json().catch(function(){return{};});
2504
2681
  if (!res.ok){ toast('종료 실패: '+(j.detail||j.error||('HTTP '+res.status))); }
2505
- else { toast('고아 터미널 '+(j.count||0)+'개 종료됨'+((j.skipped&&j.skipped.length)?(' · 건너뜀 '+j.skipped.length+'개'):'')); }
2682
+ else { toast('Orphans '+(j.count||0)+'개 종료됨'+((j.skipped&&j.skipped.length)?(' · 건너뜀 '+j.skipped.length+'개'):'')); }
2506
2683
  }catch(e){ toast('종료 실패: '+e); }
2507
2684
  finally{ reaping=false; }
2508
2685
  await loadReap();
2509
2686
  }
2510
- $('reapBtn').addEventListener('click', openReap);
2687
+ // 여는 입구는 Workspace 헤더의 ⋯ 메뉴 하나다(v5.28 F1) — 인라인 링크는 사라졌다.
2511
2688
  $('reapClose').addEventListener('click', closeReap);
2512
2689
  $('reapModal').addEventListener('click', function(e){ if(e.target===this) closeReap(); });
2513
2690
  $('reapGo').addEventListener('click', runReap);
@@ -2577,7 +2754,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
2577
2754
  await hydrate();
2578
2755
  await loadWt();
2579
2756
  }
2580
- $('wtBtn').addEventListener('click', openWt);
2757
+ // 여는 입구는 Workspace 헤더의 ⋯ 메뉴 하나다(v5.28 F1) — 인라인 링크는 사라졌다.
2581
2758
  $('wtClose').addEventListener('click', closeWt);
2582
2759
  $('wtModal').addEventListener('click', function(e){ if(e.target===this) closeWt(); });
2583
2760
  $('wtGo').addEventListener('click', runWt);
@@ -2745,6 +2922,68 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
2745
2922
  $('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
2746
2923
  $('sessionBtn').addEventListener('click', openSession);
2747
2924
 
2925
+ // ── v5.28 G5 — 코크핏에서 프로젝트 만들기(Projects ⋯ > + Add project…) ──
2926
+ // 보드의 repo 브라우저와 **같은 창구**(GET /api/browse · POST /api/repos · POST /api/repos/new)를
2927
+ // 코크핏의 시트 껍데기로 가져온 것뿐이다. 새 전송도, 새 동작도 없다 — 옮긴 것은 "닿는 자리"다.
2928
+ // 경로는 절대 타이핑하지 않는다: 폴더를 눌러 들어가고, git 폴더에는 Register, 빈 폴더에는 Start here.
2929
+ var projCur='';
2930
+ function projErr(msg){ var el=$('projErr'); if(!msg){ el.hidden=true; el.textContent=''; return; } el.textContent=msg; el.hidden=false; }
2931
+ function openAddProject(){ $('projModal').classList.add('on'); projErr(''); projBrowse(projCur||''); }
2932
+ function closeAddProject(){ $('projModal').classList.remove('on'); }
2933
+ async function projBrowse(p){
2934
+ $('projList').innerHTML = '<div class="pick-row" style="cursor:default;color:var(--faint)">불러오는 중…</div>';
2935
+ try{
2936
+ var res = await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):''));
2937
+ if(!res.ok){ $('projList').innerHTML = '<div class="pick-row" style="color:var(--failed)">'+(res.status===401?'인증이 만료됐어요 — 새로고침 후 다시 로그인':'폴더를 읽을 수 없습니다 (HTTP '+res.status+')')+'</div>'; return; }
2938
+ var d = await res.json();
2939
+ projCur = d.path; $('projPath').textContent = d.path;
2940
+ // 지금 서 있는 폴더 자체가 repo 면 그 자리에서 등록할 수 있다(보드의 Register here 와 같은 규칙).
2941
+ $('projRegHere').hidden = !d.isRepo;
2942
+ $('projHint').textContent = d.isRepo ? '이 폴더는 git repo 입니다 — 바로 등록할 수 있습니다'
2943
+ : '폴더를 눌러 들어갑니다 · git 은 Register · 빈 폴더는 Start here';
2944
+ var html = '';
2945
+ if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-projgo="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
2946
+ (d.dirs||[]).forEach(function(x){
2947
+ var full = d.path==='/' ? '/'+x.name : d.path+'/'+x.name;
2948
+ html += '<div class="pick-row" data-projgo="'+esc(full)+'"><span class="ic">'+(x.isRepo?'◆':'▸')+'</span>'
2949
+ + '<span>'+esc(x.name)+'</span>'
2950
+ + (x.isRepo ? '<span class="rp">git</span><button type="button" class="pbtn" data-projreg="'+esc(full)+'" title="등록 — 목록에 올리기만 합니다(디스크의 파일은 그대로)">Register</button>'
2951
+ : (x.isEmpty ? '<button type="button" class="pbtn" data-projnew="'+esc(full)+'" title="여기서 시작 — 빈 초기 커밋 하나를 심고 등록합니다">Start here</button>' : ''))
2952
+ + '</div>';
2953
+ });
2954
+ if (!(d.dirs||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">하위 폴더 없음</div>';
2955
+ $('projList').innerHTML = html;
2956
+ }catch(e){ $('projList').innerHTML = '<div class="pick-row" style="color:var(--failed)">폴더를 읽을 수 없습니다</div>'; }
2957
+ }
2958
+ var addingProject=false;
2959
+ // 성공하면 트리가 곧 그 프로젝트를 든다 — hydrate 가 renderTree 를 부르므로 따로 그릴 것이 없다.
2960
+ async function projPost(url, full, okMsg){
2961
+ if (addingProject) return;
2962
+ addingProject=true; projErr('');
2963
+ try{
2964
+ var res = await fetch(url,{method:'POST',headers:{'content-type':'application/json'},
2965
+ body:JSON.stringify({machineSlug:machineSlug(), path:full})});
2966
+ var j = await res.json().catch(function(){return{};});
2967
+ if (res.ok){ closeAddProject(); await hydrate(); toast(okMsg+' · '+full); return; }
2968
+ // 커밋이 없는 repo 는 등록이 거절된다 — 대신 어디로 가야 하는지 그 자리에서 말한다(G6: 창구는 그대로).
2969
+ if (j.code==='NO_COMMITS') projErr('커밋이 아직 없는 repo 입니다 — Start here 로 시작하면 빈 초기 커밋을 심고 등록합니다');
2970
+ else projErr((j.detail||j.error||('HTTP '+res.status)));
2971
+ }catch(e){ projErr('실패: '+e); }
2972
+ finally{ addingProject=false; }
2973
+ }
2974
+ function projRegister(full){ projPost('/api/repos', full, '프로젝트 등록'); }
2975
+ function projStartHere(full){ projPost('/api/repos/new', full, '새 프로젝트 시작'); }
2976
+ $('projList').addEventListener('click', function(e){
2977
+ var rg=e.target.closest('[data-projreg]'); if(rg){ e.stopPropagation(); projRegister(rg.dataset.projreg); return; }
2978
+ var nw=e.target.closest('[data-projnew]'); if(nw){ e.stopPropagation(); projStartHere(nw.dataset.projnew); return; }
2979
+ var go=e.target.closest('[data-projgo]'); if(go){ projErr(''); projBrowse(go.dataset.projgo); }
2980
+ });
2981
+ $('projRegHere').addEventListener('click', function(){ if(projCur) projRegister(projCur); });
2982
+ $('projHome').addEventListener('click', function(){ projErr(''); projBrowse(''); });
2983
+ $('projClose').addEventListener('click', closeAddProject);
2984
+ $('projModal').addEventListener('click', function(e){ if(e.target===this) closeAddProject(); });
2985
+ document.addEventListener('keydown', function(e){ if(e.key==='Escape' && $('projModal').classList.contains('on')){ e.preventDefault(); closeAddProject(); } });
2986
+
2748
2987
  // ── v6.0 Part T — 새 작업(+ 새 작업) / 에이전트 추가(+ 에이전트) ──
2749
2988
  // 작업 = task(이름이 사는 곳), 그 아래 세션 = run. 작업을 만들면 repo 체크아웃에
2750
2989
  // main 터미널(root 세션)이 바로 열리고, 에이전트는 그 작업 아래에 worktree 로 붙는다.
@@ -2931,7 +3170,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
2931
3170
  else{ var j=await res.json().catch(function(){return{};}); toast('저장 실패: '+(j.error||res.status)); }
2932
3171
  }catch(e){ toast('저장 실패: '+e); }
2933
3172
  }
2934
- $('secretsBtn').addEventListener('click', openSecrets);
3173
+ // 여는 자리는 둘 — 상단바 ⋯ 메뉴의 ∗ Secrets(v5.28 G4)와 ⌘K 팔레트. 헤더의 인라인 버튼은 그 메뉴로 들어갔다.
2935
3174
  $('secretsClose').addEventListener('click', closeSecrets);
2936
3175
  $('secretsModal').addEventListener('click', function(e){ if(e.target===this) closeSecrets(); });
2937
3176
  $('secAdd').addEventListener('click', addSecret);
@@ -3215,11 +3454,183 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
3215
3454
  document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('injMenu').hidden){ e.preventDefault(); var o=injMenuOwner; closeInjMenu(); if(o) o.focus(); } });
3216
3455
  window.addEventListener('resize', closeInjMenu);
3217
3456
 
3457
+ // ══ v5.28 Part E — 활동 페인(run activity view) ═════════════════════════════
3458
+ // 돌고 있는 에이전트를 눌렀을 때 보여야 하는 것은 **그 에이전트가 하고 있는 일**이다.
3459
+ // 헤드리스 run 의 tmux 는 빈 worktree 셸이고(에이전트는 떨어져 나가 돈다), 진짜 작업은
3460
+ // 이미 파싱돼 있는 이벤트 스트림(r.events · latestActivity · Part A agentstate)에 있다.
3461
+ // 이 페인은 그것을 **읽을 뿐**이다 — 새 창구도, 새 색도, 새 상태도 만들지 않는다.
3462
+ // ① 터미널은 없어지지 않는다(액션바 첫 버튼이 한 번에 되돌린다)
3463
+ // ② 없는 것은 지어내지 않는다(아직 이벤트가 없으면 starting…)
3464
+ // ③ 에이전트 run 에만 뜬다(손 세션·워크벤치는 진짜 셸이라 터미널이 옳다)
3465
+
3466
+ // 보드 타임라인과 **같은 한 벌**(src/humanize.ts) — 같은 줄을 같은 규칙으로. 사본을 만들지 않는다.
3467
+ ${HUMANIZE_JS}
3468
+
3469
+ // 에이전트 run = 이벤트 스트림을 가진 run. 자유 세션·손 터미널(agent='session')·워크벤치는 아니다.
3470
+ function runIsAgent(runId){
3471
+ if(runId==null || isViewer(runId)) return false;
3472
+ var r=runById[runId]; if(!r) return false;
3473
+ if(runIsSession(runId)) return false;
3474
+ return r.agent!=='session' && r.agent!=='workbench';
3475
+ }
3476
+ // 탭이 열릴 때의 기본 모드(E1). 모르는 run 은 오늘과 똑같이 터미널이다.
3477
+ function defaultTabMode(runId){ return runIsAgent(runId) ? 'activity' : 'terminal'; }
3478
+ function actHostOf(t){
3479
+ if(!t.actHost){ var h=document.createElement('div'); h.className='act-host'; t.actHost=h; }
3480
+ return t.actHost;
3481
+ }
3482
+ // 모드 전환 — 터미널은 **여기서 처음** 만들어지고(lazy), 한 번 만든 뒤로는 계속 산다(되돌아오기는 즉시).
3483
+ function setTabMode(runId, mode){
3484
+ var t=tabs[runId]; if(!t || t.kind==='viewer') return;
3485
+ var want=(mode==='terminal')?'terminal':'activity';
3486
+ if(t.mode===want) return;
3487
+ t.mode=want;
3488
+ if(want==='terminal') ensureTerm(t);
3489
+ render();
3490
+ }
3491
+
3492
+ // "지금" 한 줄 — 파싱된 스트림에서 온 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다.
3493
+ function actNowText(runId){
3494
+ var r=runById[runId]; if(!r) return '';
3495
+ var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
3496
+ if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
3497
+ return latestActivity(runId) || String(r.status||'');
3498
+ }
3499
+ function actPath(runId){
3500
+ var r=runById[runId]; if(!r) return '';
3501
+ var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
3502
+ return (rp?rp.name:'?')+' ▸ '+((task&&task.title)||('task '+(r.taskId!=null?r.taskId:'?')));
3503
+ }
3504
+ // 모델 · 브랜치. 브랜치가 없는 run 은 없다고 말한다(in-place — 트리 행·Review 와 같은 표기).
3505
+ function actMeta(runId){
3506
+ var r=runById[runId]; if(!r) return '';
3507
+ var bits=[]; if(r.model) bits.push(r.model);
3508
+ bits.push(r.branch || 'in-place');
3509
+ return bits.join(' · ');
3510
+ }
3511
+ function actFilesText(runId){ var r=runById[runId]; return 'filesChanged '+((r&&r.filesChanged)||0); }
3512
+
3513
+ function activityHTML(runId){
3514
+ var s=agentStateOf(runId);
3515
+ return '<div class="act-h">'
3516
+ + '<span class="anm">'+esc(runLabel(runId))+'</span>'
3517
+ + '<span class="apath">'+esc(actPath(runId))+'</span>'
3518
+ + '<span class="act-chip '+asClass(s)+'" data-role="actchip">'+esc(s)+'</span>'
3519
+ + '<span class="ameta">'+esc(actMeta(runId))+'</span>'
3520
+ + '</div>'
3521
+ + '<div class="act-now" data-role="actnow">'+esc(actNowText(runId))+'</div>'
3522
+ + '<div class="act-tlwrap"><div class="act-tl" data-role="acttl"></div>'
3523
+ + '<button type="button" class="act-jump" data-role="actjump" data-actjump="'+runId+'" hidden>Latest ↓</button></div>'
3524
+ + '<div class="act-chg"><div class="act-chg-h">'
3525
+ + '<span class="afiles" data-role="actfiles">'+esc(actFilesText(runId))+'</span>'
3526
+ + '<button type="button" class="abtn" data-actdiff="'+runId+'" title="Diff so far — same renderer as Review">Changes</button>'
3527
+ + '<button type="button" class="rv-ws" data-actws="'+runId+'" title="Show whitespace (space/tab)" hidden>␣ WS</button>'
3528
+ + '</div><div class="rv-diff" data-role="actdiffbody" hidden></div></div>'
3529
+ + '<div class="act-bar">'
3530
+ + '<button type="button" class="abtn prim" data-actterm="'+runId+'" title="이 페인을 날 터미널(worktree 셸)로 — 언제든 한 번에 돌아옵니다">Open terminal</button>'
3531
+ + '<button type="button" class="abtn" data-actsteer="'+runId+'" title="이 run 에 후속 지시 (요청바 Steer 경로 그대로)">Steer…</button>'
3532
+ // 대기 중일 때만 Part C 빠른 답이 그대로 선다 — 여기서 새로 만들지 않는다.
3533
+ // 자리 이름은 data-arun 이다(헤더 스트립의 data-qrun 과 달라서, paintPaneStrip 이 이 칸을 덧칠하지 않는다).
3534
+ + '<span class="qr" data-role="qr" data-arun="'+runId+'">'+(s==='waiting'?paneStripHTML(runId):'')+'</span>'
3535
+ + '<button type="button" class="abtn" data-actreview="'+runId+'" title="보드의 run 상세로 (전체 타임라인·산출물)">Review</button>'
3536
+ + '</div>';
3537
+ }
3538
+ function renderActivity(t){
3539
+ var h=actHostOf(t);
3540
+ h.innerHTML=activityHTML(t.runId);
3541
+ t._tlKeys=null; t._tlPinned=true;
3542
+ var tl=h.querySelector('[data-role=acttl]');
3543
+ if(tl) tl.addEventListener('scroll', function(){
3544
+ var atEnd=(tl.scrollHeight - tl.scrollTop - tl.clientHeight) < 24;
3545
+ t._tlPinned=atEnd;
3546
+ var j=h.querySelector('[data-role=actjump]'); if(j) j.hidden=atEnd;
3547
+ });
3548
+ actPaintTimeline(t);
3549
+ if(t._diffOpen){ var db=h.querySelector('[data-actdiff]'); if(db) db.textContent='Hide changes'; actLoadDiff(t.runId); }
3550
+ }
3551
+ function actKey(h){ return h.k+'\\u0000'+h.t; }
3552
+ // 새 줄은 **아래에 잇는다**. 통째로 다시 그리는 것은 이을 자리를 못 찾았을 때뿐이다
3553
+ // (이벤트 창이 40개로 밀려 앞이 잘린 경우). 꼬리에 붙어 있을 때만 따라 내려간다.
3554
+ function actPaintTimeline(t){
3555
+ var h=t.actHost; if(!h) return;
3556
+ var el=h.querySelector('[data-role=acttl]'); if(!el) return;
3557
+ var r=runById[t.runId];
3558
+ var lines=humanLines((r&&r.events)||[]);
3559
+ var prev=t._tlKeys, start=0;
3560
+ if(!prev || !prev.length){ el.innerHTML=''; prev=[]; }
3561
+ else {
3562
+ var last=prev[prev.length-1], at=-1;
3563
+ for(var i=lines.length-1;i>=0;i--){ if(actKey(lines[i])===last){ at=i; break; } }
3564
+ if(at<0){ el.innerHTML=''; prev=[]; } else start=at+1;
3565
+ }
3566
+ for(var j=start;j<lines.length;j++){
3567
+ var d=document.createElement('div'); d.className='act-l';
3568
+ d.innerHTML='<span class="ak">'+esc(lines[j].k)+'</span><span class="at">'+esc(lines[j].t)+'</span>';
3569
+ el.appendChild(d); prev.push(actKey(lines[j]));
3570
+ }
3571
+ t._tlKeys=prev;
3572
+ var jb=h.querySelector('[data-role=actjump]');
3573
+ if(!prev.length){ el.innerHTML='<div class="act-empty">No events yet — starting…</div>'; if(jb) jb.hidden=true; return; }
3574
+ if(t._tlPinned){ el.scrollTop=el.scrollHeight; if(jb) jb.hidden=true; }
3575
+ else if(start<lines.length && jb) jb.hidden=false;
3576
+ }
3577
+ // 델타 한 건 = 열려 있는 활동 페인의 그 자리만 고쳐 칠한다(리하이드레이트 없음 — A4 규율 그대로).
3578
+ function paintActivity(runId){
3579
+ var t=tabs[runId]; if(!t || t.kind==='viewer' || t.mode!=='activity' || !t.actHost) return;
3580
+ var h=t.actHost, s=agentStateOf(runId);
3581
+ var chip=h.querySelector('[data-role=actchip]'); if(chip){ chip.className='act-chip '+asClass(s); chip.textContent=s; }
3582
+ var now=h.querySelector('[data-role=actnow]'); if(now) now.textContent=actNowText(runId);
3583
+ var files=h.querySelector('[data-role=actfiles]'); if(files) files.textContent=actFilesText(runId);
3584
+ var qr=h.querySelector('[data-role=qr]'); if(qr) qr.innerHTML=(s==='waiting')?paneStripHTML(runId):'';
3585
+ actPaintTimeline(t);
3586
+ }
3587
+ // 변경 미리보기 — Review 가 쓰는 그 창구(/api/runs/:id/diff)와 그 렌더러(diffHTML·공백 토글) 그대로.
3588
+ function actLoadDiff(runId){
3589
+ var t=tabs[runId]; if(!t||!t.actHost) return;
3590
+ var body=t.actHost.querySelector('[data-role=actdiffbody]'); if(!body) return;
3591
+ body.hidden=false; body.textContent='Loading…';
3592
+ var wsBtn=t.actHost.querySelector('[data-actws]'); if(wsBtn){ wsBtn.hidden=false; wsBtn.classList.toggle('on', rvShowWs); }
3593
+ fetch('/api/runs/'+runId+'/diff').then(function(x){ return x.json(); }).then(function(d){
3594
+ if(tabs[runId]!==t || !t._diffOpen) return;
3595
+ if(d && d.ok) body.innerHTML=diffHTML(d.diff||'');
3596
+ else body.textContent=(d&&d.stat)||'worktree 가 없어 diff 를 볼 수 없습니다';
3597
+ }).catch(function(){ body.textContent='diff 를 가져오지 못했습니다'; });
3598
+ }
3599
+ function actToggleDiff(runId){
3600
+ var t=tabs[runId]; if(!t||!t.actHost) return;
3601
+ t._diffOpen=!t._diffOpen;
3602
+ var btn=t.actHost.querySelector('[data-actdiff]'); if(btn) btn.textContent=t._diffOpen?'Hide changes':'Changes';
3603
+ if(t._diffOpen){ actLoadDiff(runId); return; }
3604
+ var body=t.actHost.querySelector('[data-role=actdiffbody]'); if(body){ body.hidden=true; body.innerHTML=''; }
3605
+ var wsBtn=t.actHost.querySelector('[data-actws]'); if(wsBtn) wsBtn.hidden=true;
3606
+ }
3607
+ // 공백 표시는 Review 와 **같은 취향 하나**를 나눠 쓴다(두 군데서 따로 기억하지 않는다).
3608
+ function actToggleWs(runId){
3609
+ rvShowWs=!rvShowWs;
3610
+ try{ $('rvWs').classList.toggle('on', rvShowWs); }catch(e){}
3611
+ var t=tabs[runId];
3612
+ if(t && t.actHost){ var b=t.actHost.querySelector('[data-actws]'); if(b) b.classList.toggle('on', rvShowWs); }
3613
+ if(t && t._diffOpen) actLoadDiff(runId);
3614
+ if(reviewOn && rvTaskId!=null) loadCompare(rvTaskId);
3615
+ }
3616
+ // Steer… = 요청바의 steer 경로 그대로(새 창구 없음). 빈 지시는 보내지 않고 작성칸에 세워 준다.
3617
+ function actSteer(runId){
3618
+ var l=null; eachLeaf(layout,function(x){ if(l==null && x.tab===runId) l=x; });
3619
+ if(l) setLeafFocus(l.id);
3620
+ setMode('steer');
3621
+ try{ $('reqInput').focus(); }catch(e){}
3622
+ toast('r'+runId+' 에 이어서 보낼 지시를 적고 ⏎');
3623
+ }
3624
+ // Review = 보드의 run 상세. 이미 있는 /?run=N 딥링크로 갈 뿐, 코크핏이 상세를 다시 그리지 않는다.
3625
+ function gotoBoardRun(runId){ location.href='/?run='+encodeURIComponent(runId); }
3626
+
3218
3627
  // ── 요청바: New(팬아웃) / Steer / Broadcast ──
3219
3628
  var reqMode = 'new';
3220
3629
  function setMode(m){
3221
3630
  reqMode = m;
3222
- Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
3631
+ // 요청바의 세그만 — 같은 .mode 알갱이를 쓰는 다른 세그(시트의 provider·place, 페인의 Activity|Terminal)
3632
+ // 자기 'on' 을 스스로 들고 있다. data-mode 를 가진 것만 만진다.
3633
+ Array.prototype.forEach.call(document.querySelectorAll('.mode[data-mode]'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
3223
3634
  var go=$('reqGo'), inp=$('reqInput');
3224
3635
  $('newCtl').style.display = m==='new'?'inline-flex':'none';
3225
3636
  $('reqTgt').style.display = m==='new'?'none':'inline';
@@ -0,0 +1,62 @@
1
+ // 이벤트 인간화 — 보드와 코크핏이 **같은 한 벌**을 쓴다(사본 금지).
2
+ // 보드 모달의 타임라인과 코크핏 활동 페인(v5.28 E)이 같은 줄을 같은 규칙으로 보여야 하므로,
3
+ // 클라이언트 JS 를 여기 한 곳에 두고 두 페이지의 <script> 에 그대로 끼워 넣는다(icons.ts 전례).
4
+ // 문자열 안의 이스케이프는 **클라이언트 기준**이다: \\n 은 클라이언트의 \n 이 된다.
5
+ export const HUMANIZE_JS = `/* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
6
+ function humanize(e){
7
+ const kind = e.kind, payload = e.payload;
8
+ if (kind === 'rate_limit_event') return null;
9
+ if (kind === 'steer') return { k:'steer', t:'→ '+payload };
10
+ if (kind === 'ask') return { k:'ask', t:'? '+payload };
11
+ if (kind === 'sync') return { k:'sync', t:payload };
12
+ 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 }; } }
13
+ if (kind === 'pr') return { k:'pr', t:payload };
14
+ if (kind === 'stderr') return { k:'stderr', t:payload };
15
+ try{
16
+ const o = JSON.parse(payload);
17
+ if (o.type === 'system'){
18
+ if (o.subtype === 'init' || !o.subtype) return { k:'session',
19
+ t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
20
+ .replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
21
+ 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' };
22
+ return null; // thinking_tokens 등 스트림 잡음
23
+ }
24
+ if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
25
+ if (o.type === 'assistant' && o.message){
26
+ const parts = [];
27
+ for (const x of (o.message.content||[])){
28
+ if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
29
+ else if (x.type === 'tool_use'){
30
+ const i = x.input || {};
31
+ const arg = i.file_path || i.command || i.path || i.pattern || '';
32
+ parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
33
+ }
34
+ }
35
+ return parts.length ? parts : null;
36
+ }
37
+ if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
38
+ if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
39
+ 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(' '))+')' };
40
+ if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
41
+ return { k:kind, t:payload.slice(0,140) };
42
+ }catch{
43
+ // 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
44
+ // text 조각만 구제하고, 없으면 생략.
45
+ if (payload.trim().startsWith('{')){
46
+ const texts = [];
47
+ const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
48
+ while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
49
+ return texts.length ? { k:'said', t:texts.join(' · ') } : null;
50
+ }
51
+ return { k:kind, t:payload };
52
+ }
53
+ }
54
+ function humanLines(events){
55
+ const out = [];
56
+ for (const e of (events||[])){
57
+ const h = humanize(e);
58
+ if (!h) continue;
59
+ if (Array.isArray(h)) out.push(...h); else out.push(h);
60
+ }
61
+ return out;
62
+ }`;