coxpit 3.3.0 → 3.4.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/README.md +10 -0
- package/package.json +1 -1
- package/src/board.ts +15 -0
- package/src/config.ts +10 -0
- package/src/index.ts +10 -0
- package/src/orchestrator.ts +25 -7
- package/src/server.ts +4 -2
package/README.md
CHANGED
|
@@ -75,6 +75,16 @@ Your keys and login never touch coxpit's config or database.
|
|
|
75
75
|
|
|
76
76
|
Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep basic auth on — it exposes shells.
|
|
77
77
|
|
|
78
|
+
## Platform support
|
|
79
|
+
|
|
80
|
+
| platform | daemon | agent runs |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| macOS / Linux | ✅ first-class | ✅ |
|
|
83
|
+
| Windows (WSL2) | ✅ run the daemon inside WSL | ✅ |
|
|
84
|
+
| Windows (native) | ⚠️ board + remote machines only | ❌ needs a POSIX shell + tmux |
|
|
85
|
+
|
|
86
|
+
On Windows, install the daemon inside WSL2 (`npm i -g coxpit`) — WSL2 forwards `localhost`, so the Windows desktop app detects the WSL daemon and attaches to it automatically. A native Windows daemon still serves the board and can drive **remote** (ssh) machines, but the local machine will fail its readiness checks (no tmux).
|
|
87
|
+
|
|
78
88
|
## Architecture
|
|
79
89
|
|
|
80
90
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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": "MIT",
|
package/src/board.ts
CHANGED
|
@@ -37,6 +37,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
37
37
|
.brand{display:flex;align-items:baseline;gap:9px;font-family:var(--mono)}
|
|
38
38
|
.brand .mark{color:var(--brand);font-size:15px;font-weight:700;letter-spacing:.01em}
|
|
39
39
|
.brand .sub{color:var(--faint);font-size:11px;text-transform:uppercase;letter-spacing:.14em}
|
|
40
|
+
.daemon-badge{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:3px 9px;
|
|
41
|
+
border:1px solid var(--line);border-radius:999px;background:var(--surface);cursor:default}
|
|
42
|
+
.daemon-badge b{color:var(--muted);font-weight:500}
|
|
40
43
|
.ws{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:var(--muted);
|
|
41
44
|
padding:4px 10px;border:1px solid var(--line);border-radius:999px;background:var(--surface)}
|
|
42
45
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--s-failed);transition:background .3s}
|
|
@@ -155,6 +158,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
155
158
|
@media (prefers-reduced-motion:reduce){.chip.running i{animation:none}.card:hover{transform:none}}
|
|
156
159
|
.meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
157
160
|
.meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
|
|
161
|
+
.meta .resumable{color:var(--brand);opacity:.75}
|
|
158
162
|
.log{border-top:1px solid var(--line);background:#0e1118;padding:10px 14px;font-family:var(--mono);
|
|
159
163
|
font-size:11px;height:150px;overflow:auto;display:flex;flex-direction:column;gap:4px}
|
|
160
164
|
.ev{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
@@ -302,6 +306,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
302
306
|
<body>
|
|
303
307
|
<header>
|
|
304
308
|
<div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
|
|
309
|
+
<span class="daemon-badge" id="daemonBadge" style="display:none"></span>
|
|
305
310
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
306
311
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
|
|
307
312
|
<div class="machines" id="machines"></div>
|
|
@@ -761,6 +766,8 @@ function cardHTML(r){
|
|
|
761
766
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
762
767
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
763
768
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
769
|
+
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
770
|
+
? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
|
|
764
771
|
+ (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
|
|
765
772
|
+ '</div>'
|
|
766
773
|
+ '<div class="log">'+evs+'</div></div>';
|
|
@@ -781,6 +788,14 @@ async function hydrate(){
|
|
|
781
788
|
(r.tasks||[]).forEach(t => tasks.set(t.id, t));
|
|
782
789
|
runs.clear();
|
|
783
790
|
(r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
|
|
791
|
+
if (r.daemon) {
|
|
792
|
+
const d = r.daemon;
|
|
793
|
+
const db = String(d.dbPath||'').replace(/^\\/(?:Users|home)\\/[^/]+/, '~');
|
|
794
|
+
const el = $('daemonBadge');
|
|
795
|
+
el.innerHTML = 'daemon <b>v'+esc(String(d.version||'?'))+'</b> · :'+esc(String(d.port||'?'));
|
|
796
|
+
el.title = 'pid '+d.pid+' · db '+db;
|
|
797
|
+
el.style.display = '';
|
|
798
|
+
}
|
|
784
799
|
paintSidebar(); render();
|
|
785
800
|
}
|
|
786
801
|
function paintSidebar(){
|
package/src/config.ts
CHANGED
|
@@ -42,8 +42,18 @@ function defaultDbPath(): string {
|
|
|
42
42
|
|
|
43
43
|
const dbPath = process.env.COXPIT_DB || defaultDbPath();
|
|
44
44
|
|
|
45
|
+
// 버전 SSOT = package.json (하드코딩 문자열 이중화 방지)
|
|
46
|
+
const pkgVersion: string = (() => {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0';
|
|
49
|
+
} catch {
|
|
50
|
+
return '0.0.0';
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
|
|
45
54
|
/** 런타임 설정. 시크릿은 전부 env 주입(번들 0). */
|
|
46
55
|
export const config = {
|
|
56
|
+
version: pkgVersion,
|
|
47
57
|
// UTF-8 보장된 로케일 — PTY/원격 셸에 명시 전달용
|
|
48
58
|
lang: process.env.LANG!,
|
|
49
59
|
host: process.env.COXPIT_HOST ?? '127.0.0.1',
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,16 @@ import { acquireDaemonLock } from './lock';
|
|
|
5
5
|
import { reconcileOrphanRuns } from './orchestrator';
|
|
6
6
|
import { buildServer } from './server';
|
|
7
7
|
|
|
8
|
+
// Windows 네이티브는 에이전트 실행 계층(sh·tmux·git worktree over sh)이 성립하지 않는다.
|
|
9
|
+
// 보드/원격 머신 관제는 되지만 로컬 run 은 불가 — WSL 데몬을 안내한다.
|
|
10
|
+
if (process.platform === 'win32') {
|
|
11
|
+
console.warn(
|
|
12
|
+
'[coxpit] Windows detected: local agent runs need a POSIX shell + tmux, which Windows lacks.\n' +
|
|
13
|
+
'[coxpit] Run the daemon inside WSL2 (npm i -g coxpit) and connect from the browser or desktop app,\n' +
|
|
14
|
+
'[coxpit] or register remote (ssh) machines only — the local machine will fail readiness checks.',
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
// DB 를 열기 전에 단일 데몬 보장 — 이미 떠 있으면 그 URL 을 안내하고 종료.
|
|
9
19
|
await acquireDaemonLock();
|
|
10
20
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -42,6 +42,25 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
|
|
|
42
42
|
const liveChildren = new Map<number, ChildProcess>();
|
|
43
43
|
const stoppedRuns = new Set<number>();
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 원격 에이전트 kill 스크립트 — pid 파일 기준.
|
|
47
|
+
* $$ 가 그룹 리더가 아닐 수 있어 ps 로 실제 pgid 를 조회해 그룹째 죽인다
|
|
48
|
+
* (pkill -P 폴백은 직계 자식만 잡아 에이전트가 띄운 손자 프로세스가 잔존했음).
|
|
49
|
+
* TERM 후에도 살아 있으면 KILL 로 에스컬레이션, 끝나면 pid 파일 제거.
|
|
50
|
+
*/
|
|
51
|
+
function remoteKillScript(worktreePath: string): string {
|
|
52
|
+
const pidFile = shq(`${worktreePath}/.coxpit-agent.pid`);
|
|
53
|
+
return (
|
|
54
|
+
`P=$(cat ${pidFile} 2>/dev/null); [ -n "$P" ] && {` +
|
|
55
|
+
` PG=$(ps -o pgid= -p "$P" 2>/dev/null | tr -d ' ');` +
|
|
56
|
+
` kill -TERM -"\${PG:-$P}" 2>/dev/null || { pkill -TERM -P "$P" 2>/dev/null; kill -TERM "$P" 2>/dev/null; };` +
|
|
57
|
+
` sleep 2;` +
|
|
58
|
+
` kill -0 "$P" 2>/dev/null && { kill -KILL -"\${PG:-$P}" 2>/dev/null || { pkill -KILL -P "$P" 2>/dev/null; kill -KILL "$P" 2>/dev/null; }; };` +
|
|
59
|
+
` rm -f ${pidFile};` +
|
|
60
|
+
` } ; true`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
interface RunContext {
|
|
46
65
|
runId: number;
|
|
47
66
|
machine: MachineTarget;
|
|
@@ -313,13 +332,8 @@ export async function stopRun(runId: number): Promise<{ ok: boolean; detail: str
|
|
|
313
332
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
314
333
|
const run = rr[0];
|
|
315
334
|
if (ctx && run && ctx.machine.kind !== 'local' && ctx.machine.address !== '' && run.worktreePath) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
await runShellOn(
|
|
319
|
-
ctx.machine,
|
|
320
|
-
`P=$(cat ${pidFile} 2>/dev/null) && { kill -TERM -"$P" 2>/dev/null || { pkill -TERM -P "$P" 2>/dev/null; kill -TERM "$P" 2>/dev/null; }; } || true`,
|
|
321
|
-
8000,
|
|
322
|
-
);
|
|
335
|
+
// pgid 조회 그룹 kill + TERM→KILL 에스컬레이션(스크립트 내 sleep 2 포함 — 타임아웃 여유).
|
|
336
|
+
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
|
323
337
|
}
|
|
324
338
|
|
|
325
339
|
// 로컬(또는 ssh 채널) 프로세스 그룹 종료 — sh 손자(실제 에이전트) 포함.
|
|
@@ -749,6 +763,10 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
|
|
|
749
763
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
750
764
|
const run = rr[0];
|
|
751
765
|
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
766
|
+
// 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
|
|
767
|
+
if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
|
|
768
|
+
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
|
769
|
+
}
|
|
752
770
|
await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`=coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
|
|
753
771
|
const rm = await runShellOn(
|
|
754
772
|
ctx.machine,
|
package/src/server.ts
CHANGED
|
@@ -33,7 +33,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
33
33
|
app.addHook('onRequest', authGate);
|
|
34
34
|
|
|
35
35
|
// 무인증 헬스(외부 감시용)
|
|
36
|
-
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version:
|
|
36
|
+
app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: config.version }));
|
|
37
37
|
|
|
38
38
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨.
|
|
39
39
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -57,6 +57,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
57
57
|
return {
|
|
58
58
|
machines: ms, repos: rs, tasks: ts, captures: dcs,
|
|
59
59
|
runs: rns.map((r) => ({ ...r, events: byRun.get(r.id) ?? [] })),
|
|
60
|
+
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
61
|
+
daemon: { version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath },
|
|
60
62
|
};
|
|
61
63
|
});
|
|
62
64
|
|
|
@@ -502,7 +504,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
502
504
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
503
505
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
504
506
|
addSink(socket);
|
|
505
|
-
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version:
|
|
507
|
+
socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: config.version }));
|
|
506
508
|
socket.on('close', () => removeSink(socket));
|
|
507
509
|
});
|
|
508
510
|
|