coxpit 5.17.1 → 5.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/cockpit.ts +250 -17
- package/src/files.ts +159 -0
- package/src/orchestrator.ts +22 -0
- package/src/server.ts +49 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.20.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"dotenv": "^16",
|
|
52
52
|
"drizzle-orm": "^0.38",
|
|
53
53
|
"fastify": "^5",
|
|
54
|
+
"marked": "^14.1.4",
|
|
54
55
|
"node-pty": "^1.1.0",
|
|
55
56
|
"tsx": "^4"
|
|
56
57
|
},
|
package/src/cockpit.ts
CHANGED
|
@@ -136,6 +136,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
136
136
|
.term-host .xterm-viewport::-webkit-scrollbar-thumb{background:var(--line-hi);border-radius:4px}
|
|
137
137
|
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover{background:var(--faint)}
|
|
138
138
|
|
|
139
|
+
/* ── 파일 뷰어 페인(비터미널 탭) ── */
|
|
140
|
+
.view-host{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column;background:var(--bg)}
|
|
141
|
+
.view-bar{display:flex;align-items:center;gap:8px;height:26px;padding:0 8px;background:var(--surface2);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:11px;color:var(--muted);flex:none}
|
|
142
|
+
.view-bar .vp{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;direction:rtl;text-align:left;color:var(--faint)}
|
|
143
|
+
.view-bar button{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--line-hi);border-radius:5px;padding:1px 8px;cursor:pointer}
|
|
144
|
+
.view-bar button:hover{border-color:var(--brand)}
|
|
145
|
+
.view-bar .prim{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
|
|
146
|
+
.view-body{flex:1;min-height:0;overflow:auto;background:var(--bg);position:relative}
|
|
147
|
+
.view-body:has(iframe){overflow:hidden} /* iframe 이 자체 스크롤 — 바깥 view-body 는 스크롤 금지(이중 스크롤바 방지) */
|
|
148
|
+
.view-body iframe{display:block;width:100%;height:100%;border:0;background:#fff}
|
|
149
|
+
.view-body img{max-width:100%;display:block;margin:0 auto;padding:12px}
|
|
150
|
+
.view-body pre.vtext{margin:0;padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-word}
|
|
151
|
+
.view-body textarea.vedit{width:100%;height:100%;box-sizing:border-box;border:0;outline:none;resize:none;padding:12px 14px;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--ink);background:var(--bg)}
|
|
152
|
+
.view-msg{padding:24px;text-align:center;color:var(--faint);font-family:var(--mono);font-size:12px}
|
|
153
|
+
.view-msg a{color:var(--brand)}
|
|
154
|
+
.st.vdoc{background:none;color:var(--faint);width:auto;font-size:11px}
|
|
155
|
+
|
|
139
156
|
.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
140
157
|
.empty .card{max-width:440px}
|
|
141
158
|
.empty .glyph{font-family:var(--mono);font-size:24px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
|
|
@@ -173,7 +190,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
173
190
|
.pick-h .x{margin-left:auto;background:none;border:none;color:var(--faint);font-size:16px;cursor:pointer}
|
|
174
191
|
.pick-h .x:hover{color:var(--ink)}
|
|
175
192
|
.pick-path{padding:8px 15px;font-size:11.5px;color:var(--brand);border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
|
|
193
|
+
.fp-search{padding:8px 12px;border-bottom:1px solid var(--line)}
|
|
194
|
+
.fp-search input{width:100%;box-sizing:border-box;font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--panel);border:1px solid var(--line-hi);border-radius:7px;padding:7px 10px;outline:none}
|
|
195
|
+
.fp-search input:focus{border-color:var(--brand)}
|
|
176
196
|
.pick-list{flex:1;overflow:auto;padding:6px}
|
|
197
|
+
.pick-row .rel{margin-left:auto;font-size:10px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;max-width:60%}
|
|
177
198
|
.pick-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;color:var(--muted)}
|
|
178
199
|
.pick-row:hover{background:var(--surface2);color:var(--ink)}
|
|
179
200
|
.pick-row .ic{width:14px;text-align:center;color:var(--faint)}
|
|
@@ -200,6 +221,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
200
221
|
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
201
222
|
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
202
223
|
.tnode.session .p{color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis}
|
|
224
|
+
.tnode.session .del{margin-left:auto;color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;line-height:1;padding:0 3px;opacity:0;flex:none}
|
|
225
|
+
.tnode.session:hover .del{opacity:1} .tnode.session .del:hover{color:var(--failed)}
|
|
226
|
+
body.touch .tnode.session .del{opacity:.65}
|
|
203
227
|
|
|
204
228
|
.toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
|
|
205
229
|
font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
|
|
@@ -326,6 +350,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
326
350
|
<button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐ split</button>
|
|
327
351
|
<button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
|
|
328
352
|
<button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
|
|
353
|
+
<button class="tc-btn" id="fileBtn" title="파일 보기 — md·html·pdf·이미지·텍스트 뷰어(터미널 옆 페인)">▤<span class="b-txt"> File</span></button>
|
|
329
354
|
<button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> pane</span></button>
|
|
330
355
|
</div>
|
|
331
356
|
</div>
|
|
@@ -411,6 +436,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
411
436
|
</div>
|
|
412
437
|
</div>
|
|
413
438
|
|
|
439
|
+
<div class="modal" id="fpickModal">
|
|
440
|
+
<div class="pick">
|
|
441
|
+
<div class="pick-h"><span class="t">파일 보기</span><button class="x" id="fpClose" title="닫기">×</button></div>
|
|
442
|
+
<div class="pick-path" id="fpPath">…</div>
|
|
443
|
+
<div class="fp-search"><input id="fpSearch" placeholder="이 폴더 아래에서 이름으로 검색 (2자 이상)" autocomplete="off" spellcheck="false" /></div>
|
|
444
|
+
<div class="pick-list" id="fpList"></div>
|
|
445
|
+
<div class="pick-f">
|
|
446
|
+
<button class="home" id="fpHome" title="홈으로">⌂ home</button>
|
|
447
|
+
<span id="fpHint" style="flex:1;font-size:11px;color:var(--faint)">폴더=이동 · 파일=뷰어로 열기</span>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
414
452
|
<div class="modal" id="secretsModal">
|
|
415
453
|
<div class="pick" style="width:min(520px,92vw)">
|
|
416
454
|
<div class="pick-h"><span class="t">시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
|
|
@@ -444,6 +482,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
444
482
|
<script src="/vendor/addon-fit.js"></script>
|
|
445
483
|
<script src="/vendor/addon-unicode11.js"></script>
|
|
446
484
|
<script src="/vendor/addon-web-links.js"></script>
|
|
485
|
+
<script src="/vendor/marked.js"></script>
|
|
447
486
|
<script>
|
|
448
487
|
// 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
|
|
449
488
|
// (이전엔 보드로 리다이렉트했지만, 이제 cockpit 을 모바일 대응)
|
|
@@ -514,7 +553,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
514
553
|
sessRuns.forEach(function(s){
|
|
515
554
|
var r=s.run; var open = tabs[r.id] ? ' open' : '';
|
|
516
555
|
html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
517
|
-
+ '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span
|
|
556
|
+
+ '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span>'
|
|
557
|
+
+ '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
|
|
518
558
|
});
|
|
519
559
|
} else {
|
|
520
560
|
html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
|
|
@@ -556,6 +596,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
556
596
|
}
|
|
557
597
|
$('tree').addEventListener('click', function(e){
|
|
558
598
|
if (e.target.closest('[data-newsession]')){ openSession(); return; }
|
|
599
|
+
var del = e.target.closest('[data-delsession]');
|
|
600
|
+
if (del){ e.stopPropagation(); deleteSession(+del.dataset.delsession); return; }
|
|
559
601
|
var run = e.target.closest('[data-run]');
|
|
560
602
|
if (run){ openRunPane(+run.dataset.run); if (isMobile()) setDrawer(false); return; }
|
|
561
603
|
var fn = e.target.closest('[data-fold]');
|
|
@@ -576,9 +618,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
576
618
|
var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
|
|
577
619
|
var focusLeaf = 'L0';
|
|
578
620
|
var leafSeq = 1;
|
|
621
|
+
var viewerSeq = 1; // 뷰어 탭 키(문자열 'v#')
|
|
579
622
|
var MAX_LEAVES = 6;
|
|
580
623
|
var dragRunId = null;
|
|
581
624
|
|
|
625
|
+
// 뷰어 탭은 문자열 키('v1'…), 터미널 탭은 숫자 runId. 이벤트 핸들러에서 강제 숫자화 금지.
|
|
626
|
+
function isViewer(id){ return typeof id==='string' && id.charAt(0)==='v'; }
|
|
627
|
+
function tabIdOf(el){ var v=el.dataset.tab; return (v && v.charAt(0)==='v') ? v : +v; }
|
|
628
|
+
|
|
582
629
|
function newLeafId(){ return 'L'+(leafSeq++); }
|
|
583
630
|
function eachLeaf(node, fn){ if(node.leaf){ fn(node); } else { eachLeaf(node.a,fn); eachLeaf(node.b,fn); } }
|
|
584
631
|
function findLeaf(id, node){ node=node||layout; if(node.leaf) return node.id===id?node:null; return findLeaf(id,node.a)||findLeaf(id,node.b); }
|
|
@@ -609,6 +656,26 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
609
656
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
610
657
|
// URL 링크 클릭 가능(웹=새 탭, 데스크톱 앱=시스템 브라우저 — setWindowOpenHandler 가 external 로).
|
|
611
658
|
try{ term.loadAddon(new window.WebLinksAddon.WebLinksAddon(function(ev, uri){ window.open(uri, '_blank', 'noopener'); })); }catch(e){}
|
|
659
|
+
// 파일 경로 링크: 에이전트가 찍는 경로(src/x.ts:12 · /Users/…/README.md 등)를 클릭하면 뷰어 페인으로.
|
|
660
|
+
try{ term.registerLinkProvider({ provideLinks: function(y, cb){
|
|
661
|
+
var ln; try{ ln=term.buffer.active.getLine(y-1); }catch(e){ cb(undefined); return; }
|
|
662
|
+
if(!ln){ cb(undefined); return; }
|
|
663
|
+
var s=ln.translateToString(true);
|
|
664
|
+
var re=/(?:~\\/|\\.{0,2}\\/)?[\\w.\\-\\/]*\\.[A-Za-z0-9]{1,8}(?::\\d+(?::\\d+)?)?/g;
|
|
665
|
+
var links=[], m;
|
|
666
|
+
while((m=re.exec(s))){
|
|
667
|
+
var raw=m[0]; if(!raw || raw.indexOf('://')>=0 || raw.slice(0,2)==='//') continue; // URL 은 WebLinksAddon 담당
|
|
668
|
+
var before = m.index>0 ? s.charAt(m.index-1) : ' ';
|
|
669
|
+
if(before===':' || before==='/' || /[A-Za-z0-9]/.test(before)) continue; // URL 조각·토큰 중간 배제
|
|
670
|
+
var pathPart=raw.replace(/:\\d+(?::\\d+)?$/,'');
|
|
671
|
+
var ext=(pathPart.split('.').pop()||'').toLowerCase();
|
|
672
|
+
if(!(VIEW_EXT[ext] || pathPart.indexOf('/')>=0)) continue; // 오탐 축소: 알려진 확장자거나 경로형
|
|
673
|
+
var sx=m.index+1, ex=m.index+raw.length;
|
|
674
|
+
links.push({ text:raw, range:{ start:{x:sx,y:y}, end:{x:ex,y:y} },
|
|
675
|
+
activate:function(ev, txt){ openPathFromTerm(runId, txt); } });
|
|
676
|
+
}
|
|
677
|
+
cb(links.length?links:undefined);
|
|
678
|
+
}}); }catch(e){}
|
|
612
679
|
// 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
|
|
613
680
|
// ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
|
|
614
681
|
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
|
|
@@ -629,6 +696,102 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
629
696
|
tabs[runId]=t; tabOrder.push(runId);
|
|
630
697
|
return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
|
|
631
698
|
}
|
|
699
|
+
|
|
700
|
+
// ── 파일 뷰어 탭(비터미널) — md/html/pdf/이미지/텍스트 보기 + .env 등 편집 ──
|
|
701
|
+
function ensureViewer(path, name){
|
|
702
|
+
var id='v'+(viewerSeq++);
|
|
703
|
+
var host=document.createElement('div'); host.className='view-host';
|
|
704
|
+
var t={ runId:id, name:name||(path.split('/').pop()||path), kind:'viewer', term:null, ws:null, host:host, path:path, closing:false, opened:false };
|
|
705
|
+
tabs[id]=t; tabOrder.push(id);
|
|
706
|
+
renderViewer(t);
|
|
707
|
+
return t;
|
|
708
|
+
}
|
|
709
|
+
function openViewer(path, name){ var t=ensureViewer(path, name); openTab(t.runId); }
|
|
710
|
+
// 뷰어 대상 확장자(터미널 경로 링크 오탐 축소용)
|
|
711
|
+
var VIEW_EXT = (function(){ var o={}; ('md markdown mdx html htm pdf png jpg jpeg gif webp svg bmp ico avif txt env json jsonc yaml yml toml ini conf cfg log csv tsv xml sql sh bash zsh js cjs mjs ts tsx jsx css scss less py rb php go rs java c h cpp hpp swift kt lua pl r dart vue svelte').split(' ').forEach(function(e){o[e]=1;}); return o; })();
|
|
712
|
+
var fsHome = '';
|
|
713
|
+
try{ fetch('/api/fs/list').then(function(r){return r.json();}).then(function(d){ fsHome=d.home||''; }).catch(function(){}); }catch(e){}
|
|
714
|
+
// 터미널에서 클릭한 경로 → 절대경로로 해석 후 뷰어 페인. 상대경로는 그 run 의 작업폴더(cwd) 기준.
|
|
715
|
+
function openPathFromTerm(runId, raw){
|
|
716
|
+
var p=(raw||'').replace(/:\\d+(?::\\d+)?$/,''); // :line:col 제거
|
|
717
|
+
var abs;
|
|
718
|
+
if(p.charAt(0)==='/') abs=p;
|
|
719
|
+
else if(p.slice(0,2)==='~/') abs=(fsHome||'').replace(/\\/$/,'')+p.slice(1);
|
|
720
|
+
else { var r=runById[runId]; var cwd=r&&r.worktreePath; if(!cwd){ toast('작업 폴더를 몰라 경로를 열 수 없습니다'); return; } abs=cwd.replace(/\\/$/,'')+'/'+p; }
|
|
721
|
+
openViewer(abs);
|
|
722
|
+
}
|
|
723
|
+
function fmtSize(n){ return n<1024?(n+' B'):n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'); }
|
|
724
|
+
var MD_FRAME_CSS = 'body{margin:0 auto;padding:40px 28px 80px;background:#0b0d12;color:#dee4ec;font:14px/1.7 -apple-system,BlinkMacSystemFont,\\'Apple SD Gothic Neo\\',\\'Noto Sans KR\\',sans-serif;max-width:740px}'
|
|
725
|
+
+ 'a{color:#4ec9b0}h1,h2,h3{color:#fff;line-height:1.3}h1{border-bottom:1px solid #2c3444;padding-bottom:.3em}h2{border-bottom:1px solid #232a36;padding-bottom:.25em}'
|
|
726
|
+
+ 'code{background:#1c212c;padding:.15em .4em;border-radius:4px;font-family:ui-monospace,Menlo,monospace;font-size:.9em}'
|
|
727
|
+
+ 'pre{background:#12161d;padding:12px 14px;border-radius:8px;overflow:auto}pre code{background:none;padding:0}'
|
|
728
|
+
+ 'blockquote{margin:0;padding:.2em 1em;border-left:3px solid #4ec9b0;color:#9aa4b2}'
|
|
729
|
+
+ 'table{border-collapse:collapse}th,td{border:1px solid #2c3444;padding:6px 10px}img{max-width:100%}hr{border:0;border-top:1px solid #2c3444}';
|
|
730
|
+
function renderViewer(t){
|
|
731
|
+
var host=t.host;
|
|
732
|
+
host.innerHTML='<div class="view-bar"><span class="vp"></span></div><div class="view-body"><div class="view-msg">불러오는 중…</div></div>';
|
|
733
|
+
var bar=host.querySelector('.view-bar'), body=host.querySelector('.view-body');
|
|
734
|
+
bar.querySelector('.vp').textContent=t.path;
|
|
735
|
+
var rawUrl='/api/fs/raw?path='+encodeURIComponent(t.path);
|
|
736
|
+
fetch('/api/fs/read?path='+encodeURIComponent(t.path)).then(function(r){ return r.json(); }).then(function(d){
|
|
737
|
+
if(d.error){ body.innerHTML='<div class="view-msg">열 수 없습니다: '+esc(d.error)+'</div>'; return; }
|
|
738
|
+
// 공통 액션: 원본 열기(새 탭/시스템)
|
|
739
|
+
var actions='<button data-act="raw" title="원본을 새 탭/시스템 뷰어로">↗ 원본</button>';
|
|
740
|
+
if(d.kind==='md' || d.kind==='html' || d.kind==='pdf' || d.kind==='image'){
|
|
741
|
+
bar.innerHTML='<span class="vp"></span>'+actions; bar.querySelector('.vp').textContent=t.path;
|
|
742
|
+
}
|
|
743
|
+
if(d.kind==='md'){
|
|
744
|
+
var html='<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>'+MD_FRAME_CSS+'</style></head><body>'
|
|
745
|
+
+ ((window.marked&&window.marked.parse)?window.marked.parse(d.text||''):esc(d.text||'')) + '</body></html>';
|
|
746
|
+
var f=document.createElement('iframe'); f.setAttribute('sandbox','allow-popups allow-popups-to-escape-sandbox'); f.srcdoc=html;
|
|
747
|
+
body.innerHTML=''; body.appendChild(f);
|
|
748
|
+
} else if(d.kind==='html'){
|
|
749
|
+
// 저장된/임의 HTML 은 신뢰 불가 → same-origin 금지 sandbox(불투명 출처라 쿠키·API 접근 차단)
|
|
750
|
+
var fh=document.createElement('iframe'); fh.setAttribute('sandbox','allow-scripts allow-popups allow-popups-to-escape-sandbox'); fh.src=rawUrl;
|
|
751
|
+
body.innerHTML=''; body.appendChild(fh);
|
|
752
|
+
} else if(d.kind==='pdf'){
|
|
753
|
+
var fp=document.createElement('iframe'); fp.src=rawUrl; // 브라우저/Electron 내장 PDF 뷰어(수동적 — sandbox 불필요)
|
|
754
|
+
body.innerHTML=''; body.appendChild(fp);
|
|
755
|
+
} else if(d.kind==='image'){
|
|
756
|
+
body.innerHTML=''; var im=document.createElement('img'); im.src=rawUrl; im.alt=t.name; body.appendChild(im);
|
|
757
|
+
} else if(d.kind==='binary'){
|
|
758
|
+
body.innerHTML='<div class="view-msg">미리보기 불가 · '+fmtSize(d.size||0)+(d.note?(' · '+esc(d.note)):'')+'<br><br><a href="'+rawUrl+'" target="_blank">원본 열기 / 내려받기</a></div>';
|
|
759
|
+
} else { // text
|
|
760
|
+
if(d.editable){ actions='<button data-act="edit" class="prim">편집</button>'+actions; }
|
|
761
|
+
bar.innerHTML='<span class="vp"></span>'+actions; bar.querySelector('.vp').textContent=t.path;
|
|
762
|
+
var pre=document.createElement('pre'); pre.className='vtext'; pre.textContent=d.text||''; body.innerHTML=''; body.appendChild(pre);
|
|
763
|
+
t._text=d.text||''; t._editable=!!d.editable;
|
|
764
|
+
}
|
|
765
|
+
}).catch(function(){ body.innerHTML='<div class="view-msg">불러오기 실패</div>'; });
|
|
766
|
+
|
|
767
|
+
// 바 액션(위임)
|
|
768
|
+
bar.addEventListener('click', function(e){
|
|
769
|
+
var b=e.target.closest('button[data-act]'); if(!b) return;
|
|
770
|
+
var act=b.getAttribute('data-act');
|
|
771
|
+
if(act==='raw'){ window.open(rawUrl,'_blank','noopener'); return; }
|
|
772
|
+
if(act==='edit'){ startEdit(t); return; }
|
|
773
|
+
if(act==='save'){ saveEdit(t); return; }
|
|
774
|
+
if(act==='cancel'){ renderViewer(t); return; }
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
function startEdit(t){
|
|
778
|
+
var bar=t.host.querySelector('.view-bar'), body=t.host.querySelector('.view-body');
|
|
779
|
+
bar.innerHTML='<span class="vp"></span><button data-act="save" class="prim">저장</button><button data-act="cancel">취소</button>';
|
|
780
|
+
bar.querySelector('.vp').textContent=t.path;
|
|
781
|
+
var ta=document.createElement('textarea'); ta.className='vedit'; ta.value=t._text||''; ta.spellcheck=false;
|
|
782
|
+
body.innerHTML=''; body.appendChild(ta); ta.focus();
|
|
783
|
+
ta.addEventListener('keydown', function(e){ if((e.metaKey||e.ctrlKey)&&e.key==='s'){ e.preventDefault(); saveEdit(t); } });
|
|
784
|
+
t._ta=ta;
|
|
785
|
+
}
|
|
786
|
+
function saveEdit(t){
|
|
787
|
+
var ta=t._ta; if(!ta) return; var content=ta.value;
|
|
788
|
+
fetch('/api/fs/write',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:t.path,content:content})})
|
|
789
|
+
.then(function(r){ return r.json(); }).then(function(d){
|
|
790
|
+
if(d.error){ toast('저장 실패: '+d.error); return; }
|
|
791
|
+
t._text=content; toast('저장됨 · '+t.name+' ('+fmtSize(d.size||0)+')'); renderViewer(t);
|
|
792
|
+
}).catch(function(){ toast('저장 실패'); });
|
|
793
|
+
}
|
|
794
|
+
|
|
632
795
|
function connectTab(t){
|
|
633
796
|
if (t.closing || !t.term) return;
|
|
634
797
|
try{ t.fit.fit(); }catch(e){}
|
|
@@ -652,14 +815,22 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
652
815
|
}
|
|
653
816
|
|
|
654
817
|
function fitTab(t){ if(!t||!t.fit||!t.term) return; try{ t.fit.fit(); if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'r',cols:t.term.cols,rows:t.term.rows})); }catch(e){} }
|
|
655
|
-
function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
|
|
818
|
+
function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].kind!=='viewer' && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
|
|
656
819
|
|
|
657
820
|
// ── 탭 바 렌더(터미널 없음 — 언제든 안전) ──
|
|
658
821
|
function renderTabs(){
|
|
659
822
|
var shown={}; eachLeaf(layout,function(l){ if(l.tab!=null) shown[l.tab]=true; });
|
|
660
823
|
var html='';
|
|
661
824
|
tabOrder.forEach(function(runId){
|
|
662
|
-
var t=tabs[runId]; if(!t) return;
|
|
825
|
+
var t=tabs[runId]; if(!t) return;
|
|
826
|
+
if(t.kind==='viewer'){
|
|
827
|
+
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="파일 뷰어 · 드래그=페인에 배치">'
|
|
828
|
+
+ '<span class="st vdoc">▤</span>'
|
|
829
|
+
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
830
|
+
+ '<button class="x" title="뷰어 닫기">×</button></div>';
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
var r=runById[runId];
|
|
663
834
|
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="더블클릭=이름변경 · 드래그=페인에 배치">'
|
|
664
835
|
+ '<span class="st '+esc(r?r.status:'')+'"></span>'
|
|
665
836
|
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
@@ -673,11 +844,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
673
844
|
var t=node.tab!=null?tabs[node.tab]:null; var r=node.tab!=null?runById[node.tab]:null;
|
|
674
845
|
var head=document.createElement('div'); head.className='leaf-h'; head.dataset.leafhead=node.id;
|
|
675
846
|
head.innerHTML = t
|
|
676
|
-
?
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
847
|
+
? (t.kind==='viewer'
|
|
848
|
+
? '<span class="st vdoc">▤</span><span class="nm">'+esc(t.name)+'</span>'
|
|
849
|
+
+ '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
|
|
850
|
+
: '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
851
|
+
+ '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
|
|
852
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
853
|
+
+ '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
|
|
854
|
+
+ '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
|
|
681
855
|
: '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
|
|
682
856
|
var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
|
|
683
857
|
if (!t){ var em=document.createElement('div'); em.className='leaf-empty'; em.textContent='탭을 여기로 드래그하거나 탭을 클릭하세요'; body.appendChild(em); }
|
|
@@ -692,6 +866,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
692
866
|
}
|
|
693
867
|
// host 를 DOM 에 붙이고, 최초 1회 open → connect(Phase 2 순서). fit 은 render 의 rAF 에서.
|
|
694
868
|
function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return; body.appendChild(t.host);
|
|
869
|
+
if(t.kind==='viewer') return; // 뷰어는 DOM 만 붙이면 끝(터미널 open/connect 없음)
|
|
695
870
|
if(!t.opened){ try{ t.term.open(t.host); t.opened=true; }catch(e){} }
|
|
696
871
|
if(!t.connected){ t.connected=true; connectTab(t); }
|
|
697
872
|
}); }
|
|
@@ -715,14 +890,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
715
890
|
function setLeafFocus(id){
|
|
716
891
|
focusLeaf=id;
|
|
717
892
|
Array.prototype.forEach.call($('panes').querySelectorAll('.leaf'),function(el){ el.classList.toggle('focus', el.dataset.leaf===id); });
|
|
718
|
-
var rid=focusedRunId(); if(rid!=null && tabs[rid]) try{ tabs[rid].term.focus(); }catch(e){}
|
|
893
|
+
var rid=focusedRunId(); if(rid!=null && tabs[rid] && tabs[rid].term) try{ tabs[rid].term.focus(); }catch(e){}
|
|
719
894
|
updateControls();
|
|
720
895
|
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
721
896
|
}
|
|
722
897
|
|
|
723
898
|
// 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
|
|
724
899
|
function openTab(runId){
|
|
725
|
-
ensureTab(runId);
|
|
900
|
+
if(!tabs[runId]) ensureTab(runId); // 뷰어 탭은 ensureViewer 로 이미 생성됨 → 터미널로 오생성 방지
|
|
726
901
|
var l=leafOfTab(runId);
|
|
727
902
|
if (l){ setLeafFocus(l.id); return; }
|
|
728
903
|
var f=findLeaf(focusLeaf) || firstLeaf();
|
|
@@ -757,7 +932,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
757
932
|
var t=tabs[runId]; if(!t) return; t.closing=true;
|
|
758
933
|
try{ if(t.ws) t.ws.close(); }catch(e){}
|
|
759
934
|
try{ if(t.ro) t.ro.disconnect(); }catch(e){}
|
|
760
|
-
try{ t.term.dispose(); }catch(e){}
|
|
935
|
+
try{ if(t.term) t.term.dispose(); }catch(e){}
|
|
761
936
|
eachLeaf(layout,function(l){ if(l.tab===runId) l.tab=null; });
|
|
762
937
|
delete tabs[runId]; tabOrder=tabOrder.filter(function(x){return x!==runId;});
|
|
763
938
|
render(); renderTree();
|
|
@@ -779,8 +954,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
779
954
|
|
|
780
955
|
// fleet 갱신 시 라벨·상태만 갱신(페인 DOM 재구성 X — 터미널 유지). 사라진 run 탭 정리.
|
|
781
956
|
function syncPanes(){
|
|
782
|
-
tabOrder.slice().forEach(function(runId){ if(!runById[runId]) closeTab(runId); });
|
|
783
|
-
tabOrder.forEach(function(runId){ var t=tabs[runId]; if(t) t.name=tabName(runId); });
|
|
957
|
+
tabOrder.slice().forEach(function(runId){ if(!isViewer(runId) && !runById[runId]) closeTab(runId); }); // 뷰어는 run 이 없어도 유지
|
|
958
|
+
tabOrder.forEach(function(runId){ var t=tabs[runId]; if(t && t.kind!=='viewer') t.name=tabName(runId); });
|
|
784
959
|
renderTabs();
|
|
785
960
|
eachLeaf(layout,function(l){
|
|
786
961
|
if(l.tab==null) return; var r=runById[l.tab]; if(!r) return;
|
|
@@ -819,12 +994,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
819
994
|
|
|
820
995
|
// ── 탭 바 이벤트 ──
|
|
821
996
|
$('tabs').addEventListener('click', function(e){
|
|
822
|
-
var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; var runId
|
|
997
|
+
var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; var runId=tabIdOf(tabEl);
|
|
823
998
|
if (e.target.closest('.x')){ closeTab(runId); return; }
|
|
824
999
|
openTab(runId);
|
|
825
1000
|
});
|
|
826
|
-
$('tabs').addEventListener('dblclick', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) startRename(
|
|
827
|
-
$('tabs').addEventListener('dragstart', function(e){ var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; dragRunId
|
|
1001
|
+
$('tabs').addEventListener('dblclick', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) startRename(tabIdOf(tabEl), tabEl); });
|
|
1002
|
+
$('tabs').addEventListener('dragstart', function(e){ var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; dragRunId=tabIdOf(tabEl); tabEl.classList.add('drag'); try{ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', String(dragRunId)); }catch(_){} });
|
|
828
1003
|
$('tabs').addEventListener('dragend', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) tabEl.classList.remove('drag'); dragRunId=null; });
|
|
829
1004
|
|
|
830
1005
|
// ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
|
|
@@ -884,7 +1059,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
884
1059
|
|
|
885
1060
|
// ── 뷰어 — 위 내용을 (대화) Claude Code 로그 / (터미널) 스크롤백 으로 읽기(읽기 전용) ──
|
|
886
1061
|
var histMode='chat';
|
|
887
|
-
function openHistory(){ var rid=focusedRunId(); if(rid==null){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
1062
|
+
function openHistory(){ var rid=focusedRunId(); if(rid==null||isViewer(rid)){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
888
1063
|
function closeHistory(){ $('histModal').classList.remove('on'); }
|
|
889
1064
|
function setHistMode(m){
|
|
890
1065
|
histMode=m; $('hmChat').classList.toggle('on',m==='chat'); $('hmRaw').classList.toggle('on',m==='raw');
|
|
@@ -925,6 +1100,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
925
1100
|
// ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
|
|
926
1101
|
var pickPathCur = '';
|
|
927
1102
|
function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
|
|
1103
|
+
async function deleteSession(runId){
|
|
1104
|
+
if(!confirm('이 세션을 삭제할까요?\\n터미널만 종료됩니다 · 폴더와 파일은 그대로 보존됩니다.')) return;
|
|
1105
|
+
try{
|
|
1106
|
+
var res=await fetch('/api/runs/'+runId,{method:'DELETE'});
|
|
1107
|
+
var j=await res.json().catch(function(){return{};});
|
|
1108
|
+
if(res.ok){ if(tabs[runId]) closeTab(runId); toast('세션 삭제됨 (폴더 보존)'); await hydrate(); }
|
|
1109
|
+
else toast('삭제 실패: '+(j.error||res.status));
|
|
1110
|
+
}catch(e){ toast('삭제 실패: '+e); }
|
|
1111
|
+
}
|
|
928
1112
|
function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
|
|
929
1113
|
function closePicker(){ $('pickModal').classList.remove('on'); }
|
|
930
1114
|
async function browseTo(p){
|
|
@@ -962,6 +1146,55 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
962
1146
|
});
|
|
963
1147
|
$('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
|
|
964
1148
|
$('sessionBtn').addEventListener('click', openSession);
|
|
1149
|
+
|
|
1150
|
+
// ── 파일 피커(뷰어로 열기) — 폴더=이동, 파일=뷰어 ──
|
|
1151
|
+
var fpDirCur = '';
|
|
1152
|
+
function openFilePicker(){ $('fpickModal').classList.add('on'); $('fpSearch').value=''; fpTo(fpDirCur||''); setTimeout(function(){ try{ $('fpSearch').focus(); }catch(e){} },30); }
|
|
1153
|
+
function closeFilePicker(){ $('fpickModal').classList.remove('on'); }
|
|
1154
|
+
function fpIcon(kind){ return kind==='md'?'M':kind==='html'?'H':kind==='pdf'?'P':kind==='image'?'I':kind==='binary'?'·':'T'; }
|
|
1155
|
+
async function fpTo(p){
|
|
1156
|
+
try{
|
|
1157
|
+
var d = await (await fetch('/api/fs/list'+(p?('?path='+encodeURIComponent(p)):''))).json();
|
|
1158
|
+
fpDirCur = d.path; $('fpPath').textContent = d.path; if($('fpHint')) $('fpHint').textContent='폴더=이동 · 파일=뷰어로 열기';
|
|
1159
|
+
var html='';
|
|
1160
|
+
if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-dir="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
|
|
1161
|
+
(d.entries||[]).forEach(function(e){
|
|
1162
|
+
var full = d.path==='/' ? '/'+e.name : d.path+'/'+e.name;
|
|
1163
|
+
if (e.dir) html += '<div class="pick-row" data-dir="'+esc(full)+'"><span class="ic">▸</span><span>'+esc(e.name)+'</span></div>';
|
|
1164
|
+
else html += '<div class="pick-row" data-file="'+esc(full)+'"><span class="ic">'+fpIcon(e.kind)+'</span><span>'+esc(e.name)+'</span></div>';
|
|
1165
|
+
});
|
|
1166
|
+
if (!(d.entries||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">'+(d.error?'읽을 수 없습니다':'항목 없음')+'</div>';
|
|
1167
|
+
$('fpList').innerHTML = html;
|
|
1168
|
+
}catch(e){ $('fpList').innerHTML = '<div class="pick-row" style="color:var(--failed)">읽을 수 없습니다</div>'; }
|
|
1169
|
+
}
|
|
1170
|
+
var fpFindT=null, fpFindSeq=0;
|
|
1171
|
+
async function fpFind(q){
|
|
1172
|
+
var seq=++fpFindSeq;
|
|
1173
|
+
try{
|
|
1174
|
+
var d = await (await fetch('/api/fs/find?path='+encodeURIComponent(fpDirCur||'')+'&q='+encodeURIComponent(q))).json();
|
|
1175
|
+
if(seq!==fpFindSeq) return; // 뒤늦게 도착한 오래된 응답 무시
|
|
1176
|
+
if($('fpHint')) $('fpHint').textContent = d.error ? d.error : (d.results.length+'개'+(d.truncated?'+':'')+' · '+ (fpDirCur||'') +' 아래');
|
|
1177
|
+
if(d.error){ $('fpList').innerHTML='<div class="pick-row" style="cursor:default;color:var(--faint)">'+esc(d.error)+'</div>'; return; }
|
|
1178
|
+
if(!d.results.length){ $('fpList').innerHTML='<div class="pick-row" style="cursor:default;color:var(--faint)">일치하는 파일 없음</div>'; return; }
|
|
1179
|
+
$('fpList').innerHTML = d.results.map(function(e){
|
|
1180
|
+
return '<div class="pick-row" data-file="'+esc(e.path)+'"><span class="ic">'+fpIcon(e.kind)+'</span><span>'+esc(e.name)+'</span><span class="rel">'+esc(e.rel)+'</span></div>';
|
|
1181
|
+
}).join('');
|
|
1182
|
+
}catch(e){ if(seq===fpFindSeq) $('fpList').innerHTML='<div class="pick-row" style="color:var(--failed)">검색 실패</div>'; }
|
|
1183
|
+
}
|
|
1184
|
+
$('fpSearch').addEventListener('input', function(){
|
|
1185
|
+
var q=this.value.trim();
|
|
1186
|
+
if(fpFindT){ clearTimeout(fpFindT); fpFindT=null; }
|
|
1187
|
+
if(q.length<2){ fpTo(fpDirCur); return; } // 비우면 현재 폴더 목록으로 복귀
|
|
1188
|
+
fpFindT=setTimeout(function(){ fpFindT=null; fpFind(q); }, 220);
|
|
1189
|
+
});
|
|
1190
|
+
$('fpList').addEventListener('click', function(e){
|
|
1191
|
+
var dir=e.target.closest('[data-dir]'); if(dir){ $('fpSearch').value=''; fpTo(dir.dataset.dir); return; }
|
|
1192
|
+
var file=e.target.closest('[data-file]'); if(file){ closeFilePicker(); openViewer(file.dataset.file); if(isMobile()) setDrawer(false); }
|
|
1193
|
+
});
|
|
1194
|
+
$('fpClose').addEventListener('click', closeFilePicker);
|
|
1195
|
+
$('fpHome').addEventListener('click', function(){ $('fpSearch').value=''; fpTo(''); });
|
|
1196
|
+
$('fpickModal').addEventListener('click', function(e){ if(e.target===this) closeFilePicker(); });
|
|
1197
|
+
$('fileBtn').addEventListener('click', openFilePicker);
|
|
965
1198
|
$('sessionCta').addEventListener('click', openSession);
|
|
966
1199
|
|
|
967
1200
|
// ── (A) 시크릿 볼트 — 세션 env 주입 ──
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// File viewer/edit backing (owner-only tool). Scoped to the daemon user's home
|
|
2
|
+
// directory — reading file *contents* is higher-risk than /api/browse's dir listing,
|
|
3
|
+
// so we jail here. All real work (repos, worktrees, sessions, ~/services) lives under ~.
|
|
4
|
+
import { readdir, stat, readFile, writeFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { resolve as presolve, dirname as pdirname, join as pjoin, basename as pbasename, extname as pextname } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const HOME = presolve(homedir());
|
|
9
|
+
const MAX_TEXT = 2 * 1024 * 1024; // read as text up to 2MB
|
|
10
|
+
const MAX_WRITE = 512 * 1024; // edit-save cap (.env etc. are tiny)
|
|
11
|
+
|
|
12
|
+
export type FileKind = 'md' | 'html' | 'pdf' | 'image' | 'text' | 'binary';
|
|
13
|
+
|
|
14
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
|
|
15
|
+
const MD_EXT = new Set(['.md', '.markdown', '.mdx']);
|
|
16
|
+
const HTML_EXT = new Set(['.html', '.htm']);
|
|
17
|
+
// recognized text families (default is a NUL-byte sniff, this just short-circuits)
|
|
18
|
+
const TEXT_EXT = new Set([
|
|
19
|
+
'.txt', '.env', '.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg',
|
|
20
|
+
'.log', '.csv', '.tsv', '.xml', '.sql', '.sh', '.bash', '.zsh', '.fish',
|
|
21
|
+
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.css', '.scss', '.less',
|
|
22
|
+
'.py', '.rb', '.php', '.go', '.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.cc',
|
|
23
|
+
'.swift', '.kt', '.lua', '.pl', '.r', '.dart', '.vue', '.svelte', '.gradle',
|
|
24
|
+
'.gitignore', '.dockerignore', '.editorconfig', '.properties', '.svg',
|
|
25
|
+
]);
|
|
26
|
+
const TEXT_BASENAME = new Set(['dockerfile', 'makefile', 'readme', 'license', 'procfile', '.env', '.gitignore', '.npmrc', '.prettierrc', '.eslintrc']);
|
|
27
|
+
|
|
28
|
+
const MIME: Record<string, string> = {
|
|
29
|
+
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
|
|
31
|
+
'.ico': 'image/x-icon', '.avif': 'image/avif', '.html': 'text/html', '.htm': 'text/html',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function ext(p: string) { return pextname(p).toLowerCase(); }
|
|
35
|
+
|
|
36
|
+
export function classify(p: string): FileKind {
|
|
37
|
+
const e = ext(p);
|
|
38
|
+
const base = pbasename(p).toLowerCase();
|
|
39
|
+
if (MD_EXT.has(e)) return 'md';
|
|
40
|
+
if (HTML_EXT.has(e)) return 'html';
|
|
41
|
+
if (e === '.pdf') return 'pdf';
|
|
42
|
+
if (e === '.svg') return 'image'; // render, but also text-editable via read()
|
|
43
|
+
if (IMAGE_EXT.has(e)) return 'image';
|
|
44
|
+
if (TEXT_EXT.has(e) || TEXT_BASENAME.has(base) || base.startsWith('.env')) return 'text';
|
|
45
|
+
return 'binary'; // decided for real in read() via NUL sniff
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mimeFor(p: string): string {
|
|
49
|
+
return MIME[ext(p)] || 'application/octet-stream';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reject anything that escapes HOME (symlink-safe via realpath on the closest existing ancestor).
|
|
53
|
+
async function jail(input?: string): Promise<string> {
|
|
54
|
+
const p = presolve(input && input.startsWith('/') ? input : HOME);
|
|
55
|
+
// realpath the deepest existing ancestor so a missing leaf (new file) still validates its dir
|
|
56
|
+
let probe = p;
|
|
57
|
+
for (;;) {
|
|
58
|
+
try { const rp = await realpath(probe); const rest = p.slice(probe.length); const full = presolve(rp + rest);
|
|
59
|
+
if (full !== HOME && !full.startsWith(HOME + '/')) throw new Error('outside home');
|
|
60
|
+
return full;
|
|
61
|
+
} catch (e: any) {
|
|
62
|
+
if (e && e.message === 'outside home') throw e;
|
|
63
|
+
const parent = pdirname(probe);
|
|
64
|
+
if (parent === probe) { if (p !== HOME && !p.startsWith(HOME + '/')) throw new Error('outside home'); return p; }
|
|
65
|
+
probe = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function listDir(input?: string) {
|
|
71
|
+
const path = await jail(input);
|
|
72
|
+
const entries: Array<{ name: string; dir: boolean; size: number; kind?: FileKind }> = [];
|
|
73
|
+
let error: string | undefined;
|
|
74
|
+
try {
|
|
75
|
+
const items = await readdir(path, { withFileTypes: true });
|
|
76
|
+
for (const it of items) {
|
|
77
|
+
// hide most dotfiles, but keep .env* visible (the one text file they edit)
|
|
78
|
+
if (it.name.startsWith('.') && !it.name.startsWith('.env')) continue;
|
|
79
|
+
const dir = it.isDirectory();
|
|
80
|
+
let size = 0;
|
|
81
|
+
if (!dir) { try { size = (await stat(pjoin(path, it.name))).size; } catch { size = 0; } }
|
|
82
|
+
entries.push({ name: it.name, dir, size, kind: dir ? undefined : classify(it.name) });
|
|
83
|
+
if (entries.length >= 500) break;
|
|
84
|
+
}
|
|
85
|
+
entries.sort((a, b) => (b.dir ? 1 : 0) - (a.dir ? 1 : 0) || a.name.localeCompare(b.name));
|
|
86
|
+
} catch { error = 'cannot read directory'; }
|
|
87
|
+
return { path, parent: pdirname(path), home: HOME, entries, error };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recursive filename search under a folder (bounded). For the picker's search box.
|
|
91
|
+
const FIND_SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', 'venv', '.venv', '__pycache__', '.turbo', 'coverage', '.gradle', 'target']);
|
|
92
|
+
export async function findFiles(input: string | undefined, q: string, limit = 300) {
|
|
93
|
+
const root = await jail(input);
|
|
94
|
+
const needle = (q || '').trim().toLowerCase();
|
|
95
|
+
if (needle.length < 2) return { root, q: needle, results: [] as any[], error: 'query too short (min 2 chars)' };
|
|
96
|
+
const out: Array<{ path: string; rel: string; name: string; size: number; kind: FileKind }> = [];
|
|
97
|
+
async function walk(dir: string, depth: number): Promise<void> {
|
|
98
|
+
if (out.length >= limit || depth > 6) return;
|
|
99
|
+
let items;
|
|
100
|
+
try { items = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
101
|
+
for (const it of items) {
|
|
102
|
+
if (out.length >= limit) return;
|
|
103
|
+
if (it.name.startsWith('.') && !it.name.startsWith('.env')) continue;
|
|
104
|
+
const full = pjoin(dir, it.name);
|
|
105
|
+
if (it.isDirectory()) { if (!FIND_SKIP.has(it.name)) await walk(full, depth + 1); continue; }
|
|
106
|
+
if (it.name.toLowerCase().includes(needle)) {
|
|
107
|
+
let size = 0; try { size = (await stat(full)).size; } catch { /* skip */ }
|
|
108
|
+
out.push({ path: full, rel: full.slice(root.length + 1), name: it.name, size, kind: classify(it.name) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
await walk(root, 0);
|
|
113
|
+
const truncated = out.length >= limit;
|
|
114
|
+
out.sort((a, b) => a.rel.length - b.rel.length || a.rel.localeCompare(b.rel));
|
|
115
|
+
return { root, q: needle, results: out, truncated };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function readForView(input?: string) {
|
|
119
|
+
const path = await jail(input);
|
|
120
|
+
const st = await stat(path);
|
|
121
|
+
if (st.isDirectory()) throw new Error('is a directory');
|
|
122
|
+
const name = pbasename(path);
|
|
123
|
+
let kind = classify(path);
|
|
124
|
+
const size = st.size;
|
|
125
|
+
|
|
126
|
+
// For pdf/image/html the client fetches /api/fs/raw; no body needed here.
|
|
127
|
+
if (kind === 'pdf' || kind === 'image' || kind === 'html') {
|
|
128
|
+
return { path, name, kind, size, editable: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// text / md / unknown → try to read as text (NUL sniff decides 'binary')
|
|
132
|
+
if (size > MAX_TEXT) return { path, name, kind: 'binary' as FileKind, size, editable: false, note: 'too large to preview' };
|
|
133
|
+
const buf = await readFile(path);
|
|
134
|
+
if (buf.includes(0)) return { path, name, kind: 'binary' as FileKind, size, editable: false };
|
|
135
|
+
const text = buf.toString('utf8');
|
|
136
|
+
if (kind === 'binary') kind = 'text'; // unknown ext but no NUL → treat as text
|
|
137
|
+
const editable = (kind === 'text' || kind === 'md') && size <= MAX_WRITE;
|
|
138
|
+
return { path, name, kind, size, editable, text };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Raw bytes with correct content-type — this is what makes PDFs/images/html render.
|
|
142
|
+
export async function readRaw(input?: string) {
|
|
143
|
+
const path = await jail(input);
|
|
144
|
+
const st = await stat(path);
|
|
145
|
+
if (st.isDirectory()) throw new Error('is a directory');
|
|
146
|
+
return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function writeText(input: string, content: string) {
|
|
150
|
+
const path = await jail(input);
|
|
151
|
+
const st = await stat(path).catch(() => null);
|
|
152
|
+
if (st && st.isDirectory()) throw new Error('is a directory');
|
|
153
|
+
if (st && st.size > MAX_WRITE) throw new Error('file too large to edit here');
|
|
154
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE) throw new Error('content exceeds edit cap (512KB)');
|
|
155
|
+
const kind = classify(path);
|
|
156
|
+
if (kind === 'pdf' || kind === 'image' || kind === 'binary') throw new Error('not an editable text file');
|
|
157
|
+
await writeFile(path, content, 'utf8');
|
|
158
|
+
return { path, name: pbasename(path), size: Buffer.byteLength(content, 'utf8') };
|
|
159
|
+
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -1158,6 +1158,28 @@ export async function openSessionAt(machineSlug: string, path: string, title: st
|
|
|
1158
1158
|
return { ok: true, detail: 'session open', taskId: task.id, runId };
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* 세션 삭제 — tmux 종료 + run/task 레코드 제거(폴더는 보존). sessions 버킷 run 에만 허용.
|
|
1163
|
+
* (일반 task run 은 cleanup/reclaim 을 쓴다 — 삭제로 이력이 사라지면 안 되므로.)
|
|
1164
|
+
*/
|
|
1165
|
+
export async function deleteSession(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
1166
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
1167
|
+
const run = rr[0];
|
|
1168
|
+
if (!run) return { ok: false, detail: 'not found' };
|
|
1169
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
|
|
1170
|
+
const task = tr[0];
|
|
1171
|
+
const rp = task ? (await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1))[0] : undefined;
|
|
1172
|
+
if (!rp || rp.kind !== 'sessions') return { ok: false, detail: 'not a session (use cleanup/reclaim for task runs)' };
|
|
1173
|
+
await cleanupRun(runId).catch(() => { /* tmux kill + 포인터 비움; 실패해도 레코드는 지운다 */ });
|
|
1174
|
+
await db.delete(agentEvents).where(eq(agentEvents.runId, runId));
|
|
1175
|
+
await db.delete(agentRuns).where(eq(agentRuns.id, runId));
|
|
1176
|
+
// 세션 task 는 run 과 1:1 — 남은 run 이 없으면 task 도 제거해 트리에서 사라지게.
|
|
1177
|
+
const siblings = await db.select().from(agentRuns).where(eq(agentRuns.taskId, run.taskId));
|
|
1178
|
+
if (task && siblings.length === 0) await db.delete(tasks).where(eq(tasks.id, task.id));
|
|
1179
|
+
broadcast({ type: 'run', runId, deleted: true }); // 모든 콘솔이 재하이드레이트 → 행 제거
|
|
1180
|
+
return { ok: true, detail: 'session deleted (folder preserved)' };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1161
1183
|
/**
|
|
1162
1184
|
* 그룹에 속한 태스크 1개를 만들고 run 1개를 발사한다(공용 helper).
|
|
1163
1185
|
* planFanout(plan 형제) 과 /api/groups/:id/spawn(+New attempt) 이 공유하는
|
package/src/server.ts
CHANGED
|
@@ -19,13 +19,14 @@ import { db } from './db';
|
|
|
19
19
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
|
|
20
20
|
import { BOOKMARKLET_JS } from './design';
|
|
21
21
|
import { runShellOn, shq } from './exec';
|
|
22
|
-
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, verifyRun, openSessionAt, getScrollback, getSessionChat } from './orchestrator';
|
|
22
|
+
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, verifyRun, openSessionAt, deleteSession, getScrollback, getSessionChat } from './orchestrator';
|
|
23
23
|
import { openTerm } from './term';
|
|
24
24
|
import { addSink, removeSink, broadcast } from './hub';
|
|
25
25
|
import { getProvider, listProviders } from './providers';
|
|
26
26
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
27
27
|
import { BOARD_HTML } from './board';
|
|
28
28
|
import { COCKPIT_HTML } from './cockpit';
|
|
29
|
+
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles } from './files';
|
|
29
30
|
|
|
30
31
|
const require_ = createRequire(import.meta.url);
|
|
31
32
|
|
|
@@ -36,6 +37,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
36
37
|
'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
|
|
37
38
|
'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
|
|
38
39
|
'addon-web-links.js': { pkg: '@xterm/addon-web-links/package.json', rel: 'lib/addon-web-links.js', type: 'text/javascript' },
|
|
40
|
+
'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
// ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
|
|
@@ -743,6 +745,43 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
743
745
|
return { path: p, parent: pdirname(p), home: homedir(), isRepo: existsSync(pjoin(p, '.git')), dirs, error };
|
|
744
746
|
});
|
|
745
747
|
|
|
748
|
+
// ─── File viewer (md/html/pdf/image/text 보기 + .env 등 작은 텍스트 편집) ──────
|
|
749
|
+
// 홈 디렉터리로 감옥(files.ts). owner 전용 도구지만 파일 *내용* 읽기는 browse 보다 위험 → 잼.
|
|
750
|
+
app.get('/api/fs/list', async (req) => {
|
|
751
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
752
|
+
try { return await fsListDir(q.path); }
|
|
753
|
+
catch { return { path: q.path ?? '', parent: '', home: homedir(), entries: [], error: 'outside home or unreadable' }; }
|
|
754
|
+
});
|
|
755
|
+
app.get('/api/fs/find', async (req) => {
|
|
756
|
+
const q = (req.query ?? {}) as { path?: string; q?: string };
|
|
757
|
+
try { return await fsFindFiles(q.path, q.q ?? ''); }
|
|
758
|
+
catch { return { root: q.path ?? '', q: q.q ?? '', results: [], error: 'outside home or unreadable' }; }
|
|
759
|
+
});
|
|
760
|
+
app.get('/api/fs/read', async (req, reply) => {
|
|
761
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
762
|
+
if (!q.path) return reply.code(400).send({ error: 'path required' });
|
|
763
|
+
try { return await fsReadForView(q.path); }
|
|
764
|
+
catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
765
|
+
});
|
|
766
|
+
app.get('/api/fs/raw', async (req, reply) => {
|
|
767
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
768
|
+
if (!q.path) return reply.code(400).send({ error: 'path required' });
|
|
769
|
+
try {
|
|
770
|
+
const { name, mime, buf } = await fsReadRaw(q.path);
|
|
771
|
+
// inline so the browser/Electron renders (PDF plugin, <img>, sandboxed <iframe>) instead of downloading
|
|
772
|
+
return reply.type(mime)
|
|
773
|
+
.header('content-disposition', 'inline; filename="' + name.replace(/[^\w.\- ]/g, '_') + '"')
|
|
774
|
+
.header('x-content-type-options', 'nosniff')
|
|
775
|
+
.send(buf);
|
|
776
|
+
} catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
777
|
+
});
|
|
778
|
+
app.post('/api/fs/write', async (req, reply) => {
|
|
779
|
+
const b = (req.body ?? {}) as { path?: string; content?: string };
|
|
780
|
+
if (!b.path || typeof b.content !== 'string') return reply.code(400).send({ error: 'path and content required' });
|
|
781
|
+
try { return await fsWriteText(b.path, b.content); }
|
|
782
|
+
catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
783
|
+
});
|
|
784
|
+
|
|
746
785
|
// ─── Design Mode ───────────────────────────────────────────────
|
|
747
786
|
// 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
|
|
748
787
|
const captureKeyOk = (req: { query?: unknown }): boolean => {
|
|
@@ -964,6 +1003,15 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
964
1003
|
return res;
|
|
965
1004
|
});
|
|
966
1005
|
|
|
1006
|
+
// 세션 삭제(tmux 종료 + 레코드 제거, 폴더 보존). sessions 버킷 run 에만 허용.
|
|
1007
|
+
app.delete('/api/runs/:id', async (req, reply) => {
|
|
1008
|
+
const id = Number((req.params as { id: string }).id);
|
|
1009
|
+
if (!Number.isInteger(id)) return reply.code(400).send({ error: 'bad id' });
|
|
1010
|
+
const res = await deleteSession(id);
|
|
1011
|
+
if (!res.ok) return reply.code(res.detail === 'not found' ? 404 : 400).send({ error: res.detail });
|
|
1012
|
+
return res;
|
|
1013
|
+
});
|
|
1014
|
+
|
|
967
1015
|
// v5.1 Part C foundation — resolve the land target + base drift for a run.
|
|
968
1016
|
// ?fetch=1 refreshes the remote first (network) so ahead/behind are current.
|
|
969
1017
|
app.get('/api/runs/:id/land-target', async (req, reply) => {
|