coxpit 5.18.0 → 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 +1 -1
- package/src/cockpit.ts +82 -8
- package/src/files.ts +28 -0
- package/src/orchestrator.ts +22 -0
- package/src/server.ts +16 -2
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",
|
package/src/cockpit.ts
CHANGED
|
@@ -144,7 +144,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
144
144
|
.view-bar button:hover{border-color:var(--brand)}
|
|
145
145
|
.view-bar .prim{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
|
|
146
146
|
.view-body{flex:1;min-height:0;overflow:auto;background:var(--bg);position:relative}
|
|
147
|
-
.view-body
|
|
147
|
+
.view-body:has(iframe){overflow:hidden} /* iframe 이 자체 스크롤 — 바깥 view-body 는 스크롤 금지(이중 스크롤바 방지) */
|
|
148
|
+
.view-body iframe{display:block;width:100%;height:100%;border:0;background:#fff}
|
|
148
149
|
.view-body img{max-width:100%;display:block;margin:0 auto;padding:12px}
|
|
149
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}
|
|
150
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)}
|
|
@@ -189,7 +190,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
189
190
|
.pick-h .x{margin-left:auto;background:none;border:none;color:var(--faint);font-size:16px;cursor:pointer}
|
|
190
191
|
.pick-h .x:hover{color:var(--ink)}
|
|
191
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)}
|
|
192
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%}
|
|
193
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)}
|
|
194
199
|
.pick-row:hover{background:var(--surface2);color:var(--ink)}
|
|
195
200
|
.pick-row .ic{width:14px;text-align:center;color:var(--faint)}
|
|
@@ -216,6 +221,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
216
221
|
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
217
222
|
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
218
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}
|
|
219
227
|
|
|
220
228
|
.toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
|
|
221
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}
|
|
@@ -432,10 +440,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
432
440
|
<div class="pick">
|
|
433
441
|
<div class="pick-h"><span class="t">파일 보기</span><button class="x" id="fpClose" title="닫기">×</button></div>
|
|
434
442
|
<div class="pick-path" id="fpPath">…</div>
|
|
443
|
+
<div class="fp-search"><input id="fpSearch" placeholder="이 폴더 아래에서 이름으로 검색 (2자 이상)" autocomplete="off" spellcheck="false" /></div>
|
|
435
444
|
<div class="pick-list" id="fpList"></div>
|
|
436
445
|
<div class="pick-f">
|
|
437
446
|
<button class="home" id="fpHome" title="홈으로">⌂ home</button>
|
|
438
|
-
<span style="flex:1;font-size:11px;color:var(--faint)">폴더=이동 · 파일=뷰어로 열기</span>
|
|
447
|
+
<span id="fpHint" style="flex:1;font-size:11px;color:var(--faint)">폴더=이동 · 파일=뷰어로 열기</span>
|
|
439
448
|
</div>
|
|
440
449
|
</div>
|
|
441
450
|
</div>
|
|
@@ -544,7 +553,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
544
553
|
sessRuns.forEach(function(s){
|
|
545
554
|
var r=s.run; var open = tabs[r.id] ? ' open' : '';
|
|
546
555
|
html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
547
|
-
+ '<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>';
|
|
548
558
|
});
|
|
549
559
|
} else {
|
|
550
560
|
html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
|
|
@@ -586,6 +596,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
586
596
|
}
|
|
587
597
|
$('tree').addEventListener('click', function(e){
|
|
588
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; }
|
|
589
601
|
var run = e.target.closest('[data-run]');
|
|
590
602
|
if (run){ openRunPane(+run.dataset.run); if (isMobile()) setDrawer(false); return; }
|
|
591
603
|
var fn = e.target.closest('[data-fold]');
|
|
@@ -644,6 +656,26 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
644
656
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
645
657
|
// URL 링크 클릭 가능(웹=새 탭, 데스크톱 앱=시스템 브라우저 — setWindowOpenHandler 가 external 로).
|
|
646
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){}
|
|
647
679
|
// 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
|
|
648
680
|
// ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
|
|
649
681
|
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
|
|
@@ -675,8 +707,21 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
675
707
|
return t;
|
|
676
708
|
}
|
|
677
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
|
+
}
|
|
678
723
|
function fmtSize(n){ return n<1024?(n+' B'):n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'); }
|
|
679
|
-
var MD_FRAME_CSS = 'body{margin:0;padding:
|
|
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}'
|
|
680
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}'
|
|
681
726
|
+ 'code{background:#1c212c;padding:.15em .4em;border-radius:4px;font-family:ui-monospace,Menlo,monospace;font-size:.9em}'
|
|
682
727
|
+ 'pre{background:#12161d;padding:12px 14px;border-radius:8px;overflow:auto}pre code{background:none;padding:0}'
|
|
@@ -1055,6 +1100,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1055
1100
|
// ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
|
|
1056
1101
|
var pickPathCur = '';
|
|
1057
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
|
+
}
|
|
1058
1112
|
function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
|
|
1059
1113
|
function closePicker(){ $('pickModal').classList.remove('on'); }
|
|
1060
1114
|
async function browseTo(p){
|
|
@@ -1095,13 +1149,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1095
1149
|
|
|
1096
1150
|
// ── 파일 피커(뷰어로 열기) — 폴더=이동, 파일=뷰어 ──
|
|
1097
1151
|
var fpDirCur = '';
|
|
1098
|
-
function openFilePicker(){ $('fpickModal').classList.add('on'); fpTo(fpDirCur||''); }
|
|
1152
|
+
function openFilePicker(){ $('fpickModal').classList.add('on'); $('fpSearch').value=''; fpTo(fpDirCur||''); setTimeout(function(){ try{ $('fpSearch').focus(); }catch(e){} },30); }
|
|
1099
1153
|
function closeFilePicker(){ $('fpickModal').classList.remove('on'); }
|
|
1100
1154
|
function fpIcon(kind){ return kind==='md'?'M':kind==='html'?'H':kind==='pdf'?'P':kind==='image'?'I':kind==='binary'?'·':'T'; }
|
|
1101
1155
|
async function fpTo(p){
|
|
1102
1156
|
try{
|
|
1103
1157
|
var d = await (await fetch('/api/fs/list'+(p?('?path='+encodeURIComponent(p)):''))).json();
|
|
1104
|
-
fpDirCur = d.path; $('fpPath').textContent = d.path;
|
|
1158
|
+
fpDirCur = d.path; $('fpPath').textContent = d.path; if($('fpHint')) $('fpHint').textContent='폴더=이동 · 파일=뷰어로 열기';
|
|
1105
1159
|
var html='';
|
|
1106
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>';
|
|
1107
1161
|
(d.entries||[]).forEach(function(e){
|
|
@@ -1113,12 +1167,32 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1113
1167
|
$('fpList').innerHTML = html;
|
|
1114
1168
|
}catch(e){ $('fpList').innerHTML = '<div class="pick-row" style="color:var(--failed)">읽을 수 없습니다</div>'; }
|
|
1115
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
|
+
});
|
|
1116
1190
|
$('fpList').addEventListener('click', function(e){
|
|
1117
|
-
var dir=e.target.closest('[data-dir]'); if(dir){ fpTo(dir.dataset.dir); return; }
|
|
1191
|
+
var dir=e.target.closest('[data-dir]'); if(dir){ $('fpSearch').value=''; fpTo(dir.dataset.dir); return; }
|
|
1118
1192
|
var file=e.target.closest('[data-file]'); if(file){ closeFilePicker(); openViewer(file.dataset.file); if(isMobile()) setDrawer(false); }
|
|
1119
1193
|
});
|
|
1120
1194
|
$('fpClose').addEventListener('click', closeFilePicker);
|
|
1121
|
-
$('fpHome').addEventListener('click', function(){ fpTo(''); });
|
|
1195
|
+
$('fpHome').addEventListener('click', function(){ $('fpSearch').value=''; fpTo(''); });
|
|
1122
1196
|
$('fpickModal').addEventListener('click', function(e){ if(e.target===this) closeFilePicker(); });
|
|
1123
1197
|
$('fileBtn').addEventListener('click', openFilePicker);
|
|
1124
1198
|
$('sessionCta').addEventListener('click', openSession);
|
package/src/files.ts
CHANGED
|
@@ -87,6 +87,34 @@ export async function listDir(input?: string) {
|
|
|
87
87
|
return { path, parent: pdirname(path), home: HOME, entries, error };
|
|
88
88
|
}
|
|
89
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
|
+
|
|
90
118
|
export async function readForView(input?: string) {
|
|
91
119
|
const path = await jail(input);
|
|
92
120
|
const st = await stat(path);
|
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,14 +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 } from './files';
|
|
29
|
+
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles } from './files';
|
|
30
30
|
|
|
31
31
|
const require_ = createRequire(import.meta.url);
|
|
32
32
|
|
|
@@ -752,6 +752,11 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
752
752
|
try { return await fsListDir(q.path); }
|
|
753
753
|
catch { return { path: q.path ?? '', parent: '', home: homedir(), entries: [], error: 'outside home or unreadable' }; }
|
|
754
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
|
+
});
|
|
755
760
|
app.get('/api/fs/read', async (req, reply) => {
|
|
756
761
|
const q = (req.query ?? {}) as { path?: string };
|
|
757
762
|
if (!q.path) return reply.code(400).send({ error: 'path required' });
|
|
@@ -998,6 +1003,15 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
998
1003
|
return res;
|
|
999
1004
|
});
|
|
1000
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
|
+
|
|
1001
1015
|
// v5.1 Part C foundation — resolve the land target + base drift for a run.
|
|
1002
1016
|
// ?fetch=1 refreshes the remote first (network) so ahead/behind are current.
|
|
1003
1017
|
app.get('/api/runs/:id/land-target', async (req, reply) => {
|