coxpit 6.3.0 → 6.3.3
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/README.md +1 -0
- package/package.json +1 -1
- package/src/.!31514!cockpit.ts +0 -0
- package/src/activity.ts +29 -0
- package/src/auth.ts +4 -1
- package/src/claudeshim.ts +110 -0
- package/src/cockpit.ts +322 -59
- package/src/config.ts +7 -0
- package/src/db/index.ts +21 -0
- package/src/db/schema.ts +5 -0
- package/src/hud.ts +672 -0
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +265 -35
- package/src/server.ts +94 -15
- package/src/uistate.ts +65 -0
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)">−</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 · enter · y/n/c · 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
188
|
+
function escA(s){ return esc(s).replace(/"/g,'"').replace(/'/g,'''); }
|
|
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>`;
|