coxpit 5.8.0 → 5.10.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/cockpit.ts +38 -4
- package/src/orchestrator.ts +28 -15
- package/src/server.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.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/cockpit.ts
CHANGED
|
@@ -81,7 +81,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
81
81
|
.pb-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:11px;padding:3px 9px;cursor:pointer;font-family:var(--mono)}
|
|
82
82
|
.pb-btn:hover:not([disabled]){color:var(--ink);border-color:var(--line-hi)}
|
|
83
83
|
.pb-btn[disabled]{opacity:.4;cursor:default}
|
|
84
|
-
.panes{flex:1;display:
|
|
84
|
+
.panes{flex:1;display:none;gap:1px;background:var(--line);padding:1px;min-height:0} /* 초기 숨김 — layoutPanes 가 페인 있을 때만 grid 로 */
|
|
85
85
|
.pane{display:flex;flex-direction:column;background:var(--bg);min-width:0;min-height:0;overflow:hidden}
|
|
86
86
|
.pane.focus .pane-h{background:var(--brand-dim)}
|
|
87
87
|
.pane.focus{box-shadow:inset 0 0 0 1px rgba(78,201,176,.4)}
|
|
@@ -109,7 +109,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
109
109
|
.empty .card{max-width:440px}
|
|
110
110
|
.empty .glyph{font-family:var(--mono);font-size:24px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
|
|
111
111
|
.empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
|
|
112
|
-
.empty p{color:var(--muted);font-size:13px;margin:0}
|
|
112
|
+
.empty p{color:var(--muted);font-size:13px;margin:0 0 18px}
|
|
113
|
+
.empty .cta{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:13px;font-weight:600;
|
|
114
|
+
color:var(--brand-ink);background:var(--brand);border:none;border-radius:9px;padding:10px 16px;cursor:pointer}
|
|
115
|
+
.empty .cta:hover{filter:brightness(1.06)}
|
|
116
|
+
.empty .hint{color:var(--faint);font-size:11.5px;margin-top:12px}
|
|
117
|
+
.pb-btn.session{color:var(--brand);border-color:rgba(78,201,176,.35)}
|
|
118
|
+
.pb-btn.session:hover:not([disabled]){background:var(--brand-dim);border-color:var(--brand)}
|
|
113
119
|
|
|
114
120
|
.reqbar{display:flex;align-items:center;gap:9px;border-top:1px solid var(--line);background:var(--surface);padding:9px 12px;font-family:var(--mono)}
|
|
115
121
|
.modes{display:inline-flex;gap:2px;border:1px solid var(--line);border-radius:8px;padding:2px;background:var(--panel)}
|
|
@@ -188,14 +194,17 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
188
194
|
<span id="paneCount">0 panes</span>
|
|
189
195
|
<span style="color:var(--faint)">· run 을 열수록 자동 분할(타일)</span>
|
|
190
196
|
<span class="grow"></span>
|
|
197
|
+
<button class="pb-btn session" id="sessionBtn" title="에이전트에 안 묶인 자유 작업 세션(터미널) 열기">+ Session</button>
|
|
191
198
|
<button class="pb-btn" id="closeBtn" disabled title="포커스 페인 닫기">× close focused</button>
|
|
192
199
|
</div>
|
|
193
200
|
<div class="panes" id="panes"></div>
|
|
194
201
|
<div class="empty" id="empty">
|
|
195
202
|
<div class="card">
|
|
196
203
|
<div class="glyph">⌗ ⌗ ⌗</div>
|
|
197
|
-
<h1
|
|
198
|
-
<p
|
|
204
|
+
<h1>여기서 작업을 시작하세요</h1>
|
|
205
|
+
<p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도 그 터미널이 페인으로 열립니다.</p>
|
|
206
|
+
<button class="cta" id="sessionCta">+ 새 작업 세션 열기</button>
|
|
207
|
+
<div class="hint">세션 = repo <b>최상위 체크아웃</b>의 tmux 셸(격리 폴더 아님, 전체를 보고 관리). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
|
|
199
208
|
</div>
|
|
200
209
|
</div>
|
|
201
210
|
<div class="reqbar">
|
|
@@ -452,6 +461,30 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
452
461
|
}
|
|
453
462
|
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
454
463
|
|
|
464
|
+
// ── 자유 작업 세션(workbench) — 에이전트에 안 묶인 터미널. repo 워크트리 + tmux 셸. ──
|
|
465
|
+
var openingSession = false;
|
|
466
|
+
async function openSession(){
|
|
467
|
+
if (openingSession) return;
|
|
468
|
+
var repos = fleet.repos||[];
|
|
469
|
+
if (!repos.length){ toast('먼저 repo 를 등록하세요 — 보드(← Board)에서 Add repository'); return; }
|
|
470
|
+
var repoId = Number($('reqRepo').value) || repos[0].id;
|
|
471
|
+
var rp = repoById[repoId];
|
|
472
|
+
openingSession = true; $('sessionBtn').disabled = true;
|
|
473
|
+
try{
|
|
474
|
+
var res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
|
|
475
|
+
body:JSON.stringify({repoId:repoId, title:'Session', root:true})});
|
|
476
|
+
var j = await res.json().catch(function(){return{};});
|
|
477
|
+
if (res.ok && j.runId){
|
|
478
|
+
await hydrate();
|
|
479
|
+
openRunPane(j.runId);
|
|
480
|
+
toast('세션 열림'+(rp?(' · '+rp.name+' 최상위'):'')+' — 이 터미널에서 직접 에이전트를 구동하세요');
|
|
481
|
+
} else { toast('세션 실패: '+(j.detail||j.error||res.status)); }
|
|
482
|
+
}catch(e){ toast('세션 실패: '+e); }
|
|
483
|
+
finally{ openingSession=false; $('sessionBtn').disabled=false; }
|
|
484
|
+
}
|
|
485
|
+
$('sessionBtn').addEventListener('click', openSession);
|
|
486
|
+
$('sessionCta').addEventListener('click', openSession);
|
|
487
|
+
|
|
455
488
|
// ── 요청바: New(팬아웃) / Steer / Broadcast ──
|
|
456
489
|
var reqMode = 'new';
|
|
457
490
|
function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
|
|
@@ -639,6 +672,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
639
672
|
function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
|
|
640
673
|
|
|
641
674
|
setMode('new');
|
|
675
|
+
layoutPanes(); // 초기 빈 상태 정합(paneCount·close 비활성·panes 숨김)
|
|
642
676
|
hydrate();
|
|
643
677
|
wsConnect();
|
|
644
678
|
window.addEventListener('resize', function(){ panes.forEach(fitPane); });
|
package/src/orchestrator.ts
CHANGED
|
@@ -977,7 +977,7 @@ export async function mergeRun(runId: number): Promise<{ ok: boolean; detail: st
|
|
|
977
977
|
* 띄우지 않는다. 사람이 터미널로 들어가(원하면 claude TUI 로) 오래 작업하고,
|
|
978
978
|
* coxpit 은 diff·merge·PR·export 레일만 제공한다. status='open'.
|
|
979
979
|
*/
|
|
980
|
-
export async function openWorkbench(repoId: number, title: string): Promise<{
|
|
980
|
+
export async function openWorkbench(repoId: number, title: string, root = false): Promise<{
|
|
981
981
|
ok: boolean; detail: string; taskId?: number; runId?: number;
|
|
982
982
|
}> {
|
|
983
983
|
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
@@ -988,34 +988,41 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
|
|
|
988
988
|
if (!m) return { ok: false, detail: 'machine not found' };
|
|
989
989
|
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
990
990
|
|
|
991
|
-
|
|
991
|
+
// root=true: repo 실체 체크아웃(최상위)에 그대로 tmux 를 연다 — 격리 worktree 아님(전체 확인·관리용).
|
|
992
|
+
// branch='' 로 남겨 merge 는 자동 거부(=이미 base). cleanup 도 worktree remove 를 건너뛴다.
|
|
993
|
+
// root=false: 기존 workbench — 격리 worktree + 브랜치(수동 변경 후 Review 에서 merge).
|
|
994
|
+
const agent = root ? 'session' : 'workbench';
|
|
995
|
+
const tIns = await db.insert(tasks).values({ repoId, title: title || (root ? 'Session' : 'Workbench'), prompt: root ? '(root session)' : '(interactive workbench)' }).returning();
|
|
992
996
|
const task = tIns[0]!;
|
|
993
|
-
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent
|
|
997
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent, status: 'pending' }).returning();
|
|
994
998
|
const run = rIns[0]!;
|
|
995
999
|
const runId = run.id;
|
|
996
|
-
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent
|
|
1000
|
+
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent, branch: '', filesChanged: 0 });
|
|
997
1001
|
|
|
998
|
-
const branch = `coxpit/r${runId}`;
|
|
999
|
-
const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
|
|
1000
|
-
const wtPath = ppath.join(wtParent, `r${runId}`);
|
|
1001
1002
|
const session = `coxpit-r${runId}`;
|
|
1003
|
+
const branch = root ? '' : `coxpit/r${runId}`;
|
|
1004
|
+
const wtParent = ppath.join(ppath.dirname(repo.path), '.coxpit-worktrees');
|
|
1005
|
+
const wtPath = root ? repo.path : ppath.join(wtParent, `r${runId}`);
|
|
1002
1006
|
|
|
1007
|
+
// export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
|
|
1008
|
+
// 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만.
|
|
1003
1009
|
const prep = await runShellOn(
|
|
1004
1010
|
machine,
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1011
|
+
root
|
|
1012
|
+
? `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1013
|
+
` && tmux new-session -d -s ${shq(session)} -c ${shq(repo.path)}`
|
|
1014
|
+
: `export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
|
|
1015
|
+
` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1016
|
+
` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
|
|
1010
1017
|
20000,
|
|
1011
1018
|
);
|
|
1012
1019
|
if (!prep.ok) {
|
|
1013
|
-
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: '
|
|
1020
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'session prep failed' });
|
|
1014
1021
|
return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
|
|
1015
1022
|
}
|
|
1016
1023
|
await setRun(runId, { status: 'open', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
1017
|
-
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench:
|
|
1018
|
-
return { ok: true, detail: 'workbench open', taskId: task.id, runId };
|
|
1024
|
+
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, workbench: !root, rootSession: root }));
|
|
1025
|
+
return { ok: true, detail: root ? 'root session open' : 'workbench open', taskId: task.id, runId };
|
|
1019
1026
|
}
|
|
1020
1027
|
|
|
1021
1028
|
/**
|
|
@@ -1796,6 +1803,12 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
|
|
|
1796
1803
|
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
|
1797
1804
|
}
|
|
1798
1805
|
await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`=coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
|
|
1806
|
+
// 루트 세션(branch='' 또는 worktreePath=repo 실체)은 격리 worktree 가 아니다 —
|
|
1807
|
+
// git worktree remove 를 메인 체크아웃에 걸면 안 되므로 tmux 만 정리하고 포인터를 비운다.
|
|
1808
|
+
if (!run.branch || run.worktreePath === ctx.repoPath) {
|
|
1809
|
+
await setRun(runId, { worktreePath: '', tmuxWindow: '' });
|
|
1810
|
+
return { ok: true, detail: 'root session closed (checkout preserved)' };
|
|
1811
|
+
}
|
|
1799
1812
|
const rm = await runShellOn(
|
|
1800
1813
|
ctx.machine,
|
|
1801
1814
|
`git -C ${shq(ctx.repoPath)} worktree remove --force ${shq(run.worktreePath)} 2>&1` +
|
package/src/server.ts
CHANGED
|
@@ -1010,12 +1010,12 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1010
1010
|
return res;
|
|
1011
1011
|
});
|
|
1012
1012
|
|
|
1013
|
-
// Workbench — 인터랙티브 작업방(
|
|
1013
|
+
// Workbench — 인터랙티브 작업방(에이전트 없음). root=true 면 repo 실체 체크아웃(최상위)에 tmux, 아니면 격리 worktree.
|
|
1014
1014
|
app.post('/api/workbench', async (req, reply) => {
|
|
1015
|
-
const b = (req.body ?? {}) as { repoId?: number; title?: string };
|
|
1015
|
+
const b = (req.body ?? {}) as { repoId?: number; title?: string; root?: boolean };
|
|
1016
1016
|
const repoId = Number(b.repoId);
|
|
1017
1017
|
if (!repoId) return reply.code(400).send({ error: 'repoId required' });
|
|
1018
|
-
const res = await openWorkbench(repoId, (b.title ?? '').trim());
|
|
1018
|
+
const res = await openWorkbench(repoId, (b.title ?? '').trim(), b.root === true);
|
|
1019
1019
|
if (!res.ok) return reply.code(422).send(res);
|
|
1020
1020
|
return reply.code(201).send(res);
|
|
1021
1021
|
});
|