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 +1 -1
- package/src/agentstate.ts +35 -3
- package/src/board.ts +16 -66
- package/src/cockpit.ts +1189 -60
- package/src/humanize.ts +62 -0
- package/src/orchestrator.ts +113 -11
- package/src/procscan.ts +241 -0
- package/src/server.ts +89 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "6.
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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 ──
|
|
1932
|
-
(~180MB each — node_modules lives inside).
|
|
1933
|
-
|
|
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
|
-
|
|
1939
|
-
const
|
|
1940
|
-
|
|
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+'
|
|
1947
|
-
sub:'active work is untouched —
|
|
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{
|