coxpit 5.26.3 → 5.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.26.3",
3
+ "version": "5.27.1",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "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
@@ -50,6 +50,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
50
50
  .mach{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 10px}
51
51
  .mach .dot{width:6px;height:6px;border-radius:50%;background:var(--done)}
52
52
  .vtabs{display:flex;gap:4px;margin-left:4px}
53
+ .ver{font-family:var(--mono);font-size:9.5px;color:var(--faint);opacity:.8}
53
54
  .vtab{font-size:12px;color:var(--muted);padding:6px 11px;border-radius:7px;display:inline-flex;align-items:center;gap:7px;cursor:pointer;background:none;border:none;font-family:var(--mono)}
54
55
  .vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
55
56
  .vtab .g{color:var(--brand)}
@@ -382,6 +383,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
382
383
  <button type="button" class="vtab" disabled title="Docs = 보드"><span class="g">▤</span><span class="b-txt">Docs</span></button>
383
384
  </div>
384
385
  <div class="right">
386
+ <span class="ver" id="ver" title="로드된 cockpit 버전 (캐시 확인용)">v__COXPIT_VER__</span>
385
387
  <span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
386
388
  <button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">∗<span class="b-txt"> Secrets</span></button>
387
389
  <a class="toggle" href="/" title="보드(모니터) 뷰로">←<span class="b-txt"> Board</span></a>
@@ -404,6 +406,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
404
406
  <button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
405
407
  <button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
406
408
  <button class="tc-btn" id="fileBtn" title="파일 보기 — md·html·pdf·이미지·텍스트 뷰어(터미널 옆 페인)">▤<span class="b-txt"> File</span></button>
409
+ <button class="tc-btn" id="attachBtn" title="파일 첨부 — 포커스한 터미널 폴더로 업로드 + 경로 삽입 (드롭도 가능)">↥<span class="b-txt"> 첨부</span></button>
407
410
  <button class="tc-btn" id="focusBtn" title="포커스 모드 — 트리·요청바 숨기고 페인만 (⌘.)">◱<span class="b-txt"> Focus</span></button>
408
411
  <button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> pane</span></button>
409
412
  </div>
@@ -545,6 +548,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
545
548
  </div>
546
549
  </div>
547
550
 
551
+ <input type="file" id="attachInput" multiple hidden />
548
552
  <div class="toast" id="toast"></div>
549
553
 
550
554
  <script src="/vendor/xterm.js"></script>
@@ -552,6 +556,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
552
556
  <script src="/vendor/addon-unicode11.js"></script>
553
557
  <script src="/vendor/addon-web-links.js"></script>
554
558
  <script src="/vendor/addon-search.js"></script>
559
+ <script src="/vendor/addon-clipboard.js"></script>
555
560
  <script src="/vendor/marked.js"></script>
556
561
  <script>
557
562
  // 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
@@ -744,6 +749,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
744
749
  var term=new window.Terminal({
745
750
  fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
746
751
  fontSize: isMobile() ? 11 : 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
752
+ macOptionClickForcesSelection: true, // ⌥+드래그 = 로컬 선택(마우스모드 앱 안에서도) → mouseup 복사가 됨
753
+ rightClickSelectsWord: true,
747
754
  theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
748
755
  });
749
756
  var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
@@ -770,6 +777,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
770
777
  }
771
778
  cb(links.length?links:undefined);
772
779
  }}); }catch(e){}
780
+ // OSC 52 클립보드 — 터미널 안 앱(claude 등)이 "복사"하면 실제 시스템 클립보드로. (앱은 됐다는데 안 붙던 원인)
781
+ try{ term.loadAddon(new window.ClipboardAddon.ClipboardAddon()); }catch(e){}
773
782
  // 스크롤백 검색(⌘F) — SearchAddon. t.search 에 보관, 검색바가 findNext/Previous 호출.
774
783
  var search=null; try{ search=new window.SearchAddon.SearchAddon(); term.loadAddon(search);
775
784
  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 +1188,42 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1179
1188
  if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
1180
1189
  setLeafFocus(leaf.dataset.leaf);
1181
1190
  });
1182
- $('panes').addEventListener('dragover', function(e){ var body=e.target.closest('[data-leafbody]'); if(body && dragRunId!=null){ e.preventDefault(); body.classList.add('drop'); } });
1191
+ function dragHasFiles(e){ try{ return e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types||[],'Files')>=0; }catch(_){ return false; } }
1192
+ $('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
1193
  $('panes').addEventListener('dragleave', function(e){ var body=e.target.closest('[data-leafbody]'); if(body) body.classList.remove('drop'); });
1184
1194
  $('panes').addEventListener('drop', function(e){
1185
- var body=e.target.closest('[data-leafbody]'); if(!body || dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
1195
+ var body=e.target.closest('[data-leafbody]'); if(!body) return;
1196
+ if(dragHasFiles(e)){ e.preventDefault(); body.classList.remove('drop'); var lf=findLeaf(body.dataset.leafbody);
1197
+ if(lf && lf.tab!=null && e.dataTransfer.files && e.dataTransfer.files.length) uploadToTab(lf.tab, e.dataTransfer.files); else toast('업로드할 터미널 페인이 아닙니다'); return; }
1198
+ if(dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
1186
1199
  var l=findLeaf(body.dataset.leafbody); if(!l) return;
1187
1200
  var rid=dragRunId; eachLeaf(layout,function(x){ if(x.tab===rid) x.tab=null; });
1188
1201
  ensureTab(rid); l.tab=rid; focusLeaf=l.id; render(); renderTree();
1189
1202
  });
1203
+ // 파일 첨부 — 로컬 파일을 그 페인 run 의 작업폴더(cwd)로 업로드하고 경로를 터미널에 삽입.
1204
+ function uploadToTab(tabId, files){
1205
+ var t=tabs[tabId]; if(!t || t.kind==='viewer' || !t.ws){ toast('터미널 페인에만 첨부할 수 있어요'); return; }
1206
+ var r=runById[tabId]; var cwd=r&&r.worktreePath; if(!cwd){ toast('이 페인의 작업 폴더를 몰라 첨부할 수 없어요'); return; }
1207
+ Array.prototype.forEach.call(files, function(file){
1208
+ if(file.size > 25*1024*1024){ toast('너무 큼(25MB 초과): '+file.name); return; }
1209
+ var reader=new FileReader();
1210
+ reader.onload=function(){
1211
+ var b64=String(reader.result||'').split(',')[1]||'';
1212
+ fetch('/api/fs/upload',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:cwd, name:file.name, dataB64:b64})})
1213
+ .then(function(x){return x.json();}).then(function(d){
1214
+ if(d.error){ toast('첨부 실패: '+d.error); return; }
1215
+ var p=d.path; var ins=/[ "'\\\\]/.test(p) ? ('"'+p.replace(/(["\\\\])/g,'\\\\$1')+'"') : p;
1216
+ if(t.ws && t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i', d:ins+' '}));
1217
+ toast('첨부됨 · '+d.name+' → 경로 삽입');
1218
+ }).catch(function(){ toast('첨부 실패'); });
1219
+ };
1220
+ reader.readAsDataURL(file);
1221
+ });
1222
+ }
1223
+ // 상단 "↥ 첨부" 버튼 — 포커스한 터미널 페인으로 파일 선택 업로드(드래그&드롭의 보이는 대안)
1224
+ var attachTarget=null;
1225
+ $('attachBtn').addEventListener('click', function(){ var rid=focusedRunId(); if(rid==null||isViewer(rid)||!tabs[rid]||!tabs[rid].ws){ toast('첨부할 터미널 페인을 먼저 선택하세요'); return; } attachTarget=rid; $('attachInput').value=''; $('attachInput').click(); });
1226
+ $('attachInput').addEventListener('change', function(){ if(attachTarget!=null && this.files && this.files.length) uploadToTab(attachTarget, this.files); attachTarget=null; });
1190
1227
  $('panes').addEventListener('mousedown', function(e){
1191
1228
  var g=e.target.closest('[data-gutter]'); if(!g) return; e.preventDefault();
1192
1229
  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
 
@@ -209,7 +210,7 @@ export async function buildServer(): Promise<FastifyInstance> {
209
210
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
210
211
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
211
212
  // 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
212
- app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_HTML));
213
+ app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_HTML.replace(/__COXPIT_VER__/g, config.version)));
213
214
 
214
215
  // ─── 접근키 인증(access-key) ────────────────────────────────────
215
216
  // 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
@@ -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' });