coxpit 6.3.0 → 6.3.2

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.3.0",
3
+ "version": "6.3.2",
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",
@@ -0,0 +1,29 @@
1
+ // "지금 뭐 하고 있나" — 코크핏과 HUD 가 **같은 한 벌**을 쓴다(사본 금지, humanize.ts 전례).
2
+ // 코크핏 활동 페인(v5.28 E)의 Now 줄과 HUD 상세(v5.28 K5)가 같은 규칙으로 같은 말을 해야 하므로,
3
+ // 클라이언트 JS 를 여기 한 곳에 두고 두 페이지의 <script> 에 그대로 끼워 넣는다.
4
+ // 전제: 끼워 넣는 쪽이 runById(맵)를 들고 있다 — 이 함수는 그것만 읽는다.
5
+ // 문자열 안의 이스케이프는 **클라이언트 기준**이다: \\n 은 클라이언트의 \n 이 된다.
6
+ export const ACTIVITY_JS = `/* 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만. */
7
+ function latestActivity(runId){
8
+ var r=runById[runId]; if(!r) return '';
9
+ if(r.status!=='running' && r.status!=='pending') return '';
10
+ var evs=r.events||[];
11
+ for(var i=evs.length-1;i>=0;i--){
12
+ var e=evs[i], k=e.kind;
13
+ if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
14
+ if(k!=='assistant') continue;
15
+ try{ var o=JSON.parse(e.payload);
16
+ var c=o&&o.message&&o.message.content;
17
+ if(c&&c.length){ for(var j=c.length-1;j>=0;j--){ if(c[j].type==='tool_use') return c[j].name||'tool'; if(c[j].type==='text'&&(c[j].text||'').trim()) return 'thinking'; } }
18
+ else if(o&&o.text) return 'thinking';
19
+ }catch(_){}
20
+ }
21
+ return r.status==='pending' ? 'starting' : 'working';
22
+ }
23
+ /* "지금" 한 줄 — 파싱된 스트림에서 온 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다. */
24
+ function actNowText(runId){
25
+ var r=runById[runId]; if(!r) return '';
26
+ var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
27
+ if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
28
+ return latestActivity(runId) || String(r.status||'');
29
+ }`;
package/src/cockpit.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
3
3
  // Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
4
4
  import { HUMANIZE_JS } from './humanize';
5
+ import { ACTIVITY_JS } from './activity';
5
6
 
6
7
  export const COCKPIT_HTML = /* html */ `<!doctype html>
7
8
  <html lang="en">
@@ -1014,23 +1015,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1014
1015
  }
1015
1016
  var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
1016
1017
  function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
1017
- // 라이브 상태 run 최신 이벤트에서 "지금 하는지"(도구명/사고) 뽑는다. 실행 중일 때만.
1018
- function latestActivity(runId){
1019
- var r=runById[runId]; if(!r) return '';
1020
- if(r.status!=='running' && r.status!=='pending') return '';
1021
- var evs=r.events||[];
1022
- for(var i=evs.length-1;i>=0;i--){
1023
- var e=evs[i], k=e.kind;
1024
- if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
1025
- if(k!=='assistant') continue;
1026
- try{ var o=JSON.parse(e.payload);
1027
- var c=o&&o.message&&o.message.content;
1028
- if(c&&c.length){ for(var j=c.length-1;j>=0;j--){ if(c[j].type==='tool_use') return c[j].name||'tool'; if(c[j].type==='text'&&(c[j].text||'').trim()) return 'thinking'; } }
1029
- else if(o&&o.text) return 'thinking';
1030
- }catch(_){}
1031
- }
1032
- return r.status==='pending' ? 'starting' : 'working';
1033
- }
1018
+ // 라이브 상태 + "지금" HUD(v5.28 K)와 **같은 한 벌**(src/activity.ts). 사본을 만들지 않는다.
1019
+ ${ACTIVITY_JS}
1034
1020
 
1035
1021
  // ── fleet 상태 ──
1036
1022
  var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
@@ -3542,13 +3528,7 @@ ${HUMANIZE_JS}
3542
3528
  render();
3543
3529
  }
3544
3530
 
3545
- // "지금" 한 줄 파싱된 스트림에서 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다.
3546
- function actNowText(runId){
3547
- var r=runById[runId]; if(!r) return '';
3548
- var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
3549
- if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
3550
- return latestActivity(runId) || String(r.status||'');
3551
- }
3531
+ // "지금" 한 줄(actNowText)은 ACTIVITY_JS 산다 HUD 같은 벌.
3552
3532
  function actPath(runId){
3553
3533
  var r=runById[runId]; if(!r) return '';
3554
3534
  var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
package/src/db/index.ts CHANGED
@@ -8,8 +8,20 @@ const client = createClient({ url: `file:${config.dbPath}` });
8
8
 
9
9
  export const db = drizzle(client, { schema });
10
10
 
11
+ /**
12
+ * 저널 모드 — 로컬 파일 DB 에서만 의미가 있다. WAL 이면 보드 하이드레이션(읽기)이
13
+ * 이벤트 적재(쓰기)를 막지 않고, synchronous=NORMAL 이면 이벤트 INSERT 마다 fsync 를
14
+ * 기다리지 않는다(크래시 시 최근 몇 이벤트 손실 가능 — 감사 로그라 감수한다).
15
+ * 원격 libSQL URL 은 PRAGMA 를 받지 않을 수 있어 **최선노력**이다: 실패해도 부트는 계속된다.
16
+ */
17
+ async function applyPragmas(): Promise<void> {
18
+ try { await client.execute('PRAGMA journal_mode=WAL'); } catch { /* 원격/미지원 */ }
19
+ try { await client.execute('PRAGMA synchronous=NORMAL'); } catch { /* 원격/미지원 */ }
20
+ }
21
+
11
22
  /** 스키마 부트스트랩(멱등). 정식 마이그레이션은 drizzle-kit(추후). */
12
23
  export async function ensureSchema(): Promise<void> {
24
+ await applyPragmas();
13
25
  await client.executeMultiple(`
14
26
  CREATE TABLE IF NOT EXISTS machines (
15
27
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -117,4 +129,12 @@ export async function ensureSchema(): Promise<void> {
117
129
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
118
130
  // DEFAULT 1 = 기존 run 은 real 로 남는다. 모르는 과거를 dry 로 칠하지 않기 위한 기본값이다(v5.28 H1).
119
131
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN real INTEGER NOT NULL DEFAULT 1'); } catch { /* exists */ }
132
+ // 조인 키 인덱스(멱등) — 없으면 이 여섯 조회가 매번 테이블 전체를 훑는다.
133
+ // ALTER 뒤에 두는 건 의도다: tasks.parent_run_id 는 위에서 붙는 컬럼이다.
134
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_events_run ON agent_events(run_id)'); } catch { /* exists */ }
135
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_runs_task ON agent_runs(task_id)'); } catch { /* exists */ }
136
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo_id)'); } catch { /* exists */ }
137
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_run_id)'); } catch { /* exists */ }
138
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_docsnap_run ON doc_snapshots(run_id)'); } catch { /* exists */ }
139
+ try { await client.execute('CREATE INDEX IF NOT EXISTS idx_sharelinks_run ON share_links(run_id)'); } catch { /* exists */ }
120
140
  }
package/src/hud.ts ADDED
@@ -0,0 +1,672 @@
1
+ // HUD — 플릿을 **작게 다시 내놓는** 한 장 (v5.28 Part K, GET /hud).
2
+ // board.ts · cockpit.ts 와 같은 자가완결 단일 HTML 문자열(빌드 0, 프레임워크 0, 모노·다크·기존 토큰).
3
+ //
4
+ // 이 페이지가 하는 일은 넷뿐이다:
5
+ // ① 알약으로 한눈에 본다(점 + 대기 수) ② 펼쳐서 나를 기다리는 것부터 훑는다
6
+ // ③ 한 줄을 고르면 **목록 옆에** 상세가 열린다 ④ 대기 중인 에이전트에게 코크핏을 열지 않고 답한다
7
+ //
8
+ // [!] 이 실행분은 **서빙되는 페이지까지**다. 창(프레임리스·항상 위)·전역 단축키·크기 변경·
9
+ // 드래그는 뒤따르는 데스크톱 작업(K1/K2)이다. 여기서는 원하는 크기 상태만
10
+ // <html data-hud="pill|list|detail"> 에 적어 둔다 — 데스크톱이 나중에 그걸 읽어 창을 줄이고 늘린다.
11
+ // 페이지 자신은 그 값으로 **안에서** 레이아웃만 바꾼다(브라우저에서 단독으로 돌아간다).
12
+ //
13
+ // 데이터는 전부 코크핏이 이미 읽는 것: GET /api/fleet(+ agentStates · real) 과 /ws 델타.
14
+ // 새 창구는 하나뿐 — POST /api/runs/:id/input (살아 있는 tmux 에 한 줄 써 넣기, getScrollback 의 쓰기 쌍둥이).
15
+ import { HUMANIZE_JS } from './humanize';
16
+ import { ACTIVITY_JS } from './activity';
17
+
18
+ export const HUD_HTML = /* html */ `<!doctype html>
19
+ <html lang="en" data-hud="pill">
20
+ <head>
21
+ <meta charset="utf-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
23
+ <title>coxpit · hud</title>
24
+ <link rel="icon" href="/brand/favicon.ico" sizes="any" />
25
+ <style>
26
+ /* 토큰은 코크핏과 **같은 팔레트 그대로**다(board.ts/cockpit.ts 의 :root 블록). 새 색은 0개. */
27
+ :root{
28
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --panel:#0e1118;
29
+ --line:#222835; --line-hi:#2f3648;
30
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
31
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
32
+ --running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67; --merged:#4ec9b0; --open:#7f9cf5; --stopped:#b58be0;
33
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
34
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
35
+ }
36
+ *{box-sizing:border-box}
37
+ html,body{height:100%;overscroll-behavior:none}
38
+ /* 지면은 보드·코크핏과 같은 --bg 다. (데스크톱 창이 프레임리스·투명으로 이걸 띄울 때는
39
+ 그 실행분에서 투명 배경을 덧입힌다 — 지금은 브라우저에서 단독으로 열려도 어두워야 한다.) */
40
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:13px;line-height:1.45;
41
+ -webkit-font-smoothing:antialiased;overflow:hidden;display:flex;align-items:flex-start;justify-content:center;padding:6px}
42
+ button{font-family:var(--sans);cursor:pointer}
43
+ :focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
44
+ *{scrollbar-width:thin;scrollbar-color:var(--line-hi) transparent}
45
+ ::-webkit-scrollbar{width:7px;height:7px}
46
+ ::-webkit-scrollbar-track{background:transparent}
47
+ ::-webkit-scrollbar-thumb{background:var(--line-hi);border-radius:4px}
48
+
49
+ /* 세 가지 배치 = 한 장의 세 상태. data-hud 는 데스크톱이 창 크기를 맞출 때 읽어 갈 자리이기도 하다. */
50
+ html[data-hud="pill"] #panel{display:none}
51
+ html[data-hud="list"] #pill,html[data-hud="detail"] #pill{display:none}
52
+ html[data-hud="pill"] body{align-items:flex-start}
53
+
54
+ /* ── K3. 접힌 알약 — 이것이 평상시의 전부다 ───────────────────────────── */
55
+ .pill{display:inline-flex;align-items:center;gap:8px;height:26px;padding:0 11px;border-radius:999px;
56
+ background:var(--surface);border:1px solid var(--line);box-shadow:0 8px 28px rgba(0,0,0,.35);
57
+ font-family:var(--mono);font-size:11px;color:var(--muted);-webkit-app-region:drag}
58
+ .pill:hover{border-color:var(--line-hi);color:var(--ink)}
59
+ .pill>*{-webkit-app-region:no-drag}
60
+ .pdots{display:inline-flex;align-items:center;gap:4px}
61
+ .pwait{color:var(--blocked);font-size:11px;letter-spacing:.02em}
62
+ .pquiet{color:var(--faint);font-size:10.5px}
63
+
64
+ /* 상태 점 — Part A 매핑 그대로(working=--running · waiting=--blocked 맥박 · idle=--faint · exited=--done) */
65
+ .st{width:7px;height:7px;border-radius:50%;background:var(--faint);display:inline-block;flex:none}
66
+ .st.as-working{background:var(--running)}
67
+ .st.as-waiting{background:var(--blocked);animation:aspulse 1.5s ease-in-out infinite}
68
+ .st.as-idle{background:var(--faint)}
69
+ .st.as-exited{background:var(--done)}
70
+ @keyframes aspulse{0%,100%{opacity:1}50%{opacity:.35}}
71
+ @media (prefers-reduced-motion:reduce){ .st.as-waiting{animation:none} }
72
+
73
+ /* ── K4. 펼친 분류 목록 ─────────────────────────────────────────────── */
74
+ .panel{width:100%;max-width:250px;display:flex;flex-direction:column;max-height:calc(100vh - 12px);
75
+ background:var(--surface);border:1px solid var(--line);border-radius:10px;
76
+ box-shadow:0 8px 28px rgba(0,0,0,.35);overflow:hidden}
77
+ html[data-hud="detail"] .panel{max-width:570px}
78
+ .hh{display:flex;align-items:center;gap:7px;padding:7px 9px;border-bottom:1px solid var(--line);
79
+ font-family:var(--mono);font-size:11px;-webkit-app-region:drag}
80
+ .hh>button{-webkit-app-region:no-drag}
81
+ .lead{color:var(--blocked);white-space:nowrap}
82
+ .lead.clear{color:var(--done)}
83
+ .subs{color:var(--faint);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
84
+ .hx{margin-left:auto;background:none;border:none;color:var(--faint);font-family:var(--mono);font-size:13px;
85
+ line-height:1;padding:2px 5px;border-radius:5px}
86
+ .hx:hover{color:var(--ink);background:var(--surface2)}
87
+ .hbody{display:flex;min-height:0;flex:1}
88
+ .hlist{width:250px;flex:none;overflow-y:auto;padding:5px 0}
89
+ html[data-hud="list"] .hlist{width:100%}
90
+ .hdetail{flex:1;min-width:0;border-left:1px solid var(--line);overflow-y:auto;padding:9px 11px;background:var(--panel)}
91
+ html[data-hud="list"] .hdetail{display:none}
92
+ .lbl{font-family:var(--mono);font-size:9.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);
93
+ padding:6px 11px 3px}
94
+ .row{display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:none;border:none;
95
+ padding:5px 11px;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
96
+ .row:hover{background:var(--surface2);color:var(--ink)}
97
+ .row.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 2px 0 0 var(--brand)}
98
+ .row .rn{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:46%}
99
+ .row.wait .rn{color:var(--blocked)}
100
+ .row .rp{color:var(--faint);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
101
+ .row .rel{margin-left:auto;color:var(--faint);font-size:10px;flex:none}
102
+ .dryc{flex:none;font-family:var(--mono);font-size:9px;color:var(--faint);border:1px solid var(--line-hi);
103
+ border-radius:4px;padding:0 3px;letter-spacing:.04em}
104
+ .cnt{display:block;width:100%;text-align:left;background:none;border:none;padding:5px 11px;
105
+ font-family:var(--mono);font-size:10.5px;color:var(--faint)}
106
+ .cnt:hover{color:var(--ink)}
107
+ .clearbox{padding:11px;font-family:var(--mono);font-size:11px;color:var(--muted);line-height:1.6}
108
+ .clearbox .big{color:var(--done);display:block;margin-bottom:3px}
109
+ .hfoot{display:flex;align-items:center;gap:8px;padding:5px 9px;border-top:1px solid var(--line);
110
+ font-family:var(--mono);font-size:9.5px;color:var(--faint)}
111
+ .hver{margin-left:auto;opacity:.8}
112
+
113
+ /* ── K5. 상세(목록 오른쪽) ────────────────────────────────────────── */
114
+ .d-h{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;margin-bottom:6px}
115
+ .d-h .dnm{color:var(--ink)}
116
+ .d-chip{font-size:9.5px;color:var(--faint)}
117
+ .d-chip.as-working{color:var(--running)} .d-chip.as-waiting{color:var(--blocked)}
118
+ .d-chip.as-idle{color:var(--faint)} .d-chip.as-exited{color:var(--done)}
119
+ .d-meta{font-family:var(--mono);font-size:10px;color:var(--faint);margin-bottom:8px;
120
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
121
+ .d-q{border:1px solid var(--line-hi);border-radius:8px;background:var(--surface);padding:8px 9px;margin-bottom:8px}
122
+ .d-q .qk{display:block;font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;
123
+ color:var(--faint);margin-bottom:4px}
124
+ .d-q pre{margin:0;font-family:var(--mono);font-size:11px;color:var(--ink);white-space:pre-wrap;word-break:break-word;
125
+ max-height:150px;overflow:auto}
126
+ .d-q .unk{font-family:var(--mono);font-size:11px;color:var(--muted)}
127
+ .d-sec{font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);
128
+ margin:9px 0 4px}
129
+ .d-now{font-family:var(--mono);font-size:11.5px;color:var(--ink);margin-bottom:4px}
130
+ .d-tl{border-left:1px solid var(--line);padding-left:8px;max-height:130px;overflow:auto}
131
+ .d-l{display:flex;gap:6px;font-family:var(--mono);font-size:10.5px;line-height:1.5}
132
+ .d-l .ak{flex:none;color:var(--faint);min-width:42px}
133
+ .d-l .at{color:var(--muted);white-space:pre-wrap;word-break:break-word}
134
+ .d-tail{margin:0;font-family:var(--mono);font-size:10.5px;color:var(--muted);background:var(--panel);
135
+ border:1px solid var(--line);border-radius:7px;padding:7px 8px;max-height:210px;overflow:auto;
136
+ white-space:pre-wrap;word-break:break-word}
137
+ .d-diff{margin:6px 0 0;font-family:var(--mono);font-size:10px;color:var(--muted);max-height:160px;overflow:auto;
138
+ white-space:pre;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 8px}
139
+ .d-bar{display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--line)}
140
+ .dbtn{font-family:var(--mono);font-size:10px;color:var(--muted);background:none;border:1px solid var(--line);
141
+ border-radius:6px;padding:3px 8px}
142
+ .dbtn:hover,.dbtn:focus-visible{color:var(--ink);border-color:var(--line-hi);background:var(--surface2)}
143
+ .dbtn.prim{color:var(--brand);border-color:rgba(78,201,176,.4)}
144
+ .dbtn.danger:hover{color:var(--failed);border-color:var(--failed)}
145
+ /* 빠른 답은 대기의 색(--blocked)을 빌린다 — 코크핏 .qbtn 과 같은 주의색, 새 색 없음 */
146
+ .qbtn{font-family:var(--mono);font-size:10.5px;color:var(--blocked);background:none;border:1px solid var(--line-hi);
147
+ border-radius:999px;padding:3px 10px}
148
+ .qbtn:hover,.qbtn:focus-visible{color:var(--ink);border-color:var(--blocked)}
149
+ .d-steer{display:flex;gap:5px;margin-top:8px}
150
+ .d-steer input{flex:1;min-width:0;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);
151
+ border:1px solid var(--line-hi);border-radius:6px;padding:5px 8px;outline:none}
152
+ .d-steer input:focus{border-color:var(--brand)}
153
+ .d-empty{font-family:var(--mono);font-size:11px;color:var(--faint);padding:14px 4px}
154
+ .toast{position:fixed;left:50%;bottom:9px;transform:translateX(-50%);max-width:92%;
155
+ font-family:var(--mono);font-size:10.5px;color:var(--ink);background:var(--surface2);
156
+ border:1px solid var(--line-hi);border-radius:7px;padding:5px 10px;opacity:0;pointer-events:none;
157
+ transition:opacity .16s ease;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
158
+ .toast.on{opacity:1}
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <!-- K3 — 접힌 알약. 누르면 펼쳐진다(창 크기는 데스크톱이 나중에 data-hud 를 보고 맞춘다). -->
163
+ <button type="button" id="pill" class="pill" aria-label="coxpit fleet">
164
+ <span class="pdots" id="pillDots"></span>
165
+ <span class="pwait" id="pillWait" hidden></span>
166
+ <span class="pquiet" id="pillQuiet" hidden>quiet</span>
167
+ </button>
168
+
169
+ <!-- K4/K5 — 펼친 분류 목록(+ 고른 행의 상세가 오른쪽에) -->
170
+ <section id="panel" class="panel" aria-label="fleet triage">
171
+ <header class="hh">
172
+ <span class="lead" id="hudLead">connecting</span>
173
+ <span class="subs" id="hudSubs"></span>
174
+ <button type="button" class="hx" id="hudMin" title="알약으로 접기 (esc)">&minus;</button>
175
+ </header>
176
+ <div class="hbody">
177
+ <div class="hlist" id="hudList"></div>
178
+ <div class="hdetail" id="hudDetail"></div>
179
+ </div>
180
+ <div class="hfoot"><span>j/k &middot; enter &middot; y/n/c &middot; esc</span><span class="hver">v__COXPIT_VER__</span></div>
181
+ </section>
182
+ <div class="toast" id="toast"></div>
183
+
184
+ <script>
185
+ (function(){
186
+ var $=function(id){ return document.getElementById(id); };
187
+ function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
188
+ function escA(s){ return esc(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
189
+ var toastT=null;
190
+ function toast(msg){
191
+ var el=$('toast'); el.textContent=msg; el.classList.add('on');
192
+ if(toastT) clearTimeout(toastT);
193
+ toastT=setTimeout(function(){ el.classList.remove('on'); }, 3200);
194
+ }
195
+
196
+ // ── 레이아웃 상태 = 데스크톱이 읽어 갈 자리 ────────────────────────────
197
+ // 창을 줄이고 늘리는 것은 **여기가 아니다**(K1, 데스크톱 작업). 이 페이지는 원하는 크기를
198
+ // <html data-hud> 에 적어 두고, 자기 안에서는 그 값으로 배치만 바꾼다.
199
+ var layout='pill';
200
+ function setLayout(next){
201
+ layout=next;
202
+ document.documentElement.setAttribute('data-hud', next);
203
+ if(next==='pill'){ try{ $('pill').focus(); }catch(e){} }
204
+ }
205
+ function expand(){ if(layout==='pill'){ setLayout('list'); render(); } }
206
+ function collapse(){ sel=null; $('hudDetail').innerHTML=''; setLayout('pill'); }
207
+ $('pill').addEventListener('click', expand);
208
+ $('hudMin').addEventListener('click', collapse);
209
+
210
+ // ── 플릿 상태 — 코크핏과 같은 창구, 같은 판정 규칙 ─────────────────────
211
+ var fleet={runs:[],tasks:[],repos:[]}, runById={}, taskById={}, repoById={};
212
+ var agentState={}; // runId -> 'working'|'waiting'|'idle'|'exited' (서버가 준 것만; 짐작하지 않는다)
213
+ var AS_CLASS={ working:'as-working', waiting:'as-waiting', idle:'as-idle', exited:'as-exited' };
214
+ function asClass(s){ return AS_CLASS[s]||''; }
215
+ function agentStateOf(runId){ return agentState[runId]||''; }
216
+ // dry 판정은 한 줄뿐이다(Part H): real===false 인 것만 dry. undefined 는 모르는 것이지 dry 가 아니다.
217
+ function isDryRun(r){ return !!r && r.real===false; }
218
+ function dryChip(r){ return isDryRun(r) ? '<span class="dryc" title="dry run — 모의 스트림이 만든 변경입니다">dry</span>' : ''; }
219
+ function runIsSession(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; var rp=t&&repoById[t.repoId]; return !!(rp&&rp.kind==='sessions'); }
220
+ function runLive(runId){ var r=runById[runId]; if(!r) return false;
221
+ return r.status==='running'||r.status==='pending'||r.status==='preparing'||r.status==='open'; }
222
+ function runLabel(runId){
223
+ var r=runById[runId]; if(!r) return 'r'+runId;
224
+ if(runIsSession(runId)){ var t=taskById[r.taskId]; return (t&&t.title)||('r'+runId); }
225
+ return r.title || r.agent || ('r'+runId);
226
+ }
227
+ function runPath(runId){
228
+ var r=runById[runId]; if(!r) return '';
229
+ var t=taskById[r.taskId], rp=t&&repoById[t.repoId];
230
+ return (rp?rp.name:'?')+' \\u25b8 '+((t&&t.title)||('task '+(r.taskId!=null?r.taskId:'?')));
231
+ }
232
+ function runMeta(runId){
233
+ var r=runById[runId]; if(!r) return '';
234
+ var bits=[]; if(r.model) bits.push(r.model);
235
+ bits.push(runIsSession(runId) ? (r.worktreePath||'session') : (r.branch||'in-place'));
236
+ return bits.join(' \\u00b7 ');
237
+ }
238
+ function elapsed(runId){
239
+ var r=runById[runId]; if(!r||!r.startedAt) return '';
240
+ var t=new Date(r.startedAt).getTime(); if(!t) return '';
241
+ var s=Math.max(0, Math.floor((Date.now()-t)/1000));
242
+ if(s<60) return s+'s';
243
+ if(s<3600) return Math.floor(s/60)+'m';
244
+ if(s<86400) return Math.floor(s/3600)+'h';
245
+ return Math.floor(s/86400)+'d';
246
+ }
247
+
248
+ // 코크핏·보드와 **같은 한 벌**. 사본을 만들지 않는다.
249
+ ${HUMANIZE_JS}
250
+ ${ACTIVITY_JS}
251
+
252
+ // ── K4. 세 갈래 — 나를 기다리는 것 / 도는 것 / 나머지 ────────────────
253
+ // 갈래는 Part A 가 준 상태와 run.status 로만 정한다(지어낸 상태 없음).
254
+ function buckets(){
255
+ var wait=[], run=[], rest=[];
256
+ (fleet.runs||[]).forEach(function(r){
257
+ var s=agentStateOf(r.id), sess=runIsSession(r.id);
258
+ var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
259
+ if(s==='waiting'){ wait.push(r); return; }
260
+ if(!sess && (s==='working'||live)){ run.push(r); return; }
261
+ if(sess && r.tmuxWindow && r.status==='open'){ rest.push(r); return; }
262
+ if(s==='idle'||s==='exited') rest.push(r);
263
+ });
264
+ var byId=function(a,b){ return a.id-b.id; };
265
+ wait.sort(byId); run.sort(byId); rest.sort(byId);
266
+ return { wait:wait, run:run, rest:rest };
267
+ }
268
+
269
+ var sel=null; // 고른 run (상세의 주인)
270
+ var curId=null; // j/k 하이라이트가 서 있는 run — **id 로 기억한다**(다시 그려도 안 흔들리게)
271
+ var cur=-1; // 그 id 의 지금 위치
272
+ var restOpen=false; // idle/session 접힘 — 기본은 한 줄 숫자다
273
+ var rowIds=[]; // 지금 화면에 선 행들(키보드 이동 대상)
274
+ function markedId(){ return curId!=null ? curId : sel; }
275
+
276
+ function rowHTML(r, kind){
277
+ var s=agentStateOf(r.id);
278
+ var bits='<span class="st '+asClass(s)+'"></span>'
279
+ + '<span class="rn">'+esc(runLabel(r.id))+'</span>';
280
+ if(kind==='wait') bits+='<span class="rp">'+esc(runPath(r.id))+'</span>';
281
+ else if(kind==='run') bits+='<span class="rp">'+esc(actNowText(r.id))+'</span>';
282
+ else bits+='<span class="rp">'+esc(runIsSession(r.id)?'session':(s||r.status||''))+'</span>';
283
+ bits+=dryChip(r)+'<span class="rel">'+esc(elapsed(r.id))+'</span>';
284
+ return '<button type="button" class="row '+(kind==='wait'?'wait ':'')+(markedId()===r.id?'on':'')+'"'
285
+ + ' data-run="'+r.id+'" title="'+escA(runPath(r.id))+'">'+bits+'</button>';
286
+ }
287
+
288
+ function render(){
289
+ if(layout==='pill'){ paintPill(); return; }
290
+ var b=buckets();
291
+ // 헤더 — 기다리는 것이 있으면 그것이 먼저다(호박색), 없으면 all clear(초록).
292
+ var lead=$('hudLead'), subs=$('hudSubs');
293
+ if(b.wait.length){ lead.textContent=b.wait.length+' needs you'; lead.classList.remove('clear'); }
294
+ else { lead.textContent='all clear'; lead.classList.add('clear'); }
295
+ subs.textContent='\\u00b7 '+b.run.length+' running \\u00b7 '+b.rest.length+' idle';
296
+
297
+ var h='', ids=[];
298
+ if(b.wait.length){
299
+ h+='<div class="lbl">needs you</div>';
300
+ b.wait.forEach(function(r){ h+=rowHTML(r,'wait'); ids.push(r.id); });
301
+ }
302
+ if(b.run.length){
303
+ h+='<div class="lbl">running '+b.run.length+'</div>';
304
+ b.run.forEach(function(r){ h+=rowHTML(r,'run'); ids.push(r.id); });
305
+ }
306
+ if(!b.wait.length && !b.run.length){
307
+ // 대기가 0일 때는 빈 목록이 아니라 **차분한 요약**이다 — 어조가 상태를 따라간다.
308
+ h+='<div class="clearbox"><span class="big">all clear</span>'
309
+ + '기다리는 에이전트가 없습니다.<br>'+b.rest.length+' idle \\u00b7 도는 것 없음</div>';
310
+ }
311
+ if(b.rest.length){
312
+ h+='<button type="button" class="cnt" id="restToggle">'+(restOpen?'\\u25be':'\\u25b8')+' idle / session '+b.rest.length+'</button>';
313
+ if(restOpen) b.rest.forEach(function(r){ h+=rowHTML(r,'rest'); ids.push(r.id); });
314
+ }
315
+ if(!ids.length && !b.rest.length && !b.wait.length && !b.run.length && !(fleet.runs||[]).length){
316
+ h+='<div class="d-empty">아직 run 이 없습니다</div>';
317
+ }
318
+ $('hudList').innerHTML=h;
319
+ rowIds=ids;
320
+ var mk=markedId();
321
+ cur=(mk!=null)?ids.indexOf(mk):-1;
322
+ if(cur<0) curId=null;
323
+ paintPill();
324
+ }
325
+
326
+ // ── K3. 알약 — 점 + 대기 수. 대기가 없으면 조용하다(맥박 없음). ──────────
327
+ function paintPill(){
328
+ var b=buckets();
329
+ var dots='';
330
+ b.wait.slice(0,3).forEach(function(){ dots+='<span class="st as-waiting"></span>'; });
331
+ b.run.slice(0,3).forEach(function(){ dots+='<span class="st as-working"></span>'; });
332
+ if(!b.wait.length && !b.run.length) dots+='<span class="st as-idle"></span>';
333
+ $('pillDots').innerHTML=dots;
334
+ var w=$('pillWait'), q=$('pillQuiet');
335
+ if(b.wait.length){ w.hidden=false; w.textContent='\\u25d4 '+b.wait.length; q.hidden=true; }
336
+ else { w.hidden=true; q.hidden=false; q.textContent=b.run.length?(b.run.length+' running'):'quiet'; }
337
+ $('pill').title = b.wait.length ? (b.wait.length+' 개가 답을 기다립니다') : (b.run.length+' 개가 도는 중');
338
+ }
339
+
340
+ // ── K5. 상세 — 고른 행 하나. 목록은 그대로 옆에 남는다. ────────────────
341
+ // 종류에 따라 셋: 기다리는 에이전트 · 도는 에이전트 · 세션(자유 터미널).
342
+ var HUD_REPLIES=[
343
+ { k:'approve', label:'승인', send:'1' },
344
+ { k:'deny', label:'거절', send:'2' },
345
+ { k:'cont', label:'계속', send:'계속' }
346
+ ];
347
+ function replyFor(k){ for(var i=0;i<HUD_REPLIES.length;i++){ if(HUD_REPLIES[i].k===k) return HUD_REPLIES[i]; } return null; }
348
+
349
+ function detailHTML(runId){
350
+ var r=runById[runId]; if(!r) return '<div class="d-empty">사라진 run 입니다</div>';
351
+ var s=agentStateOf(runId), sess=runIsSession(runId);
352
+ var h='<div class="d-h"><span class="dnm">'+esc(runLabel(runId))+'</span>'
353
+ + '<span class="d-chip '+asClass(s)+'" data-role="dchip">'+esc(s||r.status||'')+'</span>'
354
+ + dryChip(r)+'</div>'
355
+ + '<div class="d-meta">'+esc(runPath(runId))+' \\u00b7 '+esc(runMeta(runId))+'</div>';
356
+
357
+ if(sess){
358
+ // 세션 = 에이전트가 없다. 질문도 steer 도 없고, 지금 화면(스크롤백 꼬리)이 전부다.
359
+ h+='<div class="d-sec">terminal tail</div>'
360
+ + '<pre class="d-tail" data-role="tail">읽는 중\\u2026</pre>'
361
+ + '<div class="d-bar">'
362
+ + '<a class="dbtn prim" href="/cockpit?run='+encodeURIComponent(runId)+'">터미널 열기</a>'
363
+ + '<button type="button" class="dbtn" data-rename="'+runId+'">이름변경</button>'
364
+ + '<button type="button" class="dbtn danger" data-del="'+runId+'">삭제</button>'
365
+ + '</div>';
366
+ return h;
367
+ }
368
+
369
+ if(s==='waiting'){
370
+ // 질문은 **지어내지 않는다** — 파싱된 스트림/터미널 화면에서 온 것만 보여주고, 없으면 없다고 말한다.
371
+ h+='<div class="d-q" data-role="qbox"><span class="qk">pending</span>'
372
+ + '<div class="unk" data-role="qtext">무엇을 묻는지 읽는 중\\u2026</div></div>'
373
+ + '<div class="d-bar" data-role="qr">';
374
+ HUD_REPLIES.forEach(function(q){
375
+ h+='<button type="button" class="qbtn" data-qr="'+runId+'" data-qk="'+escA(q.k)+'"'
376
+ + ' title="고정 문자열 전송 \\u00b7 '+escA(q.send)+'">'+esc(q.label)+'</button>';
377
+ });
378
+ h+='</div>';
379
+ }
380
+
381
+ h+='<div class="d-sec">now</div>'
382
+ + '<div class="d-now" data-role="dnow">'+esc(actNowText(runId))+'</div>'
383
+ + '<div class="d-sec">최근 활동</div>'
384
+ + '<div class="d-tl" data-role="dtl"></div>'
385
+ + '<div class="d-sec">change</div>'
386
+ + '<div><span class="d-now">filesChanged '+((r.filesChanged)||0)+'</span> '
387
+ + '<button type="button" class="dbtn" data-diff="'+runId+'">Changes</button></div>'
388
+ + '<pre class="d-diff" data-role="ddiff" hidden></pre>'
389
+ + '<div class="d-steer">'
390
+ + '<input id="steerInput" placeholder="'+(runLive(runId)?'이어서 보낼 한 줄':'후속 지시 (steer)')+'" autocomplete="off" spellcheck="false" />'
391
+ + '<button type="button" class="dbtn prim" data-steer="'+runId+'">Steer</button>'
392
+ + '</div>'
393
+ + '<div class="d-bar">'
394
+ + '<a class="dbtn" href="/cockpit?run='+encodeURIComponent(runId)+'">코크핏에서 열기</a>'
395
+ + '<button type="button" class="dbtn danger" data-stop="'+runId+'">정지</button>'
396
+ + '</div>';
397
+ return h;
398
+ }
399
+
400
+ function openDetail(runId){
401
+ sel=runId; curId=runId;
402
+ setLayout('detail');
403
+ $('hudDetail').innerHTML=detailHTML(runId);
404
+ paintTimeline(runId);
405
+ if(runIsSession(runId)) loadTail(runId);
406
+ else if(agentStateOf(runId)==='waiting') loadQuestion(runId);
407
+ render();
408
+ }
409
+ function closeDetail(){
410
+ sel=null; $('hudDetail').innerHTML='';
411
+ setLayout('list'); render();
412
+ }
413
+ function paintTimeline(runId){
414
+ var el=$('hudDetail').querySelector('[data-role=dtl]'); if(!el) return;
415
+ var r=runById[runId];
416
+ var lines=humanLines((r&&r.events)||[]).slice(-6);
417
+ if(!lines.length){ el.innerHTML='<div class="d-l"><span class="at">아직 이벤트가 없습니다</span></div>'; return; }
418
+ var h=''; lines.forEach(function(l){
419
+ h+='<div class="d-l"><span class="ak">'+esc(l.k)+'</span><span class="at">'+esc(String(l.t).slice(0,200))+'</span></div>';
420
+ });
421
+ el.innerHTML=h;
422
+ }
423
+ // 기다리는 에이전트가 **무엇을 묻는지** — 코크핏을 열지 않고 알아야 하는 한 가지.
424
+ // 출처는 둘뿐이다: 터미널의 지금 화면(스크롤백 꼬리) 또는 파싱된 스트림의 마지막 말.
425
+ // 둘 다 없으면 없다고 말한다. 추측한 질문을 보여주면 그 위에서 내리는 결정이 전부 거짓이 된다.
426
+ function loadQuestion(runId){
427
+ var lastSaid='';
428
+ var r=runById[runId];
429
+ var lines=humanLines((r&&r.events)||[]);
430
+ for(var i=lines.length-1;i>=0;i--){ if(lines[i].k==='said'||lines[i].k==='ask'){ lastSaid=String(lines[i].t); break; } }
431
+ fetch('/api/runs/'+runId+'/scrollback?lines=60').then(function(x){ return x.json(); }).then(function(d){
432
+ if(sel!==runId) return;
433
+ var box=$('hudDetail').querySelector('[data-role=qbox]'); if(!box) return;
434
+ var tail=(d&&d.ok&&d.text)?String(d.text).replace(/\\s+$/,''):'';
435
+ if(tail){
436
+ var keep=tail.split('\\n').slice(-14).join('\\n');
437
+ box.innerHTML='<span class="qk">터미널 마지막 화면</span><pre>'+esc(keep)+'</pre>';
438
+ return;
439
+ }
440
+ if(lastSaid){ box.innerHTML='<span class="qk">마지막 말</span><pre>'+esc(lastSaid.slice(0,600))+'</pre>'; return; }
441
+ box.innerHTML='<span class="qk">pending</span><div class="unk">무엇을 묻는지 읽지 못했습니다 \\u2014 터미널을 열어 확인하세요</div>';
442
+ }).catch(function(){
443
+ if(sel!==runId) return;
444
+ var box=$('hudDetail').querySelector('[data-role=qbox]'); if(!box) return;
445
+ box.innerHTML='<span class="qk">pending</span><div class="unk">무엇을 묻는지 읽지 못했습니다 \\u2014 터미널을 열어 확인하세요</div>';
446
+ });
447
+ }
448
+ function loadTail(runId){
449
+ fetch('/api/runs/'+runId+'/scrollback?lines=80').then(function(x){ return x.json(); }).then(function(d){
450
+ if(sel!==runId) return;
451
+ var el=$('hudDetail').querySelector('[data-role=tail]'); if(!el) return;
452
+ if(d&&d.ok&&String(d.text||'').trim()){
453
+ el.textContent=String(d.text).replace(/\\s+$/,'').split('\\n').slice(-30).join('\\n');
454
+ } else el.textContent=(d&&d.text)||'터미널 화면을 읽지 못했습니다';
455
+ }).catch(function(){
456
+ if(sel!==runId) return;
457
+ var el=$('hudDetail').querySelector('[data-role=tail]'); if(el) el.textContent='터미널 화면을 읽지 못했습니다';
458
+ });
459
+ }
460
+ function loadDiff(runId){
461
+ var el=$('hudDetail').querySelector('[data-role=ddiff]'); if(!el) return;
462
+ el.hidden=false; el.textContent='Loading\\u2026';
463
+ fetch('/api/runs/'+runId+'/diff').then(function(x){ return x.json(); }).then(function(d){
464
+ if(sel!==runId) return;
465
+ if(d&&d.ok) el.textContent=String((d.stat?d.stat+'\\n\\n':'')+(d.diff||'')).split('\\n').slice(0,60).join('\\n')||'변경 없음';
466
+ else el.textContent=(d&&d.stat)||'worktree 가 없어 diff 를 볼 수 없습니다';
467
+ }).catch(function(){ if(sel===runId) el.textContent='diff 를 가져오지 못했습니다'; });
468
+ }
469
+
470
+ // ── 행동 — 전부 이미 있는 창구다(새로 생긴 것은 input 하나뿐) ───────────
471
+ // 살아 있는 run 에 한 줄 써 넣기 = POST /api/runs/:id/input (tmux send-keys).
472
+ // 정착한 run 은 steer(--resume) 가 옳은 길이므로 그쪽으로 보낸다.
473
+ function sendInput(runId, text){
474
+ return fetch('/api/runs/'+runId+'/input', {
475
+ method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({text:text}),
476
+ }).then(function(x){ return x.json().then(function(j){ return {code:x.status, j:j}; }); });
477
+ }
478
+ function quickReply(runId, k){
479
+ var q=replyFor(k); if(!q) return;
480
+ sendInput(runId, q.send).then(function(res){
481
+ if(res.code===200 && res.j && res.j.ok) toast('보냄 \\u00b7 '+q.send);
482
+ else toast('보내지 못했습니다: '+((res.j&&(res.j.detail||res.j.error))||res.code));
483
+ }).catch(function(){ toast('보내지 못했습니다'); });
484
+ }
485
+ function steer(runId){
486
+ var inp=$('steerInput'); if(!inp) return;
487
+ var v=(inp.value||'').trim();
488
+ if(!v){ inp.focus(); toast('보낼 한 줄을 적으세요'); return; }
489
+ if(runLive(runId)){
490
+ sendInput(runId, v).then(function(res){
491
+ if(res.code===200 && res.j && res.j.ok){ inp.value=''; toast('보냄'); }
492
+ else toast('보내지 못했습니다: '+((res.j&&(res.j.detail||res.j.error))||res.code));
493
+ }).catch(function(){ toast('보내지 못했습니다'); });
494
+ return;
495
+ }
496
+ fetch('/api/runs/'+runId+'/steer', {
497
+ method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({message:v}),
498
+ }).then(function(x){ return x.json().then(function(j){ return {code:x.status, j:j}; }); })
499
+ .then(function(res){
500
+ if(res.code===202 || (res.j&&res.j.ok)){ inp.value=''; toast('이어서 보냈습니다'); }
501
+ else toast('보내지 못했습니다: '+((res.j&&(res.j.detail||res.j.error))||res.code));
502
+ }).catch(function(){ toast('보내지 못했습니다'); });
503
+ }
504
+ function stop(runId){
505
+ fetch('/api/runs/'+runId+'/stop', {method:'POST'}).then(function(x){ return x.json(); }).then(function(j){
506
+ toast(j&&j.ok?('r'+runId+' 정지'):('정지 실패: '+((j&&(j.detail||j.error))||'')));
507
+ hydrate();
508
+ }).catch(function(){ toast('정지 실패'); });
509
+ }
510
+ // 세션 이름 변경 — 네이티브 prompt 를 띄우지 않는다. 버튼 자리가 그대로 입력칸이 된다.
511
+ function startRename(runId, btn){
512
+ var r=runById[runId]; if(!r) return;
513
+ var t=taskById[r.taskId]; if(!t) return;
514
+ var box=document.createElement('span');
515
+ box.className='d-steer';
516
+ box.innerHTML='<input id="renameInput" value="'+escA(t.title||'')+'" autocomplete="off" spellcheck="false" />';
517
+ btn.replaceWith(box);
518
+ var inp=$('renameInput'); inp.focus(); inp.select();
519
+ var commit=function(){
520
+ var v=(inp.value||'').trim();
521
+ if(!v || v===t.title){ openDetail(runId); return; }
522
+ fetch('/api/tasks/'+r.taskId, {
523
+ method:'PATCH', headers:{'content-type':'application/json'}, body:JSON.stringify({title:v}),
524
+ }).then(function(x){ return x.json(); }).then(function(j){
525
+ if(j&&j.ok!==false) toast('이름 변경'); else toast('이름 변경 실패: '+((j&&j.error)||''));
526
+ hydrate();
527
+ }).catch(function(){ toast('이름 변경 실패'); });
528
+ };
529
+ inp.addEventListener('keydown', function(e){
530
+ if(e.key==='Enter'){ e.preventDefault(); commit(); }
531
+ else if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); openDetail(runId); }
532
+ });
533
+ }
534
+ // 삭제는 되돌릴 수 없다 — 버튼이 두 걸음이 된다(네이티브 confirm 금지, DESIGN.md 규칙).
535
+ function armDelete(runId, btn){
536
+ if(btn.dataset.armed==='1'){
537
+ fetch('/api/runs/'+runId, {method:'DELETE'}).then(function(x){ return x.json(); }).then(function(j){
538
+ if(j&&j.ok){ toast('세션 삭제 \\u00b7 폴더는 그대로입니다'); closeDetail(); }
539
+ else toast('삭제 실패: '+((j&&(j.detail||j.error))||''));
540
+ hydrate();
541
+ }).catch(function(){ toast('삭제 실패'); });
542
+ return;
543
+ }
544
+ btn.dataset.armed='1';
545
+ btn.textContent='정말 삭제? (폴더는 보존)';
546
+ setTimeout(function(){ if(btn&&btn.dataset.armed==='1'){ btn.dataset.armed=''; btn.textContent='삭제'; } }, 4000);
547
+ }
548
+
549
+ // ── 클릭 위임 ────────────────────────────────────────────────────────
550
+ $('hudList').addEventListener('click', function(e){
551
+ var t=e.target.closest('#restToggle');
552
+ if(t){ restOpen=!restOpen; render(); return; }
553
+ var row=e.target.closest('.row'); if(!row) return;
554
+ openDetail(Number(row.dataset.run));
555
+ });
556
+ $('hudDetail').addEventListener('click', function(e){
557
+ var b=e.target.closest('button'); if(!b) return;
558
+ if(b.dataset.qr) return quickReply(Number(b.dataset.qr), b.dataset.qk);
559
+ if(b.dataset.steer) return steer(Number(b.dataset.steer));
560
+ if(b.dataset.stop) return stop(Number(b.dataset.stop));
561
+ if(b.dataset.diff) return loadDiff(Number(b.dataset.diff));
562
+ if(b.dataset.rename) return startRename(Number(b.dataset.rename), b);
563
+ if(b.dataset.del) return armDelete(Number(b.dataset.del), b);
564
+ });
565
+ $('hudDetail').addEventListener('keydown', function(e){
566
+ if(e.key==='Enter' && e.target.id==='steerInput'){ e.preventDefault(); if(sel!=null) steer(sel); }
567
+ });
568
+
569
+ // ── 키보드: j/k 이동 · enter 상세 · y/n/c 즉답 · esc 한 겹씩 닫기 ────────
570
+ function move(d){
571
+ if(!rowIds.length) return;
572
+ cur=(cur<0)?(d>0?0:rowIds.length-1):Math.max(0, Math.min(rowIds.length-1, cur+d));
573
+ curId=rowIds[cur];
574
+ Array.prototype.forEach.call($('hudList').querySelectorAll('.row'), function(el){
575
+ el.classList.toggle('on', Number(el.dataset.run)===curId);
576
+ });
577
+ var on=$('hudList').querySelector('.row.on'); if(on&&on.scrollIntoView) on.scrollIntoView({block:'nearest'});
578
+ }
579
+ document.addEventListener('keydown', function(e){
580
+ var tag=(e.target&&e.target.tagName)||'';
581
+ var typing=(tag==='INPUT'||tag==='TEXTAREA');
582
+ if(e.key==='Escape'){
583
+ if(typing){ e.target.blur(); return; }
584
+ e.preventDefault();
585
+ if(layout==='detail') closeDetail(); else if(layout==='list') collapse();
586
+ return;
587
+ }
588
+ if(typing) return;
589
+ if(layout==='pill'){
590
+ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); expand(); }
591
+ return;
592
+ }
593
+ if(e.key==='j'){ e.preventDefault(); move(1); return; }
594
+ if(e.key==='k'){ e.preventDefault(); move(-1); return; }
595
+ if(e.key==='Enter'){ e.preventDefault(); if(cur>=0) openDetail(rowIds[cur]); return; }
596
+ if(e.key==='y'||e.key==='n'||e.key==='c'){
597
+ var id=(cur>=0)?rowIds[cur]:sel;
598
+ if(id==null) return;
599
+ // 즉답은 **대기 중인 행에만** 선다 — 아무 run 에나 글자를 밀어 넣지 않는다.
600
+ if(agentStateOf(id)!=='waiting'){ toast('대기 중인 에이전트에만 답할 수 있습니다'); return; }
601
+ e.preventDefault();
602
+ quickReply(id, e.key==='y'?'approve':(e.key==='n'?'deny':'cont'));
603
+ }
604
+ });
605
+
606
+ // ── 데이터: /api/fleet + /ws (코크핏과 같은 델타 규율) ──────────────────
607
+ async function hydrate(){
608
+ try{
609
+ var d=await (await fetch('/api/fleet?view=all')).json();
610
+ fleet=d; runById={}; taskById={}; repoById={};
611
+ (d.runs||[]).forEach(function(r){ runById[r.id]=r; });
612
+ (d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
613
+ (d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
614
+ agentState={};
615
+ var asm=d.agentStates||{};
616
+ Object.keys(asm).forEach(function(k){ var s=asm[k]&&asm[k].state; if(s&&s!=='unknown') agentState[k]=s; });
617
+ if(sel!=null && !runById[sel]){ closeDetail(); return; }
618
+ render();
619
+ if(sel!=null && layout==='detail'){ paintTimeline(sel); paintDetailState(sel); }
620
+ }catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
621
+ }
622
+ // 상태 델타 한 건 = 그 자리만 칠한다(리하이드레이트 없음 — Part A 규율 그대로).
623
+ function paintDetailState(runId){
624
+ if(sel!==runId || layout!=='detail') return;
625
+ var s=agentStateOf(runId);
626
+ var chip=$('hudDetail').querySelector('[data-role=dchip]');
627
+ if(chip){ chip.className='d-chip '+asClass(s); chip.textContent=s||((runById[runId]&&runById[runId].status)||''); }
628
+ var now=$('hudDetail').querySelector('[data-role=dnow]');
629
+ if(now) now.textContent=actNowText(runId);
630
+ }
631
+ function paintAgentState(runId, state){
632
+ var prevBucket=bucketOf(runId);
633
+ if(state&&state!=='unknown') agentState[runId]=state; else delete agentState[runId];
634
+ if(bucketOf(runId)!==prevBucket){ render(); } // 갈래가 바뀌면 목록만 다시 그린다(가져오기 없음)
635
+ else {
636
+ var row=$('hudList').querySelector('.row[data-run="'+runId+'"] .st');
637
+ if(row) row.className='st '+asClass(agentStateOf(runId));
638
+ paintPill();
639
+ }
640
+ paintDetailState(runId);
641
+ }
642
+ function bucketOf(runId){
643
+ var r=runById[runId]; if(!r) return 'none';
644
+ var s=agentStateOf(runId), sess=runIsSession(runId);
645
+ var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
646
+ if(s==='waiting') return 'wait';
647
+ if(!sess && (s==='working'||live)) return 'run';
648
+ if(sess && r.tmuxWindow && r.status==='open') return 'rest';
649
+ if(s==='idle'||s==='exited') return 'rest';
650
+ return 'none';
651
+ }
652
+ var hydT=null;
653
+ function scheduleHydrate(){ if(hydT) return; hydT=setTimeout(function(){ hydT=null; hydrate(); }, 400); }
654
+ function wsConnect(){
655
+ var proto=location.protocol==='https:'?'wss':'ws';
656
+ var ws=new WebSocket(proto+'://'+location.host+'/ws');
657
+ ws.onclose=function(){ setTimeout(wsConnect, 1500); };
658
+ ws.onmessage=function(m){
659
+ var ev=null; try{ ev=JSON.parse(m.data); }catch(e){}
660
+ if(ev && ev.type==='agentstate'){ paintAgentState(ev.runId, ev.state); return; }
661
+ scheduleHydrate();
662
+ };
663
+ }
664
+
665
+ setLayout('pill');
666
+ hydrate();
667
+ wsConnect();
668
+ setInterval(function(){ if(layout!=='pill') render(); }, 30000); // 경과 시간만 천천히 갱신
669
+ })();
670
+ </script>
671
+ </body>
672
+ </html>`;
@@ -5,7 +5,7 @@ import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from '
5
5
  import { mkdir, copyFile, readFile, writeFile, rm, unlink } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import type { ChildProcess } from 'node:child_process';
8
- import { eq, inArray, and } from 'drizzle-orm';
8
+ import { eq, inArray, and, desc } from 'drizzle-orm';
9
9
  import { config } from './config';
10
10
  import { db } from './db';
11
11
  import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups, secrets } from './db/schema';
@@ -104,8 +104,17 @@ async function recordEvent(runId: number, kind: string, payload: string): Promis
104
104
  broadcast({ type: 'event', runId, kind, payload });
105
105
  }
106
106
 
107
+ /**
108
+ * run 레코드가 움직인 횟수. 오케스트레이션 워처(1.5초 간격)가 "다시 읽을 일이 생겼나"를
109
+ * 쿼리 없이 O(1) 로 판단하는 데 쓴다. setRun 은 run 의 수명 전환에서만 불린다(줄당이 아니다)
110
+ * — 그래서 이 카운터는 드물게 움직이고, 조용한 틱은 DB 를 아예 건드리지 않는다.
111
+ * 부모별로 좁히지 않고 하나로 두는 건 의도다: 넘치게 칠해 한 번 더 읽는 쪽이 놓치는 쪽보다 낫다.
112
+ */
113
+ let runRev = 0;
114
+
107
115
  async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInsert>): Promise<void> {
108
116
  await db.update(agentRuns).set(patch).where(eq(agentRuns.id, runId));
117
+ runRev++;
109
118
  broadcast({ type: 'run', runId, ...patch });
110
119
  }
111
120
 
@@ -277,14 +286,17 @@ export function startOrchWatch(runId: number, wtPath: string, real: boolean): No
277
286
  const dir = ppath.join(wtPath, '.coxpit');
278
287
  let last = '';
279
288
  let busy = false;
289
+ let seenRev = -1; // 현황을 마지막으로 읽은 시점의 runRev. -1 = 아직 한 번도 안 읽음(첫 틱은 읽는다)
280
290
  return setInterval(() => {
281
291
  if (busy) return;
282
292
  busy = true;
283
293
  void (async () => {
294
+ let consumed = false;
284
295
  try {
285
296
  const spawnPath = ppath.join(dir, 'spawn.json');
286
297
  const txt = await readFile(spawnPath, 'utf8').catch(() => null);
287
298
  if (txt !== null) {
299
+ consumed = true;
288
300
  await rm(spawnPath).catch(() => { /* consumed */ });
289
301
  try {
290
302
  const req = JSON.parse(txt) as unknown;
@@ -298,14 +310,22 @@ export function startOrchWatch(runId: number, wtPath: string, real: boolean): No
298
310
  await recordEvent(runId, 'error', 'spawn.json was not valid JSON — nothing spawned');
299
311
  }
300
312
  }
301
- // 현황 파일 — 내용이 바뀔 때만 다시 쓴다
302
- const subs = await listSubtasks(runId);
303
- if (subs.length) {
304
- const j = JSON.stringify(subs, null, 2);
305
- if (j !== last) {
306
- last = j;
307
- await mkdir(dir, { recursive: true });
308
- await writeFile(ppath.join(dir, 'subtasks.json'), j);
313
+ // 현황 파일 — **이번 틱에 뭔가 실제로 벌어졌을 때만** 읽고 쓴다.
314
+ // 조건 둘: spawn.json 을 소비했거나(), run 레코드가 움직였거나(runRev).
315
+ // 조용한 틱(대부분)에는 쿼리조차 돌지 않는다 — 1.5초마다 도는 워처라 이 절약이
316
+ // run 사는 동안 계속 쌓인다. rev 를 **읽기 전에 떠 두고 성공 후에 저장**하는 건 의도다:
317
+ // 실패하면 여전히 더러운 상태로 남아 다음 틱에 재시도되고, 읽는 동안 생긴 변화도 안 놓친다.
318
+ const rev = runRev;
319
+ if (consumed || rev !== seenRev) {
320
+ const subs = await listSubtasks(runId);
321
+ seenRev = rev;
322
+ if (subs.length) {
323
+ const j = JSON.stringify(subs, null, 2);
324
+ if (j !== last) {
325
+ last = j;
326
+ await mkdir(dir, { recursive: true });
327
+ await writeFile(ppath.join(dir, 'subtasks.json'), j);
328
+ }
309
329
  }
310
330
  }
311
331
  } catch { /* 워처 오류는 조용히 — 다음 틱에 재시도 */ }
@@ -351,6 +371,8 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
351
371
  void launchRun(run.id, real);
352
372
  runIds.push(run.id);
353
373
  }
374
+ // 새 자식이 생겼다 — 부모 워처가 다음 틱에 현황을 다시 읽도록 표시한다(HTTP 쌍둥이 경로 포함).
375
+ runRev++;
354
376
  await recordEvent(parentRunId, 'meta', JSON.stringify({ subtask: task.id, title: task.title, runs: runIds }));
355
377
  return { ok: true, detail: `spawned task #${task.id} (${runIds.length} run(s))`, taskId: task.id, runIds };
356
378
  }
@@ -830,6 +852,25 @@ export async function getScrollback(runId: number, lines: number): Promise<{ ok:
830
852
  return { ok: true, text: r.stdout };
831
853
  }
832
854
 
855
+ /**
856
+ * 살아 있는 tmux 페인에 한 줄 써 넣기 (v5.28 K) — getScrollback 의 **쓰기 쌍둥이**.
857
+ * HUD 가 코크핏을 열지 않고 "대기 중인 에이전트"에게 답하는 유일한 길이다(steer 는 정착한 run 전용).
858
+ * 타깃 규율은 capture-pane 과 똑같이 맞춘다: send-keys 도 '=' 접두사를 못 받는 tmux 가 있어
859
+ * 세션명을 그대로 쓴다. 사람이 친 것과 같게 하려고 Enter 를 따로 한 번 더 보낸다.
860
+ * 세션이 없거나(정리됨) 죽었으면 ok:false — 라우트가 409 로 돌려준다. 지어내지 않는다.
861
+ */
862
+ export async function sendRunInput(runId: number, text: string): Promise<{ ok: boolean; detail: string }> {
863
+ const info = await getRunTermInfo(runId);
864
+ if (!info) return { ok: false, detail: 'no terminal session' };
865
+ const r = await runShellOn(
866
+ info.machine,
867
+ `tmux send-keys -t ${shq(info.session)} ${shq(text)} Enter`,
868
+ 10000,
869
+ );
870
+ if (!r.ok) return { ok: false, detail: (r.stderr || r.stdout).trim().slice(0, 300) || 'send-keys failed' };
871
+ return { ok: true, detail: 'sent' };
872
+ }
873
+
833
874
  /**
834
875
  * 이 run 의 페인이 **지금 서 있는 폴더** (v5.28 D-fix).
835
876
  * 터미널이 찍은 상대경로를 열려면 worktree 루트가 아니라 페인의 cwd 가 기준이어야 한다 —
@@ -946,11 +987,16 @@ interface OutputsManifestItem { path?: string; type?: string; title?: string }
946
987
 
947
988
  /** run 의 최종 답변 텍스트 — result 이벤트(payload JSON) 우선, 없으면 exitSummary. */
948
989
  async function runAnswerText(run: typeof agentRuns.$inferSelect): Promise<string> {
949
- const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
950
- for (let i = evs.length - 1; i >= 0; i--) {
951
- if (evs[i]!.kind !== 'result') continue;
990
+ // result 이벤트만, 최신 순으로 — 전량 로드 후 뒤에서 훑던 것과 결과는 같고 읽는 행은 훨씬 적다
991
+ // (run result 는 보통 한 줄, resume 한 run 이면 몇 줄). LIMIT 1 쓰지 않는 의도다:
992
+ // 가장 최신 result 의 payload 에 쓸 만한 result 문자열이 없으면 예전 result 로 내려가야 하고,
993
+ // 그게 원래 동작이다 — 같은 답을 내는 것이 이 변경의 조건이다.
994
+ const evs = await db.select().from(agentEvents)
995
+ .where(and(eq(agentEvents.runId, run.id), eq(agentEvents.kind, 'result')))
996
+ .orderBy(desc(agentEvents.id));
997
+ for (const e of evs) {
952
998
  try {
953
- const o = JSON.parse(evs[i]!.payload) as { result?: string };
999
+ const o = JSON.parse(e.payload) as { result?: string };
954
1000
  if (typeof o.result === 'string' && o.result.trim()) return o.result.trim();
955
1001
  } catch { /* 비-JSON result — exitSummary 로 폴백 */ }
956
1002
  }
package/src/server.ts CHANGED
@@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto';
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
9
9
  import websocket from '@fastify/websocket';
10
- import { eq, inArray, and, like, desc } from 'drizzle-orm';
10
+ import { eq, inArray, and, like, desc, asc, sql } from 'drizzle-orm';
11
11
  import { authGate } from './auth';
12
12
  import { loginPageHTML } from './login';
13
13
  import {
@@ -21,7 +21,7 @@ import { db } from './db';
21
21
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
22
22
  import { BOOKMARKLET_JS } from './design';
23
23
  import { runShellOn, shq } from './exec';
24
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, verifyBase, openSessionAt, deleteSession, getScrollback, getRunPwd, getSessionChat } from './orchestrator';
24
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, verifyBase, openSessionAt, deleteSession, getScrollback, sendRunInput, getRunPwd, getSessionChat } from './orchestrator';
25
25
  import { openTerm } from './term';
26
26
  import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates, spottedPorts } from './agentstate';
27
27
  import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
@@ -30,6 +30,7 @@ import { getProvider, listProviders } from './providers';
30
30
  import { remoteState, setServe, setFunnel } from './remote';
31
31
  import { BOARD_HTML } from './board';
32
32
  import { COCKPIT_HTML } from './cockpit';
33
+ import { HUD_HTML } from './hud';
33
34
  import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile, withinFilesRoot } from './files';
34
35
  import { ensureWorkDoc, readWorkDoc, writeWorkDoc, removeWorkDoc, workDocPath, workDocSize } from './workdoc';
35
36
 
@@ -47,6 +48,12 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
47
48
  'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
48
49
  };
49
50
 
51
+ // 버전 치환은 **모듈 로드 때 한 번** — config.version 은 부트 후 변하지 않고, cockpit.ts 는
52
+ // 281KB 짜리 한 문자열이다. 요청마다 전역 정규식으로 다시 훑던 것을 상수로 굳혔다
53
+ // (보드는 원래 상수를 그대로 내보낸다 — 세 페이지가 같은 방식이 됐다).
54
+ const COCKPIT_PAGE = COCKPIT_HTML.replace(/__COXPIT_VER__/g, config.version);
55
+ const HUD_PAGE = HUD_HTML.replace(/__COXPIT_VER__/g, config.version);
56
+
50
57
  // ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
51
58
  const escH = (x: unknown): string =>
52
59
  String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]!));
@@ -250,7 +257,10 @@ export async function buildServer(): Promise<FastifyInstance> {
250
257
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
251
258
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
252
259
  // 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
253
- app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_HTML.replace(/__COXPIT_VER__/g, config.version)));
260
+ app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_PAGE));
261
+ // HUD(v5.28 K) — 플릿을 작게 다시 내놓는 한 장. 데스크톱의 떠 있는 작은 창이 이걸 띄운다.
262
+ // 서빙되는 **페이지**라 /cockpit 과 같은 게이트 뒤다(무인증 예외 아님 — 헬스가 아니다).
263
+ app.get('/hud', async (_req, reply) => reply.type('text/html').send(HUD_PAGE));
254
264
 
255
265
  // ─── 접근키 인증(access-key) ────────────────────────────────────
256
266
  // 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
@@ -366,17 +376,31 @@ export async function buildServer(): Promise<FastifyInstance> {
366
376
  const activeTasks = allTasks.filter((t) => t.status !== 'closed');
367
377
  const closedCount = allTasks.length - activeTasks.length;
368
378
  const ts = view === 'all' ? allTasks : activeTasks;
369
- const taskIds = new Set(ts.map((t) => t.id));
370
- const allRuns = await db.select().from(agentRuns);
371
- const rns = view === 'all' ? allRuns : allRuns.filter((r) => taskIds.has(r.taskId));
379
+ const taskIds = ts.map((t) => t.id);
380
+ // run **보는 뷰만** 가져온다 — 전량 로드 후 JS 필터가 아니라 SQL WHERE.
381
+ // 정렬을 명시하는 필터가 끼어도 클라이언트가 받던 id 오름차순을 그대로 지키기 위함이다.
382
+ const rns: Array<typeof agentRuns.$inferSelect> = view === 'all'
383
+ ? await db.select().from(agentRuns).orderBy(asc(agentRuns.id))
384
+ : taskIds.length
385
+ ? await db.select().from(agentRuns).where(inArray(agentRuns.taskId, taskIds)).orderBy(asc(agentRuns.id))
386
+ : [];
372
387
  const runIds = rns.map((r) => r.id);
373
- // 이벤트는 대상 run 으로 스코프한 로드(전량 로드 후 슬라이스 = 고치려는 그 버그).
374
- const evs = runIds.length ? await db.select().from(agentEvents).where(inArray(agentEvents.runId, runIds)) : [];
388
+ // 이벤트는 run 최근 EVENT_CAP 개만 **SQL 에서** 잘라 온다. 전량 로드 후 JS 슬라이스가
389
+ // 정확히 고치려던 낭비다(활성 run 모든 이벤트 행을 메모리에 올렸다).
390
+ // 창 함수로 run 별 역순 번호를 매겨 상한 안쪽만 남기고, 클라이언트가 기대하는 id 오름차순으로 낸다.
391
+ const evs: Array<{ run_id: number; kind: string; payload: string }> = runIds.length
392
+ ? await db.all<{ run_id: number; kind: string; payload: string }>(sql`
393
+ SELECT id, run_id, kind, payload FROM (
394
+ SELECT id, run_id, kind, payload,
395
+ ROW_NUMBER() OVER (PARTITION BY run_id ORDER BY id DESC) AS rn
396
+ FROM agent_events WHERE ${inArray(agentEvents.runId, runIds)}
397
+ ) WHERE rn <= ${EVENT_CAP} ORDER BY run_id ASC, id ASC`)
398
+ : [];
375
399
  const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
376
400
  for (const e of evs) {
377
- const arr = byRun.get(e.runId) ?? [];
401
+ const arr = byRun.get(e.run_id) ?? [];
378
402
  arr.push({ kind: e.kind, payload: e.payload });
379
- byRun.set(e.runId, arr);
403
+ byRun.set(e.run_id, arr);
380
404
  }
381
405
  const taskOut = new Map(ts.map((t) => [t.id, t.outputs]));
382
406
  return {
@@ -384,7 +408,8 @@ export async function buildServer(): Promise<FastifyInstance> {
384
408
  runs: rns.map((r) => {
385
409
  const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
386
410
  // real 은 언제나 불리언으로 나간다 — 클라이언트는 real===false 하나만 보고 dry 칩을 그린다.
387
- return { ...r, real: !!r.real, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
411
+ // events 이미 SQL 에서 EVENT_CAP 으로 잘려 왔다 여기서 자르지 않는다.
412
+ return { ...r, real: !!r.real, events: byRun.get(r.id) ?? [], noop: sig.noop, noopReason: sig.reason };
388
413
  }),
389
414
  counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
390
415
  // 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
@@ -493,9 +518,22 @@ export async function buildServer(): Promise<FastifyInstance> {
493
518
  const grpTitle = new Map((await db.select().from(taskGroups)).map((g) => [g.id, g.title]));
494
519
  const total0 = closed.length;
495
520
  const page = closed.slice(offset, offset + limit);
521
+ // 페이지에 실릴 태스크의 run 을 **한 번에** 긁어 taskId 로 묶는다.
522
+ // 태스크마다 한 방씩 쏘던 N+1(50행 페이지 = 51 쿼리)을 한 쿼리로 접었다.
523
+ const pageIds = page.map((t) => t.id);
524
+ const runsByTask = new Map<number, Array<typeof agentRuns.$inferSelect>>();
525
+ if (pageIds.length) {
526
+ const pageRuns = await db.select().from(agentRuns)
527
+ .where(inArray(agentRuns.taskId, pageIds)).orderBy(asc(agentRuns.id));
528
+ for (const r of pageRuns) {
529
+ const arr = runsByTask.get(r.taskId) ?? [];
530
+ arr.push(r);
531
+ runsByTask.set(r.taskId, arr);
532
+ }
533
+ }
496
534
  const rows = [];
497
535
  for (const t of page) {
498
- const rs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
536
+ const rs = runsByTask.get(t.id) ?? [];
499
537
  if (q.status && !rs.some((r) => r.status === q.status)) continue;
500
538
  rows.push({
501
539
  taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
@@ -1269,6 +1307,25 @@ export async function buildServer(): Promise<FastifyInstance> {
1269
1307
  return res;
1270
1308
  });
1271
1309
 
1310
+ // 살아 있는 run 의 tmux 에 한 줄 써 넣기 (v5.28 K) — 위 scrollback 의 **쓰기 쌍둥이**.
1311
+ // HUD 가 코크핏을 열지 않고 "대기 중인 에이전트"에게 답하는 길이다(steer 는 정착한 run 전용이라
1312
+ // 프롬프트 앞에 서 있는 run 에는 쓸 수 없다). 보내는 것은 언제나 사람이 고른 문자열이다 —
1313
+ // 서버는 에이전트의 질문을 읽지도, 답을 고르지도 않는다.
1314
+ // 세션이 없으면(정리됐거나 애초에 없음) 409, 모르는 run 은 404.
1315
+ app.post('/api/runs/:id/input', async (req, reply) => {
1316
+ const id = Number((req.params as { id: string }).id);
1317
+ if (!Number.isInteger(id)) return reply.code(400).send({ error: 'bad id' });
1318
+ const b = (req.body ?? {}) as { text?: string };
1319
+ const text = String(b.text ?? '');
1320
+ if (!text.trim()) return reply.code(400).send({ error: 'text required' });
1321
+ if (text.length > 4000) return reply.code(400).send({ error: 'text too long (max 4000)' });
1322
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1323
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1324
+ const res = await sendRunInput(id, text);
1325
+ if (!res.ok) return reply.code(409).send(res);
1326
+ return res;
1327
+ });
1328
+
1272
1329
  // 페인이 지금 서 있는 폴더 (v5.28 D-fix) — 터미널이 찍은 상대경로를 무엇 기준으로 풀지.
1273
1330
  // 못 알아내면 200 + { ok:false, pwd:'' } — 클라이언트가 worktree 로 폴백한다(지어낸 경로는 없다).
1274
1331
  app.get('/api/runs/:id/pwd', async (req, reply) => {