coxpit 5.26.2 → 5.27.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.26.2",
3
+ "version": "5.27.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",
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@fastify/websocket": "^11",
46
46
  "@libsql/client": "^0.14",
47
+ "@xterm/addon-clipboard": "^0.1.0",
47
48
  "@xterm/addon-fit": "^0.10.0",
48
49
  "@xterm/addon-search": "^0.15.0",
49
50
  "@xterm/addon-unicode11": "^0.8.0",
package/src/cockpit.ts CHANGED
@@ -552,6 +552,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
552
552
  <script src="/vendor/addon-unicode11.js"></script>
553
553
  <script src="/vendor/addon-web-links.js"></script>
554
554
  <script src="/vendor/addon-search.js"></script>
555
+ <script src="/vendor/addon-clipboard.js"></script>
555
556
  <script src="/vendor/marked.js"></script>
556
557
  <script>
557
558
  // 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
@@ -770,15 +771,27 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
770
771
  }
771
772
  cb(links.length?links:undefined);
772
773
  }}); }catch(e){}
774
+ // OSC 52 클립보드 — 터미널 안 앱(claude 등)이 "복사"하면 실제 시스템 클립보드로. (앱은 됐다는데 안 붙던 원인)
775
+ try{ term.loadAddon(new window.ClipboardAddon.ClipboardAddon()); }catch(e){}
773
776
  // 스크롤백 검색(⌘F) — SearchAddon. t.search 에 보관, 검색바가 findNext/Previous 호출.
774
777
  var search=null; try{ search=new window.SearchAddon.SearchAddon(); term.loadAddon(search);
775
778
  search.onDidChangeResults(function(e){ if($('termFind') && !$('termFind').hidden && focusedRunId()===runId){ var c=$('findCount'); if(c){ var n=(e&&e.resultCount)||0; c.textContent = n ? (((e.resultIndex>=0?e.resultIndex+1:0))+'/'+n) : '0'; } } });
776
779
  }catch(e){}
777
780
  // 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
778
781
  // ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
782
+ // 복사: clipboard API 우선(HTTPS·PWA), 실패하면 execCommand 폴백(iOS 포함). 항상 토스트로 확인.
783
+ var copyFallback=function(s){ var ok=false;
784
+ try{ var ta=document.createElement('textarea'); ta.value=s; ta.setAttribute('readonly',''); ta.contentEditable='true';
785
+ ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.opacity='0'; document.body.appendChild(ta);
786
+ var r=document.createRange(); r.selectNodeContents(ta); var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
787
+ try{ ta.setSelectionRange(0, s.length); }catch(e){}
788
+ ok=document.execCommand('copy'); document.body.removeChild(ta);
789
+ }catch(e){ ok=false; }
790
+ toast(ok?('복사됨 · '+s.length+'자'):'복사 실패 — 브라우저가 클립보드를 막았어요'); };
779
791
  var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
780
- try{ if(navigator.clipboard&&navigator.clipboard.writeText) navigator.clipboard.writeText(s); }catch(e){}
781
- try{ var ta=document.createElement('textarea'); ta.value=s; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }catch(e){}
792
+ if(navigator.clipboard && navigator.clipboard.writeText){
793
+ navigator.clipboard.writeText(s).then(function(){ toast('복사됨 · '+s.length+''); }).catch(function(){ copyFallback(s); });
794
+ } else { copyFallback(s); }
782
795
  return true; };
783
796
  host.addEventListener('mouseup', function(){ copySel(); });
784
797
  host.addEventListener('touchend', function(){ copySel(); });
@@ -1169,14 +1182,38 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1169
1182
  if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
1170
1183
  setLeafFocus(leaf.dataset.leaf);
1171
1184
  });
1172
- $('panes').addEventListener('dragover', function(e){ var body=e.target.closest('[data-leafbody]'); if(body && dragRunId!=null){ e.preventDefault(); body.classList.add('drop'); } });
1185
+ function dragHasFiles(e){ try{ return e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types||[],'Files')>=0; }catch(_){ return false; } }
1186
+ $('panes').addEventListener('dragover', function(e){ var body=e.target.closest('[data-leafbody]'); if(body && (dragRunId!=null || dragHasFiles(e))){ e.preventDefault(); if(dragHasFiles(e)) try{ e.dataTransfer.dropEffect='copy'; }catch(_){}; body.classList.add('drop'); } });
1173
1187
  $('panes').addEventListener('dragleave', function(e){ var body=e.target.closest('[data-leafbody]'); if(body) body.classList.remove('drop'); });
1174
1188
  $('panes').addEventListener('drop', function(e){
1175
- var body=e.target.closest('[data-leafbody]'); if(!body || dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
1189
+ var body=e.target.closest('[data-leafbody]'); if(!body) return;
1190
+ if(dragHasFiles(e)){ e.preventDefault(); body.classList.remove('drop'); var lf=findLeaf(body.dataset.leafbody);
1191
+ if(lf && lf.tab!=null && e.dataTransfer.files && e.dataTransfer.files.length) uploadToTab(lf.tab, e.dataTransfer.files); else toast('업로드할 터미널 페인이 아닙니다'); return; }
1192
+ if(dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
1176
1193
  var l=findLeaf(body.dataset.leafbody); if(!l) return;
1177
1194
  var rid=dragRunId; eachLeaf(layout,function(x){ if(x.tab===rid) x.tab=null; });
1178
1195
  ensureTab(rid); l.tab=rid; focusLeaf=l.id; render(); renderTree();
1179
1196
  });
1197
+ // 파일 첨부 — 로컬 파일을 그 페인 run 의 작업폴더(cwd)로 업로드하고 경로를 터미널에 삽입.
1198
+ function uploadToTab(tabId, files){
1199
+ var t=tabs[tabId]; if(!t || t.kind==='viewer' || !t.ws){ toast('터미널 페인에만 첨부할 수 있어요'); return; }
1200
+ var r=runById[tabId]; var cwd=r&&r.worktreePath; if(!cwd){ toast('이 페인의 작업 폴더를 몰라 첨부할 수 없어요'); return; }
1201
+ Array.prototype.forEach.call(files, function(file){
1202
+ if(file.size > 25*1024*1024){ toast('너무 큼(25MB 초과): '+file.name); return; }
1203
+ var reader=new FileReader();
1204
+ reader.onload=function(){
1205
+ var b64=String(reader.result||'').split(',')[1]||'';
1206
+ fetch('/api/fs/upload',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:cwd, name:file.name, dataB64:b64})})
1207
+ .then(function(x){return x.json();}).then(function(d){
1208
+ if(d.error){ toast('첨부 실패: '+d.error); return; }
1209
+ var p=d.path; var ins=/[ "'\\\\]/.test(p) ? ('"'+p.replace(/(["\\\\])/g,'\\\\$1')+'"') : p;
1210
+ if(t.ws && t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i', d:ins+' '}));
1211
+ toast('첨부됨 · '+d.name+' → 경로 삽입');
1212
+ }).catch(function(){ toast('첨부 실패'); });
1213
+ };
1214
+ reader.readAsDataURL(file);
1215
+ });
1216
+ }
1180
1217
  $('panes').addEventListener('mousedown', function(e){
1181
1218
  var g=e.target.closest('[data-gutter]'); if(!g) return; e.preventDefault();
1182
1219
  var node=findSplit(g.dataset.gutter); if(!node) return;
package/src/files.ts CHANGED
@@ -186,6 +186,25 @@ export async function readRaw(input?: string) {
186
186
  return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
187
187
  }
188
188
 
189
+ const MAX_UPLOAD = 25 * 1024 * 1024; // 25MB
190
+ // Save an uploaded file into a directory (within root). Returns the final path (dedup, never clobbers).
191
+ export async function uploadFile(dir: string, name: string, buf: Buffer) {
192
+ if (buf.length > MAX_UPLOAD) throw new Error('파일이 너무 큽니다 (25MB 초과)');
193
+ const base = (name || 'file').replace(/[\/\x00]/g, '_').replace(/^\.+/, '').slice(0, 200) || 'file';
194
+ const dot = base.lastIndexOf('.');
195
+ const stem = dot > 0 ? base.slice(0, dot) : base;
196
+ const ext = dot > 0 ? base.slice(dot) : '';
197
+ const cleanDir = (dir || '').replace(/\/+$/, '');
198
+ let path = await jail(cleanDir + '/' + base); // jail validates within root
199
+ for (let i = 1; i < 100; i++) {
200
+ const st = await stat(path).catch(() => null);
201
+ if (!st) break; // free name
202
+ path = await jail(cleanDir + '/' + stem + '-' + i + ext);
203
+ }
204
+ await writeFile(path, buf);
205
+ return { path, name: pbasename(path), size: buf.length };
206
+ }
207
+
189
208
  export async function writeText(input: string, content: string) {
190
209
  let path: string;
191
210
  try { path = await jail(input); } catch (e) { throw friendly(e); }
package/src/server.ts CHANGED
@@ -26,7 +26,7 @@ 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
+ import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile } from './files';
30
30
 
31
31
  const require_ = createRequire(import.meta.url);
32
32
 
@@ -38,6 +38,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
38
38
  'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
39
39
  'addon-web-links.js': { pkg: '@xterm/addon-web-links/package.json', rel: 'lib/addon-web-links.js', type: 'text/javascript' },
40
40
  'addon-search.js': { pkg: '@xterm/addon-search/package.json', rel: 'lib/addon-search.js', type: 'text/javascript' },
41
+ 'addon-clipboard.js': { pkg: '@xterm/addon-clipboard/package.json', rel: 'lib/addon-clipboard.js', type: 'text/javascript' },
41
42
  'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
42
43
  };
43
44
 
@@ -784,6 +785,13 @@ export async function buildServer(): Promise<FastifyInstance> {
784
785
  .send(buf);
785
786
  } catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
786
787
  });
788
+ // 파일 첨부 — 로컬 파일을 세션 폴더로 업로드(base64). 경로 삽입은 클라가 함. 32MB 바디 허용.
789
+ app.post('/api/fs/upload', { bodyLimit: 32 * 1024 * 1024 }, async (req, reply) => {
790
+ const b = (req.body ?? {}) as { path?: string; name?: string; dataB64?: string };
791
+ if (!b.path || !b.name || typeof b.dataB64 !== 'string') return reply.code(400).send({ error: 'path, name, dataB64 required' });
792
+ try { const buf = Buffer.from(b.dataB64, 'base64'); return await fsUploadFile(b.path, b.name, buf); }
793
+ catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
794
+ });
787
795
  app.post('/api/fs/write', async (req, reply) => {
788
796
  const b = (req.body ?? {}) as { path?: string; content?: string };
789
797
  if (!b.path || typeof b.content !== 'string') return reply.code(400).send({ error: 'path and content required' });