coxpit 6.0.0 → 6.2.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": "6.0.0",
3
+ "version": "6.2.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/agentstate.ts CHANGED
@@ -22,7 +22,7 @@ const HOOK_COOLDOWN_MS = 60_000; // run 하나가 웹훅을 때릴 수 있는
22
22
 
23
23
  interface Pattern { id: string; re: RegExp }
24
24
 
25
- // ⚠️ 휴리스틱이고 provider TUI 화면에 종속적이다. "에이전트의 의도"를 안다고 주장하지 않는다 —
25
+ // [!] 휴리스틱이고 provider TUI 화면에 종속적이다. "에이전트의 의도"를 안다고 주장하지 않는다 —
26
26
  // 말하는 것은 "화면에 입력/승인 프롬프트가 떠 있다" 하나뿐이다. CLI 가 화면을 바꾸면 이 표만
27
27
  // 고치고, provider 를 늘리는 일은 행을 늘리는 일이다. 확신이 없으면 넣지 않는다:
28
28
  // 빗나간 idle 은 받아들일 수 있고, 지어낸 waiting 은 받아들일 수 없다.
@@ -90,13 +90,38 @@ interface Tracker {
90
90
  lastByteAt: number;
91
91
  timer: ReturnType<typeof setTimeout> | null; // tracker 당 정확히 하나
92
92
  lastHookAt: number; // 웹훅 쿨다운 — tracker 와 함께 살고 함께 죽는다
93
+ spotted: Set<number>; // B3 수동 포트 감지 — 출력에서 본 LISTEN 포트(감지만, 행동 없음)
94
+ }
95
+
96
+ // B3: 출력에서 "listening on :PORT" / "http(s)://host:PORT" 만 보수적으로 집는다.
97
+ // A2 와 같은 규율 — 못 맞히면 빈손이고, 포트를 지어내지 않는다. 1..65535 만 인정.
98
+ const PORT_PATTERNS: RegExp[] = [
99
+ /\blisten(?:ing)?\b[^0-9]{0,20}:(\d{2,5})\b/gi, // "Listening on :3000", "listening at port 8080" 근처의 :NNNN
100
+ /\bhttps?:\/\/[a-z0-9.\-]+:(\d{2,5})\b/gi, // http://localhost:3000
101
+ /\b(?:0\.0\.0\.0|127\.0\.0\.1|localhost)\b[^0-9]{0,4}:(\d{2,5})\b/gi,
102
+ ];
103
+
104
+ function spotPorts(t: Tracker, chunk: string): void {
105
+ for (const re of PORT_PATTERNS) {
106
+ re.lastIndex = 0;
107
+ let m: RegExpExecArray | null;
108
+ while ((m = re.exec(chunk))) {
109
+ const p = Number(m[1]);
110
+ if (p >= 1 && p <= 65535) t.spotted.add(p);
111
+ }
112
+ }
113
+ // 무한정 쌓지 않는다 — 최근 것 위주로 상한
114
+ if (t.spotted.size > 24) {
115
+ const keep = [...t.spotted].slice(-24);
116
+ t.spotted = new Set(keep);
117
+ }
93
118
  }
94
119
 
95
120
  /**
96
121
  * 주의 환기의 서버 쪽 절반(spec v5.28 A5) — 코크핏이 아예 닫혀 있을 때 유일하게 남는 신호다.
97
122
  * orchestrator 의 notifySettle 과 같은 모양으로 POST 하고, 실패는 무해하게 삼킨다.
98
123
  *
99
- * ⚠️ **상태만 보낸다.** detail 도, tail 조각도 절대 태우지 않는다 — 터미널 출력은 시크릿을
124
+ * [!] **상태만 보낸다.** detail 도, tail 조각도 절대 태우지 않는다 — 터미널 출력은 시크릿을
100
125
  * 그대로 뱉을 수 있고 웹훅 엔드포인트는 coxpit 의 신뢰 경계 **밖**이다(꼬리는 인증된 /ws 허브에만).
101
126
  */
102
127
  async function postHook(runId: number, state: AgentState): Promise<void> {
@@ -173,7 +198,7 @@ function onQuiet(runId: number): void {
173
198
  export function attach(runId: number): void {
174
199
  const cur = trackers.get(runId);
175
200
  if (cur) { cur.refs++; return; }
176
- trackers.set(runId, { refs: 1, state: 'unknown', since: Date.now(), tail: '', lastByteAt: 0, timer: null, lastHookAt: 0 });
201
+ trackers.set(runId, { refs: 1, state: 'unknown', since: Date.now(), tail: '', lastByteAt: 0, timer: null, lastHookAt: 0, spotted: new Set() });
177
202
  }
178
203
 
179
204
  /** 출력 청크 — tail 에 붙이고 시각을 찍고 working. 미러 중복이 들어와도 해롭지 않다. */
@@ -183,10 +208,17 @@ export function feed(runId: number, chunk: string): void {
183
208
  t.tail += chunk;
184
209
  if (t.tail.length > TAIL_MAX) t.tail = t.tail.slice(t.tail.length - TAIL_MAX);
185
210
  t.lastByteAt = Date.now();
211
+ spotPorts(t, chunk); // B3 수동 포트 감지 — 감지만, 행동 없음
186
212
  setState(runId, t, 'working');
187
213
  arm(runId, t, ACTIVE_MS);
188
214
  }
189
215
 
216
+ /** B3: 이 run 의 출력에서 감지한 LISTEN 포트 목록(감지만; 코크핏이 원클릭 대상으로 제안). */
217
+ export function spottedPorts(runId: number): number[] {
218
+ const t = trackers.get(runId);
219
+ return t ? [...t.spotted] : [];
220
+ }
221
+
190
222
  /**
191
223
  * 사람이 터미널에 입력했다 — waiting 을 즉시 지운다.
192
224
  * TUI 는 다시 그리므로 이미 답한 프롬프트 문구가 tail 에 남는다. 입력 신호는 "사람이 응답했다"는
package/src/board.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // 데몬이 서빙하는 단일 페이지 플릿 콘솔(빌드 스텝 0, 자가완결).
2
2
  // /api/fleet 로 하이드레이트 → /ws 구독 델타 → run 상세(타임라인·diff·터미널)·비교/머지.
3
3
  import { ICON_SPRITE, ICON_CSS, ICON_JS_HELPER } from './icons.js';
4
+ import { HUMANIZE_JS } from './humanize';
4
5
 
5
6
  export const BOARD_HTML = /* html */ `<!doctype html>
6
7
  <html lang="en">
@@ -1479,64 +1480,7 @@ $('cfmOk').addEventListener('click', ()=>cfmClose(true));
1479
1480
  $('cfmCancel').addEventListener('click', ()=>cfmClose(false));
1480
1481
  $('cfmOverlay').addEventListener('click',(e)=>{ if(e.target===$('cfmOverlay')) cfmClose(false); });
1481
1482
 
1482
- /* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
1483
- function humanize(e){
1484
- const kind = e.kind, payload = e.payload;
1485
- if (kind === 'rate_limit_event') return null;
1486
- if (kind === 'steer') return { k:'steer', t:'→ '+payload };
1487
- if (kind === 'ask') return { k:'ask', t:'? '+payload };
1488
- if (kind === 'sync') return { k:'sync', t:payload };
1489
- if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
1490
- if (kind === 'pr') return { k:'pr', t:payload };
1491
- if (kind === 'stderr') return { k:'stderr', t:payload };
1492
- try{
1493
- const o = JSON.parse(payload);
1494
- if (o.type === 'system'){
1495
- if (o.subtype === 'init' || !o.subtype) return { k:'session',
1496
- t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
1497
- .replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
1498
- if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
1499
- return null; // thinking_tokens 등 스트림 잡음
1500
- }
1501
- if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
1502
- if (o.type === 'assistant' && o.message){
1503
- const parts = [];
1504
- for (const x of (o.message.content||[])){
1505
- if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
1506
- else if (x.type === 'tool_use'){
1507
- const i = x.input || {};
1508
- const arg = i.file_path || i.command || i.path || i.pattern || '';
1509
- parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
1510
- }
1511
- }
1512
- return parts.length ? parts : null;
1513
- }
1514
- if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
1515
- if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
1516
- if (kind === 'meta' && o.subtask) return { k:'swarm', t:'↳ spawned task #'+o.subtask+' — '+String(o.title||'').slice(0,60)+' ('+((o.runs||[]).map(x=>'r'+x).join(' '))+')' };
1517
- if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
1518
- return { k:kind, t:payload.slice(0,140) };
1519
- }catch{
1520
- // 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
1521
- // text 조각만 구제하고, 없으면 생략.
1522
- if (payload.trim().startsWith('{')){
1523
- const texts = [];
1524
- const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
1525
- while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
1526
- return texts.length ? { k:'said', t:texts.join(' · ') } : null;
1527
- }
1528
- return { k:kind, t:payload };
1529
- }
1530
- }
1531
- function humanLines(events){
1532
- const out = [];
1533
- for (const e of (events||[])){
1534
- const h = humanize(e);
1535
- if (!h) continue;
1536
- if (Array.isArray(h)) out.push(...h); else out.push(h);
1537
- }
1538
- return out;
1539
- }
1483
+ ${HUMANIZE_JS}
1540
1484
  function summarize(kind, payload){
1541
1485
  const h = humanize({kind, payload});
1542
1486
  if (!h) return '';
@@ -1928,23 +1872,29 @@ $('archList').addEventListener('click', async (e)=>{
1928
1872
  }catch{ toast('could not open task', 'error'); }
1929
1873
  });
1930
1874
 
1931
- /* ── Reclaim orphaned worktrees ── cleaned/failed run worktrees are disk debt
1932
- (~180MB each — node_modules lives inside). Only shown when there's something
1933
- to reclaim; active/successful work is never listed by the server. */
1875
+ /* ── Reclaim orphaned worktrees ── finished run worktrees are disk debt
1876
+ (~180MB each — node_modules lives inside). Active work is never listed by the
1877
+ server. v6.0 T6b: settled done worktrees are listed too, but any whose change
1878
+ set is unmerged AND un-exported is flagged (reclaimRisk) — this button reclaims
1879
+ only the safe ones, so the count here is the SAFE count. Pick a flagged one
1880
+ deliberately in the cockpit's worktree sheet. */
1934
1881
  let reclaimN = 0;
1935
1882
  async function reclaimRefresh(){
1936
1883
  try{
1937
1884
  const j = await fetch('/api/worktrees').then(x=>x.json());
1938
- reclaimN = (j.items||[]).length;
1939
- const mb = Math.round((j.totalKb||0)/1024);
1940
- $('reclaimHint').textContent = reclaimN ? (reclaimN+' · ~'+mb+'MB') : '';
1885
+ const items = (j.items||[]);
1886
+ const safe = items.filter(w=>!w.reclaimRisk);
1887
+ reclaimN = safe.length;
1888
+ const mb = Math.round(safe.reduce((s,w)=>s+(w.sizeKb||0),0)/1024);
1889
+ const flagged = items.length - safe.length;
1890
+ $('reclaimHint').textContent = reclaimN ? (reclaimN+' · ~'+mb+'MB'+(flagged?(' (+'+flagged+' flagged)'):'')) : '';
1941
1891
  $('reclaimBtn').hidden = reclaimN===0;
1942
1892
  }catch{ $('reclaimBtn').hidden = true; }
1943
1893
  }
1944
1894
  $('reclaimBtn').addEventListener('click', async ()=>{
1945
1895
  if (!reclaimN) return;
1946
- const ok = await confirmUI('remove '+reclaimN+' cleaned/failed run worktree'+(reclaimN===1?'':'s')+'?', {
1947
- sub:'active work is untouched — only closed tasks and failed/error/stopped runs are reclaimed',
1896
+ const ok = await confirmUI('remove '+reclaimN+' finished run worktree'+(reclaimN===1?'':'s')+'?', {
1897
+ sub:'active work is untouched, and worktrees holding unmerged, un-exported changes are left alone tick those one by one in the cockpit',
1948
1898
  okLabel:'Reclaim', danger:true });
1949
1899
  if (!ok) return;
1950
1900
  try{