coxpit 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/board.ts ADDED
@@ -0,0 +1,602 @@
1
+ // 데몬이 서빙하는 단일 페이지 플릿 콘솔(빌드 스텝 0, 자가완결).
2
+ // /api/fleet 로 하이드레이트 → /ws 구독 델타 → run 상세(타임라인·diff·터미널)·비교/머지.
3
+ export const BOARD_HTML = /* html */ `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+ <title>coxpit · fleet</title>
9
+ <link rel="stylesheet" href="/vendor/xterm.css" />
10
+ <style>
11
+ :root{
12
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
13
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
14
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
15
+ --s-pending:#8792a2; --s-preparing:#d6a249; --s-running:#55a7e0;
16
+ --s-done:#58b368; --s-failed:#e25b67; --s-error:#e25b67; --s-stopped:#b58be0; --s-merged:#4ec9b0;
17
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
18
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
19
+ --r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
20
+ }
21
+ *{box-sizing:border-box}
22
+ html,body{height:100%}
23
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
24
+ -webkit-font-smoothing:antialiased}
25
+ ::selection{background:var(--brand-dim)}
26
+ ::-webkit-scrollbar{width:9px;height:9px}
27
+ ::-webkit-scrollbar-thumb{background:#252c3a;border-radius:5px;border:2px solid var(--bg)}
28
+ ::-webkit-scrollbar-track{background:transparent}
29
+ button{font-family:var(--sans)}
30
+ :focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
31
+
32
+ /* ── header ─────────────────────────────── */
33
+ header{display:flex;align-items:center;gap:14px;height:54px;padding:0 20px;
34
+ border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);backdrop-filter:blur(8px);
35
+ position:sticky;top:0;z-index:10}
36
+ .brand{display:flex;align-items:baseline;gap:9px;font-family:var(--mono)}
37
+ .brand .mark{color:var(--brand);font-size:15px;font-weight:700;letter-spacing:.01em}
38
+ .brand .sub{color:var(--faint);font-size:11px;text-transform:uppercase;letter-spacing:.14em}
39
+ .ws{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:var(--muted);
40
+ padding:4px 10px;border:1px solid var(--line);border-radius:999px;background:var(--surface)}
41
+ .dot{width:7px;height:7px;border-radius:50%;background:var(--s-failed);transition:background .3s}
42
+ .dot.on{background:var(--s-done)}
43
+ .machines{margin-left:auto;display:flex;gap:7px;flex-wrap:wrap}
44
+ .mchip{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;padding:4px 11px;
45
+ border:1px solid var(--line);border-radius:999px;color:var(--muted);background:var(--surface)}
46
+ .mchip b{color:var(--ink);font-weight:500}
47
+ .mdot{width:6px;height:6px;border-radius:50%;background:var(--faint)}
48
+ .mdot.on{background:var(--s-done)}
49
+
50
+ /* ── layout ─────────────────────────────── */
51
+ .layout{display:grid;grid-template-columns:318px 1fr;min-height:calc(100vh - 55px)}
52
+ aside{border-right:1px solid var(--line);padding:20px 18px;background:var(--surface);
53
+ display:flex;flex-direction:column;gap:26px}
54
+ main{padding:20px;overflow:auto}
55
+ .sect{display:flex;flex-direction:column;gap:10px}
56
+ .sect-label{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--faint);margin:0}
57
+ .repo{font-family:var(--mono);font-size:12px;padding:9px 11px;border:1px solid var(--line);
58
+ border-radius:var(--r-ctl);background:var(--surface2)}
59
+ .repo .nm{color:var(--ink)}
60
+ .repo .br{color:var(--brand);margin-left:6px;font-size:11px}
61
+ .repo .path{color:var(--faint);font-size:10.5px;word-break:break-all;margin-top:2px}
62
+ form{display:flex;flex-direction:column;gap:8px}
63
+ input,select,textarea{width:100%;background:#0e1118;color:var(--ink);border:1px solid var(--line);
64
+ border-radius:var(--r-ctl);padding:8px 10px;font-family:var(--mono);font-size:12px;
65
+ transition:border-color .15s}
66
+ input:focus,select:focus,textarea:focus{border-color:rgba(78,201,176,.55);outline:none}
67
+ input::placeholder,textarea::placeholder{color:var(--faint)}
68
+ textarea{resize:vertical;min-height:56px;line-height:1.5}
69
+ .row{display:flex;gap:8px}
70
+ .row>*{flex:1}
71
+ .row .narrow{flex:0 0 64px}
72
+ .check{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);cursor:pointer;user-select:none}
73
+ .check input{width:auto;accent-color:var(--brand)}
74
+
75
+ /* ── buttons ────────────────────────────── */
76
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;cursor:pointer;
77
+ border-radius:var(--r-ctl);padding:8px 14px;font-size:13px;font-weight:600;
78
+ background:var(--brand);color:var(--brand-ink);transition:filter .15s,transform .05s}
79
+ .btn:hover{filter:brightness(1.08)}
80
+ .btn:active{transform:translateY(1px)}
81
+ .btn-ghost{background:transparent;color:var(--muted);border:1px solid var(--line);font-weight:500}
82
+ .btn-ghost:hover{color:var(--ink);border-color:var(--line-hi);filter:none}
83
+ .btn-danger{background:transparent;color:var(--s-failed);border:1px solid rgba(226,91,103,.45);font-weight:500}
84
+ .btn-danger:hover{background:rgba(226,91,103,.1);filter:none}
85
+ .btn[disabled],.btn-ghost[disabled]{opacity:.4;cursor:not-allowed}
86
+ .btn.sm,.btn-ghost.sm,.btn-danger.sm{padding:5px 11px;font-size:12px}
87
+
88
+ /* ── run cards ──────────────────────────── */
89
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
90
+ .card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface);
91
+ overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
92
+ transition:border-color .18s,transform .18s,box-shadow .18s}
93
+ .card:hover{border-color:#323b4e;transform:translateY(-1px);box-shadow:var(--shadow)}
94
+ .card-h{display:flex;align-items:center;gap:10px;padding:12px 14px}
95
+ .card-h .rid{font-family:var(--mono);font-size:11px;color:var(--faint)}
96
+ .card-h .title{font-weight:600;font-size:13px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
97
+ .card-h .closed{color:var(--faint);font-weight:400;font-size:12px}
98
+ .chip{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:10.5px;
99
+ padding:3px 9px;border-radius:999px;text-transform:uppercase;letter-spacing:.06em;
100
+ border:1px solid;background:transparent}
101
+ .chip i{width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
102
+ .chip.running i{animation:pulse 1.2s ease-in-out infinite}
103
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
104
+ @media (prefers-reduced-motion:reduce){.chip.running i{animation:none}.card:hover{transform:none}}
105
+ .meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
106
+ .meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
107
+ .log{border-top:1px solid var(--line);background:#0e1118;padding:10px 14px;font-family:var(--mono);
108
+ font-size:11px;height:150px;overflow:auto;display:flex;flex-direction:column;gap:4px}
109
+ .ev{display:flex;gap:9px;align-items:baseline;min-width:0}
110
+ .ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
111
+ .ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
112
+ .empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:64px 24px;text-align:center;
113
+ display:flex;flex-direction:column;gap:10px;align-items:center}
114
+ .empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
115
+ .flash{animation:flash .5s ease}
116
+ @keyframes flash{from{border-color:rgba(78,201,176,.6)}to{border-color:var(--line)}}
117
+
118
+ /* ── overlays ───────────────────────────── */
119
+ .overlay{position:fixed;inset:0;background:rgba(5,7,10,.66);backdrop-filter:blur(3px);display:none;z-index:20}
120
+ .overlay.open{display:flex;align-items:center;justify-content:center;padding:26px}
121
+ .modal{width:min(1020px,100%);max-height:92vh;background:var(--surface);border:1px solid var(--line);
122
+ border-radius:14px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--shadow)}
123
+ .modal.wide{width:min(1460px,100%)}
124
+ .modal.term{width:min(1200px,100%);height:min(760px,92vh)}
125
+ .modal-h{display:flex;align-items:center;gap:10px;padding:13px 18px;border-bottom:1px solid var(--line)}
126
+ .modal-h .rid{font-family:var(--mono);font-size:12px;color:var(--faint)}
127
+ .modal-h .title{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
128
+ .modal-h .x{background:transparent;border:none;color:var(--muted);font-size:19px;cursor:pointer;
129
+ padding:2px 9px;border-radius:6px;line-height:1}
130
+ .modal-h .x:hover{color:var(--ink);background:var(--surface2)}
131
+ .modal-b{display:grid;grid-template-columns:1fr 1fr;overflow:hidden;flex:1;min-height:0}
132
+ .pane{display:flex;flex-direction:column;min-height:0;border-right:1px solid var(--line)}
133
+ .pane:last-child{border-right:none}
134
+ .pane-h{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--faint);
135
+ padding:9px 16px;border-bottom:1px solid var(--line);background:var(--surface2)}
136
+ .pane-c{overflow:auto;padding:12px 16px;font-family:var(--mono);font-size:11px;flex:1;min-height:0}
137
+ .tl .ev{margin-bottom:6px}
138
+ .tl .ev .t{white-space:normal;word-break:break-word}
139
+ pre.diff{margin:0;white-space:pre-wrap;word-break:break-all;line-height:1.5;font-family:var(--mono)}
140
+ .dl-add{color:var(--s-done)} .dl-del{color:var(--s-failed)} .dl-hunk{color:var(--s-running)}
141
+ .dl-file{color:var(--brand);font-weight:600}
142
+ .modal-f{display:flex;gap:8px;padding:12px 18px;border-top:1px solid var(--line);align-items:center}
143
+ .modal-f .spacer{flex:1}
144
+
145
+ /* ── compare ────────────────────────────── */
146
+ .cmp{display:flex;overflow-x:auto;flex:1;min-height:0}
147
+ .cmp-col{min-width:360px;flex:1;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
148
+ .cmp-col:last-child{border-right:none}
149
+ .cmp-h{display:flex;align-items:center;gap:9px;padding:11px 14px;border-bottom:1px solid var(--line)}
150
+ .cmp-h .rid{font-family:var(--mono);font-size:12px;color:var(--faint)}
151
+ .cmp-h .files{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
152
+ .cmp-meta{font-family:var(--mono);font-size:11px;color:var(--faint);padding:8px 14px;border-bottom:1px solid var(--line);
153
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:var(--surface2)}
154
+ .cmp-diff{overflow:auto;padding:11px 14px;flex:1;min-height:0}
155
+ .cmp-f{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center}
156
+ .cmp-f .msg{font-family:var(--mono);font-size:11px;color:var(--muted);flex:1;
157
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
158
+
159
+ /* ── terminal ───────────────────────────── */
160
+ .term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
161
+ #xterm{width:100%;height:100%}
162
+ .term-hint{font-family:var(--mono);font-size:11px;color:var(--faint)}
163
+
164
+ @media (max-width:860px){
165
+ .layout{grid-template-columns:1fr}
166
+ aside{border-right:none;border-bottom:1px solid var(--line)}
167
+ .modal-b{grid-template-columns:1fr}
168
+ .pane{border-right:none;border-bottom:1px solid var(--line)}
169
+ }
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <header>
174
+ <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
175
+ <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
176
+ <div class="machines" id="machines"></div>
177
+ </header>
178
+ <div class="layout">
179
+ <aside>
180
+ <div class="sect">
181
+ <p class="sect-label">Repositories</p>
182
+ <div id="repos" style="display:flex;flex-direction:column;gap:6px"></div>
183
+ <form id="repoForm">
184
+ <div class="row">
185
+ <select id="repoMachine" class="narrow" style="flex:0 0 96px"></select>
186
+ <input id="repoPath" placeholder="/abs/path/to/repo" />
187
+ </div>
188
+ <button class="btn-ghost sm" type="submit">Register repo</button>
189
+ </form>
190
+ </div>
191
+ <div class="sect">
192
+ <p class="sect-label">Launch agents</p>
193
+ <form id="taskForm">
194
+ <select id="taskRepo"></select>
195
+ <input id="taskTitle" placeholder="Task title" />
196
+ <textarea id="taskPrompt" placeholder="Prompt for the agents…"></textarea>
197
+ <select id="taskCapture"><option value="">no design capture</option></select>
198
+ <div class="row">
199
+ <input id="taskCount" class="narrow" type="number" min="1" max="8" value="2" title="number of agents" />
200
+ <button class="btn" type="submit">Run fleet</button>
201
+ </div>
202
+ <label class="check"><input type="checkbox" id="taskReal" /> real agent · spends credits</label>
203
+ </form>
204
+ </div>
205
+ <div class="sect">
206
+ <p class="sect-label">Design captures</p>
207
+ <div id="captures" style="display:flex;flex-direction:column;gap:6px"></div>
208
+ <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px"
209
+ title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
210
+ <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
211
+ With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
212
+ </div>
213
+ </aside>
214
+ <main>
215
+ <div class="grid" id="grid"></div>
216
+ <div class="empty" id="empty">
217
+ <span class="glyph">▚▞▚</span>
218
+ <span>No runs yet</span>
219
+ <span style="color:#3d4657">register a repo, write a task, hit Run fleet</span>
220
+ </div>
221
+ </main>
222
+ </div>
223
+
224
+ <div class="overlay" id="overlay">
225
+ <div class="modal">
226
+ <div class="modal-h">
227
+ <span class="rid" id="mRid"></span>
228
+ <span class="title" id="mTitle"></span>
229
+ <span class="chip" id="mChip"><i></i><span id="mChipTxt"></span></span>
230
+ <button class="x" id="mClose" aria-label="close">×</button>
231
+ </div>
232
+ <div class="modal-b">
233
+ <div class="pane">
234
+ <div class="pane-h">Timeline</div>
235
+ <div class="pane-c tl" id="mTimeline"></div>
236
+ </div>
237
+ <div class="pane">
238
+ <div class="pane-h">Diff <span id="mStat" style="text-transform:none;letter-spacing:0"></span></div>
239
+ <div class="pane-c"><pre class="diff" id="mDiff">loading…</pre></div>
240
+ </div>
241
+ </div>
242
+ <div class="modal-f">
243
+ <button class="btn-ghost sm" id="mTerm">Terminal</button>
244
+ <button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
245
+ <button class="btn-ghost sm" id="mCompare">Compare runs</button>
246
+ <span class="spacer"></span>
247
+ <button class="btn-danger sm" id="mStop">Stop</button>
248
+ <button class="btn-ghost sm" id="mCleanup">Cleanup</button>
249
+ <button class="btn-ghost sm" id="mCloseTask">Close task</button>
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <div class="overlay" id="cmpOverlay">
255
+ <div class="modal wide">
256
+ <div class="modal-h">
257
+ <span class="title" id="cmpTitle">Compare</span>
258
+ <button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
259
+ <button class="x" id="cmpClose" aria-label="close">×</button>
260
+ </div>
261
+ <div class="cmp" id="cmpBody"></div>
262
+ </div>
263
+ </div>
264
+
265
+ <div class="overlay" id="termOverlay">
266
+ <div class="modal term">
267
+ <div class="modal-h">
268
+ <span class="rid" id="termRid"></span>
269
+ <span class="title" id="termTitle">terminal</span>
270
+ <span class="term-hint">tmux session · Ctrl-b d detaches</span>
271
+ <button class="x" id="termClose" aria-label="close">×</button>
272
+ </div>
273
+ <div class="term-body"><div id="xterm"></div></div>
274
+ </div>
275
+ </div>
276
+
277
+ <script src="/vendor/xterm.js"></script>
278
+ <script src="/vendor/addon-fit.js"></script>
279
+ <script>
280
+ const runs = new Map(); // runId -> run object
281
+ const tasks = new Map(); // taskId -> task
282
+ let repos = [], machines = [], captures = [];
283
+
284
+ const $ = (id) => document.getElementById(id);
285
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
286
+ const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
287
+
288
+ function summarize(kind, payload){
289
+ try{
290
+ const o = JSON.parse(payload);
291
+ if (o.type === 'assistant' && o.message){
292
+ const c = (o.message.content||[]).map(x => x.type==='text' ? x.text : (x.type==='tool_use' ? '['+x.name+']' : '')).join(' ');
293
+ return c || '(assistant)';
294
+ }
295
+ if (o.type === 'assistant' && o.text) return o.text;
296
+ if (o.type === 'result') return o.result || '(result)';
297
+ if (o.type === 'user') return '(tool result)';
298
+ if (o.type === 'system') return o.subtype || 'system';
299
+ if (kind === 'meta') return 'worktree ' + (o.worktree||'');
300
+ return kind;
301
+ }catch{ return payload; }
302
+ }
303
+ function diffHTML(text){
304
+ if (!text.trim()) return '<span style="color:var(--faint)">no changes</span>';
305
+ return text.split('\\n').map(l=>{
306
+ const e = esc(l);
307
+ if (l.startsWith('diff --git')||l.startsWith('+++')||l.startsWith('---')) return '<span class="dl-file">'+e+'</span>';
308
+ if (l.startsWith('@@')) return '<span class="dl-hunk">'+e+'</span>';
309
+ if (l.startsWith('+')) return '<span class="dl-add">'+e+'</span>';
310
+ if (l.startsWith('-')) return '<span class="dl-del">'+e+'</span>';
311
+ return e;
312
+ }).join('\\n');
313
+ }
314
+ function chipHTML(status){
315
+ const s = status||'pending';
316
+ return '<span class="chip '+(s==='running'?'running':'')+'" style="color:'+statusColor(s)+';border-color:'+statusColor(s)+'">'
317
+ + '<i></i>'+esc(s)+'</span>';
318
+ }
319
+
320
+ function render(){
321
+ const list = [...runs.values()].sort((a,b)=>b.id-a.id);
322
+ $('empty').style.display = list.length ? 'none' : 'flex';
323
+ $('grid').innerHTML = list.map(cardHTML).join('');
324
+ }
325
+ function cardHTML(r){
326
+ const task = tasks.get(r.taskId);
327
+ const closed = task && task.status==='closed';
328
+ const title = (task ? esc(task.title) : ('task ' + (r.taskId ?? '?')))
329
+ + (closed ? ' <span class="closed">· closed</span>' : '');
330
+ const evs = (r.events||[]).slice(-8).map(e =>
331
+ '<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload)).slice(0,140)+'</span></div>'
332
+ ).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
333
+ return '<div class="card" id="card-'+r.id+'">'
334
+ + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'+chipHTML(r.status)+'</div>'
335
+ + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
336
+ + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
337
+ + '<span>'+esc(r.agent||'')+'</span></div>'
338
+ + '<div class="log">'+evs+'</div></div>';
339
+ }
340
+ function flash(id){ const el=$('card-'+id); if(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); } }
341
+
342
+ function upsertRun(patch){
343
+ const cur = runs.get(patch.runId ?? patch.id) || { id: patch.runId ?? patch.id, events: [] };
344
+ Object.assign(cur, patch, { id: cur.id, events: cur.events });
345
+ if (patch.runId) cur.id = patch.runId;
346
+ runs.set(cur.id, cur);
347
+ }
348
+
349
+ async function hydrate(){
350
+ const r = await fetch('/api/fleet').then(x=>x.json());
351
+ machines = r.machines||[]; repos = r.repos||[]; captures = r.captures||[];
352
+ tasks.clear();
353
+ (r.tasks||[]).forEach(t => tasks.set(t.id, t));
354
+ runs.clear();
355
+ (r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
356
+ paintSidebar(); render();
357
+ }
358
+ function paintSidebar(){
359
+ $('machines').innerHTML = machines.map(m =>
360
+ '<span class="mchip"><span class="mdot '+(m.online?'on':'')+'"></span><b>'+esc(m.slug)+'</b></span>').join('');
361
+ $('repos').innerHTML = repos.map(r =>
362
+ '<div class="repo"><span class="nm">'+esc(r.name)+'</span><span class="br">'+esc(r.defaultBranch)+'</span>'
363
+ + '<div class="path">'+esc(r.path)+'</div></div>').join('')
364
+ || '<div class="repo" style="color:var(--faint)">none registered</div>';
365
+ $('repoMachine').innerHTML = machines.map(m=>'<option value="'+esc(m.slug)+'">'+esc(m.slug)+'</option>').join('');
366
+ $('taskRepo').innerHTML = repos.map(r=>'<option value="'+r.id+'">'+esc(r.name)+'</option>').join('');
367
+ const capSel = $('taskCapture');
368
+ const cur = capSel.value;
369
+ capSel.innerHTML = '<option value="">no design capture</option>' + captures.map(c=>
370
+ '<option value="'+c.id+'">#'+c.id+' '+esc((c.selector||'').slice(0,40))+'</option>').join('');
371
+ capSel.value = cur;
372
+ $('captures').innerHTML = captures.map(c=>
373
+ '<div class="repo"><span class="nm">'+esc((c.selector||'?').slice(0,46))+'</span>'
374
+ + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
375
+ + '<div class="path">'+esc((c.url||'').slice(0,70))+'</div></div>').join('')
376
+ || '<div class="repo" style="color:var(--faint)">none captured</div>';
377
+ $('bmk').href = "javascript:(function(){var s=document.createElement('script');s.src='"
378
+ + location.origin + "/design/bookmarklet.js';document.body.appendChild(s)})()";
379
+ }
380
+ $('captures').addEventListener('click', async (e)=>{
381
+ const b = e.target.closest('button[data-delcap]'); if(!b) return;
382
+ await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
383
+ hydrate();
384
+ });
385
+
386
+ function connectWS(){
387
+ const proto = location.protocol==='https:'?'wss':'ws';
388
+ const ws = new WebSocket(proto+'://'+location.host+'/ws');
389
+ ws.onopen = ()=>{ $('wsdot').classList.add('on'); $('wstext').textContent='live'; };
390
+ ws.onclose = ()=>{ $('wsdot').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(connectWS,1500); };
391
+ ws.onmessage = (m)=>{
392
+ let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
393
+ if (ev.type==='run'){
394
+ const known = runs.has(ev.runId ?? ev.id);
395
+ upsertRun(ev);
396
+ if (!known && ev.taskId==null){ hydrate(); return; }
397
+ render(); flash(ev.runId ?? ev.id); paintModal();
398
+ if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
399
+ if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
400
+ } else if (ev.type==='event'){
401
+ const r = runs.get(ev.runId); if(!r){ hydrate(); return; }
402
+ r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
403
+ render(); flash(ev.runId); paintModal();
404
+ } else if (ev.type==='task'){
405
+ const t = tasks.get(ev.taskId);
406
+ if (t){ t.status = ev.status; render(); paintModal(); } else { hydrate(); }
407
+ } else if (ev.type==='capture'){
408
+ captures.push(ev.capture); paintSidebar();
409
+ }
410
+ };
411
+ }
412
+
413
+ /* ── run detail modal ── */
414
+ let openRunId = null;
415
+ function paintModal(){
416
+ if (openRunId==null) return;
417
+ const r = runs.get(openRunId); if(!r) return;
418
+ const task = tasks.get(r.taskId);
419
+ $('mRid').textContent = 'r'+r.id;
420
+ $('mTitle').textContent = task ? task.title : 'task '+(r.taskId??'?');
421
+ const chip = $('mChip');
422
+ chip.className = 'chip ' + (r.status==='running'?'running':'');
423
+ chip.style.color = statusColor(r.status);
424
+ chip.style.borderColor = statusColor(r.status);
425
+ $('mChipTxt').textContent = r.status||'pending';
426
+ $('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
427
+ $('mTimeline').innerHTML = (r.events||[]).map(e =>
428
+ '<div class="ev"><span class="k">'+esc(e.kind)+'</span><span class="t">'+esc(summarize(e.kind,e.payload))+'</span></div>'
429
+ ).join('') || '<span style="color:var(--faint)">no events yet</span>';
430
+ }
431
+ async function loadDiff(){
432
+ if (openRunId==null) return;
433
+ $('mDiff').textContent = 'loading…'; $('mStat').textContent='';
434
+ try{
435
+ const d = await fetch('/api/runs/'+openRunId+'/diff').then(x=>x.json());
436
+ if (!d.ok){ $('mDiff').textContent = d.stat||'no worktree'; return; }
437
+ const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
438
+ $('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
439
+ $('mDiff').innerHTML = diffHTML(d.diff||'');
440
+ }catch{ $('mDiff').textContent = 'diff failed'; }
441
+ }
442
+ function openModal(id){ openRunId = id; paintModal(); $('overlay').classList.add('open'); loadDiff(); }
443
+ function closeModal(){ openRunId=null; $('overlay').classList.remove('open'); }
444
+ $('grid').addEventListener('click',(e)=>{
445
+ const card = e.target.closest('.card'); if(!card) return;
446
+ openModal(Number(card.id.replace('card-','')));
447
+ });
448
+ $('mClose').addEventListener('click', closeModal);
449
+ $('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
450
+ document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
451
+ $('mRefreshDiff').addEventListener('click', loadDiff);
452
+ $('mStop').addEventListener('click', async ()=>{
453
+ if (openRunId==null) return;
454
+ await fetch('/api/runs/'+openRunId+'/stop',{method:'POST'});
455
+ });
456
+ $('mCleanup').addEventListener('click', async ()=>{
457
+ if (openRunId==null) return;
458
+ if (!confirm('Remove worktree + branch for r'+openRunId+'?')) return;
459
+ await fetch('/api/runs/'+openRunId+'/cleanup',{method:'POST'});
460
+ closeModal(); hydrate();
461
+ });
462
+ $('mCloseTask').addEventListener('click', async ()=>{
463
+ if (openRunId==null) return;
464
+ const r = runs.get(openRunId); if(!r) return;
465
+ if (!confirm('Close task — stop + cleanup ALL its runs?')) return;
466
+ await fetch('/api/tasks/'+r.taskId+'/close',{method:'POST'});
467
+ closeModal(); hydrate();
468
+ });
469
+
470
+ /* ── compare view ── */
471
+ let cmpTaskId = null;
472
+ async function openCompare(taskId){
473
+ cmpTaskId = taskId;
474
+ $('cmpOverlay').classList.add('open');
475
+ $('cmpBody').innerHTML = '<div class="empty" style="flex:1">loading…</div>';
476
+ await paintCompare();
477
+ }
478
+ async function paintCompare(){
479
+ if (cmpTaskId==null) return;
480
+ let d;
481
+ try{ d = await fetch('/api/tasks/'+cmpTaskId+'/compare').then(x=>x.json()); }
482
+ catch{ $('cmpBody').innerHTML = '<div class="empty" style="flex:1">compare failed</div>'; return; }
483
+ $('cmpTitle').textContent = 'Compare · '+(d.task ? d.task.title : 'task '+cmpTaskId);
484
+ if (!d.runs || !d.runs.length){ $('cmpBody').innerHTML = '<div class="empty" style="flex:1">no runs</div>'; return; }
485
+ $('cmpBody').innerHTML = d.runs.map(r=>{
486
+ const files = r.stat ? r.stat.split('\\n').filter(Boolean).length : 0;
487
+ const summary = (r.exitSummary||'').slice(0,140);
488
+ const mergeable = ['done','failed','stopped'].includes(r.status) && files>0;
489
+ const merged = r.status==='merged';
490
+ return '<div class="cmp-col" data-run="'+r.id+'">'
491
+ + '<div class="cmp-h"><span class="rid">r'+r.id+'</span>'+chipHTML(r.status)
492
+ + '<span class="files">'+files+' file'+(files===1?'':'s')+'</span></div>'
493
+ + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
494
+ + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
495
+ + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'"></span>'
496
+ + (merged
497
+ ? chipHTML('merged')
498
+ : '<button class="btn sm" data-merge="'+r.id+'"'+(mergeable?'':' disabled')+'>Merge this</button>')
499
+ + '</div></div>';
500
+ }).join('');
501
+ }
502
+ $('cmpBody').addEventListener('click', async (e)=>{
503
+ const btn = e.target.closest('button[data-merge]'); if(!btn) return;
504
+ const rid = Number(btn.dataset.merge);
505
+ if (!confirm('Merge r'+rid+' into the base branch?')) return;
506
+ btn.disabled = true;
507
+ const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
508
+ const j = await res.json().catch(()=>({detail:'merge failed'}));
509
+ const msg = $('cmpMsg-'+rid);
510
+ if (msg) msg.textContent = j.detail || (res.ok?'merged':'failed');
511
+ if (res.ok){ await paintCompare(); hydrate(); } else { btn.disabled = false; }
512
+ });
513
+ $('cmpClose').addEventListener('click', ()=>{ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); });
514
+ $('cmpOverlay').addEventListener('click',(e)=>{ if(e.target===$('cmpOverlay')){ cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
515
+ $('cmpRefresh').addEventListener('click', paintCompare);
516
+ $('mCompare').addEventListener('click', ()=>{
517
+ if (openRunId==null) return;
518
+ const r = runs.get(openRunId); if(!r) return;
519
+ closeModal(); openCompare(r.taskId);
520
+ });
521
+
522
+ /* ── terminal ── */
523
+ let termWS = null, termObj = null, fitAddon = null, termResizeObs = null;
524
+ function openTerm(runId){
525
+ const r = runs.get(runId); if(!r) return;
526
+ $('termRid').textContent = 'r'+runId;
527
+ $('termTitle').textContent = (r.tmuxWindow||'terminal');
528
+ $('termOverlay').classList.add('open');
529
+ const el = $('xterm'); el.innerHTML = '';
530
+ termObj = new window.Terminal({
531
+ fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
532
+ fontSize: 12.5, cursorBlink: true,
533
+ theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0',
534
+ selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
535
+ });
536
+ fitAddon = new window.FitAddon.FitAddon();
537
+ termObj.loadAddon(fitAddon);
538
+ termObj.open(el);
539
+ fitAddon.fit();
540
+ const proto = location.protocol==='https:'?'wss':'ws';
541
+ termWS = new WebSocket(proto+'://'+location.host+'/ws/term/'+runId);
542
+ termWS.onopen = ()=>{
543
+ termWS.send(JSON.stringify({t:'r',cols:termObj.cols,rows:termObj.rows}));
544
+ termObj.focus();
545
+ };
546
+ termWS.onmessage = (m)=>{
547
+ try{
548
+ const d = JSON.parse(m.data);
549
+ if (d.t==='o') termObj.write(d.d);
550
+ else if (d.t==='err') termObj.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
551
+ else if (d.t==='exit') termObj.write('\\r\\n\\x1b[90m[detached]\\x1b[0m\\r\\n');
552
+ }catch{}
553
+ };
554
+ termObj.onData((d)=>{ if(termWS && termWS.readyState===1) termWS.send(JSON.stringify({t:'i',d})); });
555
+ termResizeObs = new ResizeObserver(()=>{
556
+ if (!fitAddon || !termObj) return;
557
+ fitAddon.fit();
558
+ if (termWS && termWS.readyState===1) termWS.send(JSON.stringify({t:'r',cols:termObj.cols,rows:termObj.rows}));
559
+ });
560
+ termResizeObs.observe(el);
561
+ }
562
+ function closeTerm(){
563
+ $('termOverlay').classList.remove('open');
564
+ if (termResizeObs){ termResizeObs.disconnect(); termResizeObs=null; }
565
+ if (termWS){ try{ termWS.close(); }catch{} termWS=null; }
566
+ if (termObj){ try{ termObj.dispose(); }catch{} termObj=null; fitAddon=null; }
567
+ }
568
+ $('termClose').addEventListener('click', closeTerm);
569
+ $('termOverlay').addEventListener('click',(e)=>{ if(e.target===$('termOverlay')) closeTerm(); });
570
+ $('mTerm').addEventListener('click', ()=>{
571
+ if (openRunId==null) return;
572
+ const id = openRunId;
573
+ closeModal(); openTerm(id);
574
+ });
575
+
576
+ /* ── forms ── */
577
+ $('repoForm').addEventListener('submit', async (e)=>{
578
+ e.preventDefault();
579
+ const body = { machineSlug: $('repoMachine').value, path: $('repoPath').value.trim() };
580
+ if (!body.path) return;
581
+ const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
582
+ if (res.ok){ $('repoPath').value=''; await hydrate(); } else { alert('repo: '+(await res.text())); }
583
+ });
584
+ $('taskForm').addEventListener('submit', async (e)=>{
585
+ e.preventDefault();
586
+ const repoId = Number($('taskRepo').value);
587
+ const title = $('taskTitle').value.trim();
588
+ if (!repoId || !title) return;
589
+ const capId = Number($('taskCapture').value) || undefined;
590
+ const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
591
+ body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
592
+ if (!t.ok){ alert('task failed'); return; }
593
+ tasks.set(t.task.id, t.task);
594
+ await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
595
+ body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked})});
596
+ $('taskTitle').value=''; $('taskPrompt').value='';
597
+ });
598
+
599
+ hydrate().then(connectWS);
600
+ </script>
601
+ </body>
602
+ </html>`;
package/src/config.ts ADDED
@@ -0,0 +1,23 @@
1
+ import 'dotenv/config';
2
+
3
+ /** 런타임 설정. 시크릿은 전부 env 주입(번들 0). */
4
+ export const config = {
5
+ host: process.env.COXPIT_HOST ?? '127.0.0.1',
6
+ port: Number(process.env.COXPIT_PORT ?? 8210),
7
+ dbPath: process.env.COXPIT_DB ?? './coxpit.db',
8
+ // 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
9
+ sshKey: process.env.COXPIT_SSH_KEY ?? '',
10
+ agent: {
11
+ // 기본 드라이런(모의 에이전트). 실제 CLI 실행은 켤 때만(크레딧 소모).
12
+ real: process.env.COXPIT_AGENT_REAL === '1',
13
+ bin: process.env.COXPIT_AGENT_BIN ?? 'claude',
14
+ // 격리 worktree에서 헤드리스 실행 권한: acceptEdits(파일편집 자동허용) 기본.
15
+ // 완전 자율은 bypassPermissions.
16
+ perm: process.env.COXPIT_AGENT_PERM ?? 'acceptEdits',
17
+ },
18
+ auth: {
19
+ disabled: process.env.COXPIT_AUTH_DISABLED === '1',
20
+ user: process.env.COXPIT_AUTH_USER ?? 'admin',
21
+ pass: process.env.COXPIT_AUTH_PASS ?? '',
22
+ },
23
+ } as const;