coxpit 3.2.1 → 3.3.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 CHANGED
@@ -64,7 +64,7 @@ Your keys and login never touch coxpit's config or database.
64
64
  | env | default | what |
65
65
  |---|---|---|
66
66
  | `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind |
67
- | `COXPIT_DB` | `./coxpit.db` | SQLite (libSQL) file |
67
+ | `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
68
68
  | `COXPIT_AUTH_PASS` / `COXPIT_AUTH_USER` | — / `admin` | basic auth (empty pass = open; set it) |
69
69
  | `COXPIT_AUTH_DISABLED` | — | `1` disables auth (local dev only) |
70
70
  | `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
@@ -87,6 +87,8 @@ machines — git worktrees · tmux sessions · agent CLIs
87
87
 
88
88
  One daemon, one SQLite file, zero external services. Machines are reached over SSH; the local machine is just `sh`.
89
89
 
90
+ **One daemon per machine.** Every install method shares `~/.coxpit/` — the daemon takes a lock there (`daemon.lock.json`) and refuses to start if another daemon already owns the database (running two would corrupt each other's live runs). The desktop app checks for a running daemon first and attaches to it (prompting for its basic auth if set); it only spawns its own embedded daemon when none is running. So npm CLI, launchd/systemd service, and the desktop app all see the same machines, tasks, and run history.
91
+
90
92
  ## Status
91
93
 
92
94
  `v3.0` — fleet, compare/merge, terminal, Design Mode, run destinations (merge · export · PR), swarm (plan fan-out + integrate with conflict-resolving agents), AI review, and sessions (work/ask + notifications) — all shipped and e2e-tested. Roadmap: ROADMAP.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "3.2.1",
3
+ "version": "3.3.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",
@@ -44,9 +44,9 @@
44
44
  "dependencies": {
45
45
  "@fastify/websocket": "^11",
46
46
  "@libsql/client": "^0.14",
47
- "@xterm/addon-fit": "^0.11.0",
48
- "@xterm/addon-unicode11": "^0.9.0",
49
- "@xterm/xterm": "^6.0.0",
47
+ "@xterm/addon-fit": "^0.10.0",
48
+ "@xterm/addon-unicode11": "^0.8.0",
49
+ "@xterm/xterm": "^5.5.0",
50
50
  "dotenv": "^16",
51
51
  "drizzle-orm": "^0.38",
52
52
  "fastify": "^5",
package/src/config.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import 'dotenv/config';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
2
5
 
3
6
  // GUI 앱(Finder/데스크톱)에서 뜨면 PATH 가 최소(/usr/bin:/bin...)라 brew 로 설치한
4
7
  // 도구(claude·tmux·git)를 못 찾는다 — 표준 설치 경로를 부팅 시 1회 보강한다.
@@ -24,13 +27,30 @@ import 'dotenv/config';
24
27
  if (process.env.LC_ALL && !/utf-?8/i.test(process.env.LC_ALL)) delete process.env.LC_ALL;
25
28
  }
26
29
 
30
+ // 기본 DB 경로 — npm/dmg 어느 설치든 같은 상태를 보도록 ~/.coxpit 로 통일한다.
31
+ // 구버전(cwd 상대 ./coxpit.db)로 쌓아온 사용자는 그 파일이 존재하는 한 그대로 존중.
32
+ function defaultDbPath(): string {
33
+ const legacy = path.resolve('./coxpit.db');
34
+ if (fs.existsSync(legacy)) {
35
+ console.log(`[coxpit] using legacy database at ${legacy} (move it to ~/.coxpit/coxpit.db to adopt the shared default)`);
36
+ return legacy;
37
+ }
38
+ const dir = path.join(os.homedir(), '.coxpit');
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ return path.join(dir, 'coxpit.db');
41
+ }
42
+
43
+ const dbPath = process.env.COXPIT_DB || defaultDbPath();
44
+
27
45
  /** 런타임 설정. 시크릿은 전부 env 주입(번들 0). */
28
46
  export const config = {
29
47
  // UTF-8 보장된 로케일 — PTY/원격 셸에 명시 전달용
30
48
  lang: process.env.LANG!,
31
49
  host: process.env.COXPIT_HOST ?? '127.0.0.1',
32
50
  port: Number(process.env.COXPIT_PORT ?? 8210),
33
- dbPath: process.env.COXPIT_DB ?? './coxpit.db',
51
+ dbPath,
52
+ // 단일 데몬 락 파일 — DB 와 같은 폴더(그 DB 를 지키는 락이므로)
53
+ lockPath: path.join(path.dirname(path.resolve(dbPath)), 'daemon.lock.json'),
34
54
  // 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
35
55
  sshKey: process.env.COXPIT_SSH_KEY ?? '',
36
56
  // run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
package/src/index.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import { config } from './config';
2
2
  import { db, ensureSchema } from './db';
3
3
  import { machines } from './db/schema';
4
+ import { acquireDaemonLock } from './lock';
5
+ import { reconcileOrphanRuns } from './orchestrator';
4
6
  import { buildServer } from './server';
5
7
 
8
+ // DB 를 열기 전에 단일 데몬 보장 — 이미 떠 있으면 그 URL 을 안내하고 종료.
9
+ await acquireDaemonLock();
10
+
6
11
  await ensureSchema();
7
12
 
8
13
  // 첫 실행 시 로컬 머신 시드(데몬이 도는 이 기계).
@@ -10,5 +15,17 @@ if ((await db.select().from(machines)).length === 0) {
10
15
  await db.insert(machines).values({ slug: 'local', name: 'This machine', kind: 'local', online: true });
11
16
  }
12
17
 
18
+ // 재시작으로 고아가 된 running run 정산(카드가 영원히 '진행 중'으로 남는 것 방지).
19
+ const orphans = await reconcileOrphanRuns();
20
+ if (orphans > 0) console.log(`[coxpit] settled ${orphans} orphaned run(s) from a previous daemon instance`);
21
+
13
22
  const app = await buildServer();
14
- await app.listen({ host: config.host, port: config.port });
23
+ try {
24
+ await app.listen({ host: config.host, port: config.port });
25
+ } catch (e) {
26
+ if ((e as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
27
+ console.error(`[coxpit] port ${config.port} is already in use — is another daemon (or app) running? Set COXPIT_PORT to change.`);
28
+ process.exit(1);
29
+ }
30
+ throw e;
31
+ }
package/src/lock.ts ADDED
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import { config } from './config';
3
+
4
+ // 단일 데몬 락 — DB 폴더의 daemon.lock.json.
5
+ // 두 데몬이 같은 DB 를 잡으면 부팅 시 reconcileOrphanRuns() 가 상대 데몬의
6
+ // 살아있는 run 을 고아로 정산해버린다. 락 + 헬스 프로브로 원천 차단한다.
7
+
8
+ interface LockInfo {
9
+ pid: number;
10
+ host: string;
11
+ port: number;
12
+ }
13
+
14
+ /** 해당 주소에 살아있는 coxpit 데몬이 있는지 확인 (/api/health 는 인증 면제). */
15
+ export async function probeCoxpit(host: string, port: number, timeoutMs = 1500): Promise<boolean> {
16
+ try {
17
+ const ac = new AbortController();
18
+ const t = setTimeout(() => ac.abort(), timeoutMs);
19
+ const res = await fetch(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}/api/health`, { signal: ac.signal });
20
+ clearTimeout(t);
21
+ if (!res.ok) return false;
22
+ const body = (await res.json()) as { name?: string };
23
+ return body?.name === 'coxpit';
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ export function readLock(lockPath: string): LockInfo | null {
30
+ try {
31
+ const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as LockInfo;
32
+ return Number.isInteger(raw?.pid) && Number.isInteger(raw?.port) ? raw : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function pidAlive(pid: number): boolean {
39
+ try { process.kill(pid, 0); return true; } catch { return false; }
40
+ }
41
+
42
+ /**
43
+ * 락 획득. 이미 살아있는 데몬이 같은 DB 를 잡고 있으면 안내 후 종료.
44
+ * 죽은 프로세스가 남긴 stale 락은 치우고 진행한다.
45
+ */
46
+ export async function acquireDaemonLock(): Promise<void> {
47
+ const existing = readLock(config.lockPath);
48
+ if (existing && existing.pid !== process.pid) {
49
+ // pid 재사용 오탐을 피하려고 헬스 프로브를 진실로 삼는다.
50
+ const alive = pidAlive(existing.pid) && (await probeCoxpit(existing.host ?? '127.0.0.1', existing.port));
51
+ if (alive) {
52
+ const url = `http://${(existing.host ?? '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : existing.host}:${existing.port}/`;
53
+ console.error(
54
+ `[coxpit] another daemon already owns this database (pid ${existing.pid}, ${url}).\n` +
55
+ `[coxpit] open that URL instead, or stop it first — running two daemons on one DB corrupts live runs.`,
56
+ );
57
+ process.exit(1);
58
+ }
59
+ try { fs.unlinkSync(config.lockPath); } catch { /* gone */ }
60
+ }
61
+
62
+ fs.writeFileSync(config.lockPath, JSON.stringify({ pid: process.pid, host: config.host, port: config.port } satisfies LockInfo));
63
+
64
+ const release = () => {
65
+ try {
66
+ const cur = readLock(config.lockPath);
67
+ if (cur?.pid === process.pid) fs.unlinkSync(config.lockPath);
68
+ } catch { /* best effort */ }
69
+ };
70
+ process.on('exit', release);
71
+ for (const sig of ['SIGINT', 'SIGTERM'] as const) {
72
+ process.on(sig, () => { release(); process.exit(0); });
73
+ }
74
+ }
@@ -295,7 +295,17 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
295
295
  */
296
296
  export async function stopRun(runId: number): Promise<{ ok: boolean; detail: string }> {
297
297
  const child = liveChildren.get(runId);
298
- if (!child) return { ok: false, detail: 'not running' };
298
+ if (!child) {
299
+ // 데몬 재시작 등으로 고아가 된 좀비 run — 프로세스는 없는데 DB 만 running.
300
+ // stop 요청을 정산으로 처리해 카드가 영원히 '진행 중'으로 남지 않게 한다.
301
+ const zr = (await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0];
302
+ if (zr && (zr.status === 'running' || zr.status === 'starting')) {
303
+ await setRun(runId, { status: 'stopped', endedAt: new Date(), exitSummary: 'orphaned (daemon restarted) — settled by stop' });
304
+ await recordEvent(runId, 'meta', JSON.stringify({ orphanSettled: true }));
305
+ return { ok: true, detail: 'no live process — settled as stopped' };
306
+ }
307
+ return { ok: false, detail: 'not running' };
308
+ }
299
309
  stoppedRuns.add(runId);
300
310
 
301
311
  // 원격이면 먼저 원격 프로세스를 pid 파일로 죽인다(ssh 채널만 끊으면 잔존 가능).
@@ -719,6 +729,21 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
719
729
  /**
720
730
  * worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
721
731
  */
732
+ /**
733
+ * 부팅 정산 — 데몬 재시작 후 살아있는 자식이 있을 수 없는데 DB 가 running/starting 인
734
+ * run(고아)을 failed 로 정리한다. workbench('open')는 에이전트가 없으므로 대상 아님.
735
+ */
736
+ export async function reconcileOrphanRuns(): Promise<number> {
737
+ const stale = (await db.select().from(agentRuns)).filter(
738
+ (r) => r.status === 'running' || r.status === 'starting',
739
+ );
740
+ for (const r of stale) {
741
+ await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart' });
742
+ await recordEvent(r.id, 'error', 'daemon restarted while this run was live — settled as failed (worktree/branch preserved; diff still reviewable)');
743
+ }
744
+ return stale.length;
745
+ }
746
+
722
747
  export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
723
748
  const ctx = await loadContext(runId);
724
749
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
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: '3.2.1' }));
36
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.3.0' }));
37
37
 
38
38
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
39
39
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -502,7 +502,7 @@ export async function buildServer(): Promise<FastifyInstance> {
502
502
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
503
503
  app.get('/ws', { websocket: true }, (socket) => {
504
504
  addSink(socket);
505
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.2.1' }));
505
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.2.2' }));
506
506
  socket.on('close', () => removeSink(socket));
507
507
  });
508
508