coxpit 5.1.0 → 5.2.1
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 +4 -0
- package/package.json +1 -1
- package/src/auth.ts +4 -1
- package/src/board.ts +158 -15
- package/src/brand/OFL.txt +93 -0
- package/src/brand/apple-touch-icon.png +0 -0
- package/src/brand/favicon-16.png +0 -0
- package/src/brand/favicon-32.png +0 -0
- package/src/brand/favicon.ico +0 -0
- package/src/brand/icon-192.png +0 -0
- package/src/brand/icon-512.png +0 -0
- package/src/brand/mark.png +0 -0
- package/src/brand/pixelify.woff2 +0 -0
- package/src/brand/sleep.png +0 -0
- package/src/brand/wave.png +0 -0
- package/src/login.ts +8 -1
- package/src/orchestrator.ts +45 -0
- package/src/server.ts +20 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<p align="center"><img src="docs/brand/og-lockup.png" alt="coxpit — the cockpit for your agent fleet" width="640"></p>
|
|
2
|
+
|
|
1
3
|
# Coxpit
|
|
2
4
|
|
|
3
5
|
**Own your agent fleet. Run parallel AI coding agents across your own machines — steer them from any browser.**
|
|
@@ -141,3 +143,5 @@ issues privately per **[SECURITY.md](SECURITY.md)** (Coxpit exposes shells).
|
|
|
141
143
|
MIT
|
|
142
144
|
|
|
143
145
|
Icons — [Lucide](https://lucide.dev) (ISC). Paths are inlined as an SVG `<symbol>` sprite (no runtime dependency); the ISC notice is kept in [`licenses/lucide.txt`](licenses/lucide.txt).
|
|
146
|
+
|
|
147
|
+
Wordmark — [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans) (SIL Open Font License 1.1), self-hosted; the licence is kept in [`licenses/pixelify-sans-OFL.txt`](licenses/pixelify-sans-OFL.txt). The pilot mascot and logo mark are original artwork for this project.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
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": "MIT",
|
package/src/auth.ts
CHANGED
|
@@ -12,8 +12,11 @@ import { loginPageHTML } from './login';
|
|
|
12
12
|
const EXEMPT = new Set([
|
|
13
13
|
'/api/health', '/api/design/capture', '/design/bookmarklet.js', '/api/agent/subtasks',
|
|
14
14
|
'/api/auth/setup', '/api/auth/unlock', '/api/auth/logout',
|
|
15
|
+
'/favicon.ico',
|
|
15
16
|
]);
|
|
16
|
-
|
|
17
|
+
// /brand/* — 공개 브랜드 에셋(로고·마스코트·워드마크 폰트·favicon). 미인증 로그인 페이지가
|
|
18
|
+
// 이걸 불러와야 하므로 게이트 앞. 시크릿 아님(패키지 동봉 정적물).
|
|
19
|
+
const EXEMPT_PREFIX = ['/share/', '/brand/'];
|
|
17
20
|
|
|
18
21
|
/** 이 요청이 HTML 문서를 원하는 GET 인가(→ 팝업 대신 login/setup 페이지 서빙). */
|
|
19
22
|
function wantsHtml(req: FastifyRequest): boolean {
|
package/src/board.ts
CHANGED
|
@@ -8,8 +8,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
8
8
|
<meta charset="utf-8" />
|
|
9
9
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
10
10
|
<title>coxpit · fleet</title>
|
|
11
|
+
<link rel="icon" href="/brand/favicon.ico" sizes="any" />
|
|
12
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
|
|
13
|
+
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
|
11
14
|
<link rel="stylesheet" href="/vendor/xterm.css" />
|
|
12
15
|
<style>
|
|
16
|
+
@font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
|
|
13
17
|
:root{
|
|
14
18
|
--bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
|
|
15
19
|
--ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
|
|
@@ -35,13 +39,20 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
35
39
|
::-webkit-scrollbar-track{background:transparent}
|
|
36
40
|
button{font-family:var(--sans)}
|
|
37
41
|
:focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
|
|
42
|
+
/* close-X buttons — base reset so no overlay ever renders a native white button.
|
|
43
|
+
Scoped rules (.modal-h .x / .rh .x, font-size:19px for text ×) keep higher specificity. */
|
|
44
|
+
button.x{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px;
|
|
45
|
+
display:inline-flex;align-items:center;justify-content:center;border-radius:6px}
|
|
46
|
+
button.x:hover{color:var(--ink);background:var(--surface2)}
|
|
38
47
|
|
|
39
48
|
/* ── header ─────────────────────────────── */
|
|
40
49
|
header{display:flex;align-items:center;gap:14px;height:54px;padding:0 20px;
|
|
41
50
|
border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);backdrop-filter:blur(8px);
|
|
42
51
|
position:sticky;top:0;z-index:10}
|
|
43
|
-
.brand{display:flex;align-items:
|
|
44
|
-
.brand .mark{
|
|
52
|
+
.brand{display:flex;align-items:center;gap:9px;font-family:var(--mono)}
|
|
53
|
+
.brand .mark{display:inline-flex;align-items:center;gap:6px;line-height:1}
|
|
54
|
+
.brand .mark img{height:24px;width:auto;display:block}
|
|
55
|
+
.brand .mark .wm{font-family:'Pixelify';font-size:21px;font-weight:600;color:var(--ink);letter-spacing:.01em;margin-left:-2px}
|
|
45
56
|
.brand .sub{color:var(--faint);font-size:11px;text-transform:uppercase;letter-spacing:.14em}
|
|
46
57
|
.daemon-badge{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:3px 9px;
|
|
47
58
|
border:1px solid var(--line);border-radius:999px;background:var(--surface);cursor:default}
|
|
@@ -325,6 +336,55 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
325
336
|
.gov-r{color:#c9922e}
|
|
326
337
|
.gov-order{color:var(--muted);border-top:1px solid var(--line);padding-top:6px;margin-top:2px}
|
|
327
338
|
.gov-order b{color:var(--brand)}
|
|
339
|
+
/* v5.1 Documents (문서함) — grouped by run, list only, no cards */
|
|
340
|
+
#docbox .db-sub{color:var(--muted);font-size:13px;margin:0 0 14px}
|
|
341
|
+
.db-tools{display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
|
342
|
+
.db-tools select,.db-tools input{background:var(--surface);border:1px solid var(--line);border-radius:7px;
|
|
343
|
+
color:var(--muted);font-family:var(--mono);font-size:12px;padding:6px 10px}
|
|
344
|
+
.db-tools input{flex:1;min-width:160px;color:var(--ink)}
|
|
345
|
+
.db-count{font-family:var(--mono);font-size:11px;color:var(--faint);margin:0 0 8px}
|
|
346
|
+
.db-count b{color:var(--brand)}
|
|
347
|
+
.db-load{padding:24px 14px;font-family:var(--mono);font-size:13px;color:var(--faint);text-align:center;
|
|
348
|
+
border:1px solid var(--line);border-radius:10px}
|
|
349
|
+
.db-inner{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
|
|
350
|
+
.db-run{display:grid;grid-template-columns:16px 52px 1fr auto auto auto auto;gap:12px;align-items:center;
|
|
351
|
+
padding:11px 14px;background:var(--surface2);border-top:1px solid var(--line);cursor:pointer}
|
|
352
|
+
.db-run:first-child{border-top:none}
|
|
353
|
+
.db-run:hover{background:#1b212c}
|
|
354
|
+
.db-run .car{color:var(--faint);font-size:11px;transition:transform .12s}
|
|
355
|
+
.db-run.fold .car{transform:rotate(-90deg)}
|
|
356
|
+
.db-run .rid{font-family:var(--mono);font-size:13px;font-weight:700;color:var(--brand)}
|
|
357
|
+
.db-run .ttl{font-size:13.5px;color:var(--ink);font-weight:600;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
358
|
+
.db-run .repo{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
|
359
|
+
.db-run .st{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.05em;
|
|
360
|
+
display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid;border-radius:999px}
|
|
361
|
+
.db-run .st::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}
|
|
362
|
+
.db-run .st.done{color:var(--s-done);border-color:rgba(88,179,104,.4)}
|
|
363
|
+
.db-run .st.merged{color:var(--s-merged);border-color:rgba(78,201,176,.4)}
|
|
364
|
+
.db-run .st.stopped{color:var(--s-stopped);border-color:rgba(181,139,224,.4)}
|
|
365
|
+
.db-run .st.failed{color:var(--s-failed);border-color:rgba(226,91,103,.4)}
|
|
366
|
+
.db-run .st.running{color:var(--s-running);border-color:rgba(85,167,224,.4)}
|
|
367
|
+
.db-run .dt{font-family:var(--mono);font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
|
|
368
|
+
.db-run .cnt{font-family:var(--mono);font-size:11px;color:var(--faint);min-width:26px;text-align:right}
|
|
369
|
+
.db-run .cnt b{color:var(--muted)}
|
|
370
|
+
.db-items{border-top:1px solid var(--line)}
|
|
371
|
+
.db-run.fold + .db-items{display:none}
|
|
372
|
+
.db-item{display:flex;align-items:center;gap:12px;padding:8px 14px 8px 38px;
|
|
373
|
+
border-top:1px solid #1a2029;position:relative;cursor:pointer}
|
|
374
|
+
.db-item:first-child{border-top:none}
|
|
375
|
+
.db-item:hover{background:#141922}
|
|
376
|
+
.db-item .tree{position:absolute;left:24px;color:#2a323f;font-family:var(--mono);font-size:12px}
|
|
377
|
+
.db-item .badge{flex:0 0 46px;width:46px;font-family:var(--mono);font-size:9.5px;font-weight:700;
|
|
378
|
+
text-transform:uppercase;letter-spacing:.05em;text-align:center;padding:2px 0;border:1px solid;border-radius:5px}
|
|
379
|
+
.db-item .badge.answer{color:var(--s-running);border-color:rgba(85,167,224,.35);background:rgba(85,167,224,.08)}
|
|
380
|
+
.db-item .badge.code{color:var(--muted);border-color:var(--line-hi)}
|
|
381
|
+
.db-item .badge.doc,.db-item .badge.page{color:var(--brand);border-color:rgba(78,201,176,.35);background:var(--brand-dim)}
|
|
382
|
+
.db-item .badge.file{color:var(--s-stopped);border-color:rgba(181,139,224,.35);background:rgba(181,139,224,.08)}
|
|
383
|
+
.db-item .nm{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;color:var(--ink);
|
|
384
|
+
overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
385
|
+
.db-item .nm .meta{color:var(--faint);margin-left:8px;font-size:11px}
|
|
386
|
+
.db-item .act{flex:0 0 auto;margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--brand);opacity:.8}
|
|
387
|
+
.db-item:hover .act{opacity:1;text-decoration:underline}
|
|
328
388
|
.attempt{color:var(--brand);opacity:.8}
|
|
329
389
|
|
|
330
390
|
/* ── goal workroom (v4.6) — 한 goal 을 여는 단일 방 ── */
|
|
@@ -501,14 +561,16 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
501
561
|
.selbar .cnt{font-family:var(--mono);font-size:12px;color:var(--brand)}
|
|
502
562
|
.selbar .note{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
503
563
|
|
|
504
|
-
.empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:
|
|
564
|
+
.empty{color:var(--faint);font-family:var(--mono);font-size:12px;padding:56px 24px;text-align:center;
|
|
505
565
|
display:flex;flex-direction:column;gap:10px;align-items:center}
|
|
506
566
|
.empty .glyph{font-size:22px;color:#2c3444;letter-spacing:4px}
|
|
567
|
+
.empty .mascot{width:88px;height:auto;margin-bottom:2px;opacity:.96;user-select:none;-webkit-user-drag:none}
|
|
507
568
|
|
|
508
569
|
/* ── onboarding (first run) ─────────────── */
|
|
509
570
|
.setup{max-width:560px;margin:40px auto;border:1px solid var(--line);border-radius:14px;
|
|
510
571
|
background:var(--surface);overflow:hidden;text-align:left}
|
|
511
|
-
.setup-h{padding:18px 22px 14px;border-bottom:1px solid var(--line)}
|
|
572
|
+
.setup-h{padding:18px 22px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:15px}
|
|
573
|
+
.setup-h .setup-mascot{width:62px;height:auto;flex:none;-webkit-user-drag:none;user-select:none}
|
|
512
574
|
.setup-h .t{font-weight:700;font-size:16px}
|
|
513
575
|
.setup-h .d{color:var(--muted);font-size:13px;margin-top:3px}
|
|
514
576
|
.setup-sec{padding:14px 22px;border-bottom:1px solid var(--line)}
|
|
@@ -785,7 +847,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
785
847
|
${ICON_SPRITE}
|
|
786
848
|
<header>
|
|
787
849
|
<button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
|
|
788
|
-
<div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
|
|
850
|
+
<div class="brand"><span class="mark"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></span><span class="sub">fleet console</span></div>
|
|
789
851
|
<span class="daemon-badge" id="daemonBadge" style="display:none"></span>
|
|
790
852
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
791
853
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
|
|
@@ -818,6 +880,9 @@ ${ICON_SPRITE}
|
|
|
818
880
|
<button type="button" class="navi" data-view="goals">
|
|
819
881
|
<svg class="ic"><use href="#i-target"/></svg><span>Goals</span><span class="nsp"></span><span class="n" id="navGoalsN"></span>
|
|
820
882
|
</button>
|
|
883
|
+
<button type="button" class="navi" data-view="documents">
|
|
884
|
+
<svg class="ic"><use href="#i-file"/></svg><span>Documents</span><span class="nsp"></span><span class="n" id="navDocsN"></span>
|
|
885
|
+
</button>
|
|
821
886
|
<button type="button" class="navi" data-view="archive">
|
|
822
887
|
<svg class="ic"><use href="#i-archive"/></svg><span>Archive</span><span class="nsp"></span><span class="n" id="navArchiveN"></span>
|
|
823
888
|
</button>
|
|
@@ -939,7 +1004,7 @@ ${ICON_SPRITE}
|
|
|
939
1004
|
<button class="btn-ghost sm" id="selToggle">Select runs</button></div>
|
|
940
1005
|
<div class="grid" id="grid"></div>
|
|
941
1006
|
<div class="empty" id="empty">
|
|
942
|
-
<
|
|
1007
|
+
<img class="mascot" src="/brand/sleep.png" alt="" />
|
|
943
1008
|
<span>No runs yet</span>
|
|
944
1009
|
<span style="color:#3d4657">register a repo, write a task, hit Run fleet</span>
|
|
945
1010
|
</div>
|
|
@@ -953,6 +1018,15 @@ ${ICON_SPRITE}
|
|
|
953
1018
|
<div id="archList"></div>
|
|
954
1019
|
<div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
|
|
955
1020
|
</div>
|
|
1021
|
+
<div id="docbox" hidden>
|
|
1022
|
+
<p class="db-sub">이 워크스페이스가 만든 모든 산출물 — 머지·종료 후에도 DB 스냅샷으로 보존. run 기준으로 묶어 봅니다.</p>
|
|
1023
|
+
<div class="db-tools">
|
|
1024
|
+
<select id="dbRepo"><option value="">all repos</option></select>
|
|
1025
|
+
<select id="dbType"><option value="">all types</option><option value="answer">answer</option><option value="code">code</option><option value="doc">doc</option><option value="page">page</option><option value="file">file</option></select>
|
|
1026
|
+
<input id="dbQ" placeholder="search path or title…" autocomplete="off" />
|
|
1027
|
+
</div>
|
|
1028
|
+
<div class="db-list" id="dbList"></div>
|
|
1029
|
+
</div>
|
|
956
1030
|
</main>
|
|
957
1031
|
</div>
|
|
958
1032
|
|
|
@@ -1303,13 +1377,14 @@ async function brwRegister(fullPath){
|
|
|
1303
1377
|
}
|
|
1304
1378
|
toast('register: '+(j.detail||j.error||res.status), 'error');
|
|
1305
1379
|
}
|
|
1306
|
-
|
|
1380
|
+
function openRepoBrowse(){
|
|
1307
1381
|
const m = machines.find(x=>x.slug===$('repoMachine').value);
|
|
1308
1382
|
if (m && m.address){ toast('remote machine — type the path manually for now', 'error'); return; }
|
|
1309
1383
|
$('brwNewForm').hidden = true; $('brwNewName').value = '';
|
|
1310
1384
|
$('brwOverlay').classList.add('open');
|
|
1311
1385
|
brwGo(brwCur || '');
|
|
1312
|
-
}
|
|
1386
|
+
}
|
|
1387
|
+
$('repoBrowse').addEventListener('click', openRepoBrowse);
|
|
1313
1388
|
$('brwList').addEventListener('click',(e)=>{
|
|
1314
1389
|
const reg = e.target.closest('button[data-reg]');
|
|
1315
1390
|
if (reg){ brwRegister(brwCur.replace(/\\/$/,'')+'/'+reg.dataset.reg); return; }
|
|
@@ -1563,14 +1638,80 @@ function setView(v){
|
|
|
1563
1638
|
b.classList.toggle('on', on); b.setAttribute('aria-pressed', on?'true':'false');
|
|
1564
1639
|
});
|
|
1565
1640
|
const archive = v==='archive';
|
|
1641
|
+
const docs = v==='documents';
|
|
1566
1642
|
$('archive').hidden = !archive;
|
|
1567
|
-
$('
|
|
1568
|
-
$('
|
|
1569
|
-
|
|
1643
|
+
$('docbox').hidden = !docs;
|
|
1644
|
+
$('grid').style.display = (archive||docs) ? 'none' : '';
|
|
1645
|
+
$('empty').style.display = (archive||docs) ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
|
|
1646
|
+
document.querySelector('.toolbar').style.display = (archive||docs) ? 'none' : 'flex';
|
|
1570
1647
|
if (archive){ paintArchRepos(); $('archRepo').value = selectedRepo!=null ? String(selectedRepo) : ''; archFetch(true); reclaimRefresh(); }
|
|
1648
|
+
else if (docs){ renderDocbox(); }
|
|
1571
1649
|
else render();
|
|
1572
1650
|
}
|
|
1573
1651
|
document.querySelectorAll('#viewNav .navi').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
|
|
1652
|
+
// v5.1 Documents (문서함) — every output grouped by run, list only.
|
|
1653
|
+
let dbData = null; const dbFold = new Set();
|
|
1654
|
+
async function renderDocbox(){
|
|
1655
|
+
const box = $('dbList');
|
|
1656
|
+
box.innerHTML = '<div class="db-load">loading…</div>';
|
|
1657
|
+
try { dbData = (await (await fetch('/api/documents')).json()).runs || []; }
|
|
1658
|
+
catch(e){ box.innerHTML = '<div class="db-load">failed to load documents</div>'; return; }
|
|
1659
|
+
$('navDocsN').textContent = dbData.length || '';
|
|
1660
|
+
const reposList = [...new Set(dbData.map(r=>r.repo))].sort();
|
|
1661
|
+
const cur = $('dbRepo').value;
|
|
1662
|
+
$('dbRepo').innerHTML = '<option value="">all repos</option>' + reposList.map(r=>'<option'+(r===cur?' selected':'')+'>'+esc(r)+'</option>').join('');
|
|
1663
|
+
paintDocbox();
|
|
1664
|
+
}
|
|
1665
|
+
function fmtDbDate(ts){ const d=new Date(ts*1000); const p=n=>String(n).padStart(2,'0'); return p(d.getMonth()+1)+'·'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes()); }
|
|
1666
|
+
function docRunHTML(run){
|
|
1667
|
+
const stCls = ['done','merged','stopped','failed','running'].includes(run.status) ? run.status : 'done';
|
|
1668
|
+
const items = run.outputs.map((o,i,arr)=>{
|
|
1669
|
+
const tree = i===arr.length-1 ? '└' : '├';
|
|
1670
|
+
const act = ({answer:'view',code:'diff',doc:'rendered',page:'open',file:'download'})[o.type] || 'view';
|
|
1671
|
+
return '<div class="db-item" data-run="'+run.runId+'"><span class="tree">'+tree+'</span>'
|
|
1672
|
+
+ '<span class="badge '+o.type+'">'+esc(o.type)+'</span>'
|
|
1673
|
+
+ '<span class="nm">'+esc(o.name)+(o.meta?'<span class="meta">'+esc(o.meta)+'</span>':'')+'</span>'
|
|
1674
|
+
+ '<a class="act">'+act+' →</a></div>';
|
|
1675
|
+
}).join('');
|
|
1676
|
+
return '<div class="db-run'+(dbFold.has(run.runId)?' fold':'')+'" data-run="'+run.runId+'"><span class="car">▾</span>'
|
|
1677
|
+
+ '<span class="rid">r'+run.runId+'</span><span class="ttl">'+esc(run.title)+'</span>'
|
|
1678
|
+
+ '<span class="repo">'+esc(run.repo)+'</span>'
|
|
1679
|
+
+ '<span class="st '+stCls+'">'+esc(run.status)+'</span>'
|
|
1680
|
+
+ '<span class="dt">'+(run.ts?fmtDbDate(run.ts):'')+'</span><span class="cnt"><b>'+run.outputs.length+'</b></span></div>'
|
|
1681
|
+
+ '<div class="db-items">'+items+'</div>';
|
|
1682
|
+
}
|
|
1683
|
+
function paintDocbox(){
|
|
1684
|
+
if (!dbData) return;
|
|
1685
|
+
const repo = $('dbRepo').value, type = $('dbType').value, q = ($('dbQ').value||'').toLowerCase().trim();
|
|
1686
|
+
const runs = dbData.map(run=>({ ...run, outputs: type ? run.outputs.filter(o=>o.type===type) : run.outputs }))
|
|
1687
|
+
.filter(run=> run.outputs.length
|
|
1688
|
+
&& (!repo || run.repo===repo)
|
|
1689
|
+
&& (!q || run.title.toLowerCase().includes(q) || run.outputs.some(o=>o.name.toLowerCase().includes(q))));
|
|
1690
|
+
const box = $('dbList');
|
|
1691
|
+
if (!runs.length){ box.innerHTML = '<div class="db-load">no documents'+((q||repo||type)?' match the filter':' yet')+'</div>'; return; }
|
|
1692
|
+
const total = runs.reduce((n,r)=>n+r.outputs.length,0);
|
|
1693
|
+
box.innerHTML = '<div class="db-count"><b>'+total+'</b> output'+(total>1?'s':'')+' · '+runs.length+' run'+(runs.length>1?'s':'')+'</div>'
|
|
1694
|
+
+ '<div class="db-inner">'+runs.map(docRunHTML).join('')+'</div>';
|
|
1695
|
+
}
|
|
1696
|
+
$('dbList').addEventListener('click', (e)=>{
|
|
1697
|
+
const run = e.target.closest('.db-run');
|
|
1698
|
+
if (run){ const id=Number(run.dataset.run); if(dbFold.has(id))dbFold.delete(id); else dbFold.add(id); paintDocbox(); return; }
|
|
1699
|
+
const item = e.target.closest('.db-item');
|
|
1700
|
+
if (!item) return;
|
|
1701
|
+
const id = Number(item.dataset.run);
|
|
1702
|
+
// documents include closed/archived runs not in the live map — seed a minimal run+task so the modal renders
|
|
1703
|
+
if (!runs.has(id)){
|
|
1704
|
+
const dr = (dbData||[]).find(r=>r.runId===id);
|
|
1705
|
+
if (dr){
|
|
1706
|
+
runs.set(id, { id, taskId: dr.taskId, status: dr.status, branch:'', filesChanged:0, events:[], prUrl: dr.prUrl });
|
|
1707
|
+
if (!tasks.has(dr.taskId)) tasks.set(dr.taskId, { id: dr.taskId, title: dr.title, status:'closed', repoId:0 });
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
openModal(id);
|
|
1711
|
+
});
|
|
1712
|
+
$('dbRepo').addEventListener('change', paintDocbox);
|
|
1713
|
+
$('dbType').addEventListener('change', paintDocbox);
|
|
1714
|
+
$('dbQ').addEventListener('input', paintDocbox);
|
|
1574
1715
|
// 뷰 nav 카운트 — Active=스코프 run 수, Goals=스코프 그룹 수, Archive=닫힌 태스크 수(hydrate)
|
|
1575
1716
|
function paintNavCounts(){
|
|
1576
1717
|
const scoped = [...runs.values()].filter(runInScope);
|
|
@@ -1694,8 +1835,9 @@ function paintOnboarding(goalsOnly){
|
|
|
1694
1835
|
}
|
|
1695
1836
|
const agentMissing = r && r.agent && !r.agent.ok;
|
|
1696
1837
|
$('empty').innerHTML = '<div class="setup">'
|
|
1697
|
-
+ '<div class="setup-h"><
|
|
1698
|
-
+ '<div class="
|
|
1838
|
+
+ '<div class="setup-h"><img class="setup-mascot" src="/brand/sleep.png" alt="" />'
|
|
1839
|
+
+ '<div><div class="t">Welcome to coxpit</div>'
|
|
1840
|
+
+ '<div class="d">Run a fleet of coding agents on this machine — each in its own git worktree.</div></div></div>'
|
|
1699
1841
|
+ '<div class="setup-sec"><p class="setup-label">This machine</p>' + checks
|
|
1700
1842
|
+ (agentMissing
|
|
1701
1843
|
? '<div class="setup-fix"># install the agent CLI, then sign in once:\\nnpm i -g @anthropic-ai/claude-code\\n'+esc(agentBin)+' # first run opens browser login</div>'
|
|
@@ -3545,8 +3687,9 @@ $('newBtn').addEventListener('click', ()=>openLaunch('task'));
|
|
|
3545
3687
|
$('fab').addEventListener('click', ()=>openLaunch('task')); // mobile pocket-board FAB → same sheet
|
|
3546
3688
|
$('sheetClose').addEventListener('click', closeLaunch);
|
|
3547
3689
|
$('newSheet').addEventListener('click', (e)=>{ if(e.target===$('newSheet')) closeLaunch(); });
|
|
3548
|
-
// Add repository —
|
|
3549
|
-
|
|
3690
|
+
// Add repository — repo 브라우저를 바로 연다(New 태스크 시트는 열지 않음: repoBrowse 핸들러가
|
|
3691
|
+
// #brwOverlay 를 직접 띄우고, machineSlug 는 hydrate 가 채운 #repoMachine 값을 씀).
|
|
3692
|
+
$('repoAdd').addEventListener('click', openRepoBrowse);
|
|
3550
3693
|
// machine switcher — 컴팩트 버튼 + 메뉴(기존 #repoMachine 이 선택 상태 보관)
|
|
3551
3694
|
function positionMachineMenu(){
|
|
3552
3695
|
const b = $('machineSwitch').getBoundingClientRect();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
https://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/login.ts
CHANGED
|
@@ -26,7 +26,11 @@ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
|
|
|
26
26
|
<meta charset="utf-8" />
|
|
27
27
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
28
28
|
<title>coxpit · ${title}</title>
|
|
29
|
+
<link rel="icon" href="/brand/favicon.ico" sizes="any" />
|
|
30
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
|
|
31
|
+
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
|
|
29
32
|
<style>
|
|
33
|
+
@font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
|
|
30
34
|
:root{
|
|
31
35
|
--bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
|
|
32
36
|
--ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
|
|
@@ -47,8 +51,10 @@ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
|
|
|
47
51
|
::selection{background:var(--brand-dim)}
|
|
48
52
|
.card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
|
|
49
53
|
border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
|
|
54
|
+
.welcome{width:80px;height:auto;display:block;margin:2px auto 14px;opacity:.97;-webkit-user-drag:none}
|
|
50
55
|
.mark{font-family:var(--mono);font-weight:700;color:var(--brand);font-size:17px;letter-spacing:.02em;
|
|
51
56
|
display:flex;align-items:center;gap:9px}
|
|
57
|
+
.mark .wm{font-family:'Pixelify';font-weight:600;color:var(--ink);font-size:19px;letter-spacing:.01em}
|
|
52
58
|
.glyph{font-size:18px;line-height:1}
|
|
53
59
|
h1{font-size:15px;font-weight:600;margin:16px 0 4px;color:var(--ink)}
|
|
54
60
|
.sub{color:var(--muted);font-size:12.5px;margin:0 0 18px;line-height:1.5}
|
|
@@ -81,7 +87,8 @@ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
|
|
|
81
87
|
${ICON_SPRITE}
|
|
82
88
|
<form class="card" id="f" method="post" action="${action}" autocomplete="off">
|
|
83
89
|
<input type="hidden" name="nav" value="1">
|
|
84
|
-
<
|
|
90
|
+
<img class="welcome" src="/brand/wave.png" alt="" />
|
|
91
|
+
<div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span class="wm">coxpit</span></div>
|
|
85
92
|
<h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
|
|
86
93
|
<p class="sub">${setup
|
|
87
94
|
? 'Set an access key. You'll enter it once per device — no accounts, no username.'
|
package/src/orchestrator.ts
CHANGED
|
@@ -1394,6 +1394,51 @@ async function finalizeLand(runId: number): Promise<void> {
|
|
|
1394
1394
|
await recordEvent(runId, 'pr', `${m[0]} (landed on ${pend.targetBranch} after resolve)`);
|
|
1395
1395
|
}
|
|
1396
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* Documents (문서함) — every output this workspace produced, grouped by run, newest first.
|
|
1399
|
+
* DB-only so it survives merge/close and never shells git: doc/page from doc_snapshots
|
|
1400
|
+
* (persisted), code from filesChanged, answer from the settled run's final message.
|
|
1401
|
+
*/
|
|
1402
|
+
export async function listDocuments(): Promise<{ runs: Array<{
|
|
1403
|
+
runId: number; taskId: number; title: string; repo: string; status: string; ts: number | null;
|
|
1404
|
+
prUrl: string | null; outputs: Array<{ type: 'answer' | 'code' | 'doc' | 'page' | 'file'; name: string; meta: string }>;
|
|
1405
|
+
}> }> {
|
|
1406
|
+
const [allRuns, allTasks, allRepos, snaps] = await Promise.all([
|
|
1407
|
+
db.select().from(agentRuns),
|
|
1408
|
+
db.select().from(tasks),
|
|
1409
|
+
db.select().from(repos),
|
|
1410
|
+
db.select().from(docSnapshots),
|
|
1411
|
+
]);
|
|
1412
|
+
const taskById = new Map(allTasks.map((t) => [t.id, t]));
|
|
1413
|
+
const repoById = new Map(allRepos.map((r) => [r.id, r.name]));
|
|
1414
|
+
const snapByRun = new Map<number, typeof snaps>();
|
|
1415
|
+
for (const s of snaps) { const a = snapByRun.get(s.runId) ?? []; a.push(s); snapByRun.set(s.runId, a); }
|
|
1416
|
+
const out: Array<{
|
|
1417
|
+
runId: number; taskId: number; title: string; repo: string; status: string; ts: number | null;
|
|
1418
|
+
prUrl: string | null; outputs: Array<{ type: 'answer' | 'code' | 'doc' | 'page' | 'file'; name: string; meta: string }>;
|
|
1419
|
+
}> = [];
|
|
1420
|
+
for (const r of allRuns) {
|
|
1421
|
+
const t = taskById.get(r.taskId);
|
|
1422
|
+
if (!t) continue;
|
|
1423
|
+
const outputs: Array<{ type: 'answer' | 'code' | 'doc' | 'page' | 'file'; name: string; meta: string }> = [];
|
|
1424
|
+
if (['done', 'merged', 'failed', 'stopped'].includes(r.status) && r.exitSummary
|
|
1425
|
+
&& !/^(exit -?\d+|stopped by user|orphaned|worktree|no )/i.test(r.exitSummary)) {
|
|
1426
|
+
outputs.push({ type: 'answer', name: 'Final answer', meta: r.exitSummary.replace(/\s+/g, ' ').trim().slice(0, 100) });
|
|
1427
|
+
}
|
|
1428
|
+
if ((r.filesChanged ?? 0) > 0) {
|
|
1429
|
+
outputs.push({ type: 'code', name: 'code changes', meta: '+' + r.filesChanged + ' file' + (r.filesChanged > 1 ? 's' : '') + (r.prUrl ? ' · landed' : '') });
|
|
1430
|
+
}
|
|
1431
|
+
for (const s of (snapByRun.get(r.id) ?? [])) {
|
|
1432
|
+
outputs.push({ type: s.kind === 'html' ? 'page' : 'doc', name: s.path, meta: s.kind === 'html' ? 'html' : 'markdown' });
|
|
1433
|
+
}
|
|
1434
|
+
if (!outputs.length) continue;
|
|
1435
|
+
const ts = r.endedAt ? Math.floor(r.endedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : null);
|
|
1436
|
+
out.push({ runId: r.id, taskId: t.id, title: t.title, repo: repoById.get(t.repoId) ?? '?', status: r.status, ts, prUrl: r.prUrl ?? null, outputs });
|
|
1437
|
+
}
|
|
1438
|
+
out.sort((a, b) => b.runId - a.runId);
|
|
1439
|
+
return { runs: out };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1397
1442
|
/**
|
|
1398
1443
|
* worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
|
|
1399
1444
|
*/
|
package/src/server.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { db } from './db';
|
|
|
18
18
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
19
19
|
import { BOOKMARKLET_JS } from './design';
|
|
20
20
|
import { runShellOn, shq } from './exec';
|
|
21
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve } from './orchestrator';
|
|
21
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments } from './orchestrator';
|
|
22
22
|
import { openTerm } from './term';
|
|
23
23
|
import { addSink, removeSink, broadcast } from './hub';
|
|
24
24
|
import { getProvider, listProviders } from './providers';
|
|
@@ -64,6 +64,7 @@ function contentTypeFor(path: string): string {
|
|
|
64
64
|
avif: 'image/avif', pdf: 'application/pdf', txt: 'text/plain; charset=utf-8',
|
|
65
65
|
md: 'text/plain; charset=utf-8', json: 'application/json', csv: 'text/csv; charset=utf-8',
|
|
66
66
|
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
|
|
67
|
+
woff2: 'font/woff2', woff: 'font/woff',
|
|
67
68
|
};
|
|
68
69
|
return map[ext] ?? 'application/octet-stream';
|
|
69
70
|
}
|
|
@@ -347,6 +348,9 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
347
348
|
};
|
|
348
349
|
});
|
|
349
350
|
|
|
351
|
+
// v5.1 Documents (문서함) — every output the workspace produced, grouped by run (DB-only).
|
|
352
|
+
app.get('/api/documents', async () => await listDocuments());
|
|
353
|
+
|
|
350
354
|
// 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
|
|
351
355
|
app.get('/api/archive', async (req) => {
|
|
352
356
|
const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
|
|
@@ -1320,6 +1324,21 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1320
1324
|
return reply.type(v.type).header('cache-control', 'public, max-age=86400').send(body);
|
|
1321
1325
|
});
|
|
1322
1326
|
|
|
1327
|
+
// 브랜드 에셋 서빙(로고 마크·마스코트 컷·워드마크 폰트·favicon). CDN 없음, 패키지 동봉.
|
|
1328
|
+
const BRAND_FILES = new Set([
|
|
1329
|
+
'mark.png', 'sleep.png', 'wave.png', 'pixelify.woff2',
|
|
1330
|
+
'favicon.ico', 'favicon-16.png', 'favicon-32.png',
|
|
1331
|
+
'apple-touch-icon.png', 'icon-192.png', 'icon-512.png',
|
|
1332
|
+
]);
|
|
1333
|
+
async function sendBrand(file: string, reply: import('fastify').FastifyReply) {
|
|
1334
|
+
if (!BRAND_FILES.has(file)) return reply.code(404).send({ error: 'not found' });
|
|
1335
|
+
const body = await readFile(new URL('./brand/' + file, import.meta.url));
|
|
1336
|
+
return reply.type(contentTypeFor(file)).header('cache-control', 'public, max-age=86400').send(body);
|
|
1337
|
+
}
|
|
1338
|
+
app.get('/brand/:file', async (req, reply) =>
|
|
1339
|
+
sendBrand((req.params as { file: string }).file, reply));
|
|
1340
|
+
app.get('/favicon.ico', async (_req, reply) => sendBrand('favicon.ico', reply));
|
|
1341
|
+
|
|
1323
1342
|
// run 터미널 — tmux 세션에 PTY attach, WS 로 중계.
|
|
1324
1343
|
// client → {t:'i',d:string} 입력 · {t:'r',cols,rows} 리사이즈 / server → {t:'o',d} 출력 · {t:'exit'}
|
|
1325
1344
|
// 하드닝: ?cols&rows 초기 크기(80x24 경유 제거) · 세션 자동 소생 · keepalive · 백프레셔.
|