coxpit 5.26.3 → 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.3",
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,6 +771,8 @@ 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'; } } });
@@ -1179,14 +1182,38 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1179
1182
  if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
1180
1183
  setLeafFocus(leaf.dataset.leaf);
1181
1184
  });
1182
- $('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'); } });
1183
1187
  $('panes').addEventListener('dragleave', function(e){ var body=e.target.closest('[data-leafbody]'); if(body) body.classList.remove('drop'); });
1184
1188
  $('panes').addEventListener('drop', function(e){
1185
- 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');
1186
1193
  var l=findLeaf(body.dataset.leafbody); if(!l) return;
1187
1194
  var rid=dragRunId; eachLeaf(layout,function(x){ if(x.tab===rid) x.tab=null; });
1188
1195
  ensureTab(rid); l.tab=rid; focusLeaf=l.id; render(); renderTree();
1189
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
+ }
1190
1217
  $('panes').addEventListener('mousedown', function(e){
1191
1218
  var g=e.target.closest('[data-gutter]'); if(!g) return; e.preventDefault();
1192
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' });