coxpit 5.9.0 → 5.11.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 +101 -24
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +81 -16
- package/src/server.ts +15 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.11.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
|
@@ -133,6 +133,31 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
133
133
|
.reqgo:disabled{opacity:.4;cursor:default}
|
|
134
134
|
.reqgo.bcast{background:var(--open);color:#0b0d12}
|
|
135
135
|
.reqgo.steer{background:var(--running);color:#04121e}
|
|
136
|
+
/* ── folder picker (자유 세션 폴더 지정) ── */
|
|
137
|
+
.modal{position:fixed;inset:0;background:rgba(4,6,10,.6);display:none;align-items:center;justify-content:center;z-index:60}
|
|
138
|
+
.modal.on{display:flex}
|
|
139
|
+
.pick{width:min(560px,92vw);max-height:76vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-hi);border-radius:14px;overflow:hidden;font-family:var(--mono)}
|
|
140
|
+
.pick-h{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}
|
|
141
|
+
.pick-h .t{font-size:13px;color:var(--ink);font-weight:600}
|
|
142
|
+
.pick-h .x{margin-left:auto;background:none;border:none;color:var(--faint);font-size:16px;cursor:pointer}
|
|
143
|
+
.pick-h .x:hover{color:var(--ink)}
|
|
144
|
+
.pick-path{padding:8px 15px;font-size:11.5px;color:var(--brand);border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
|
|
145
|
+
.pick-list{flex:1;overflow:auto;padding:6px}
|
|
146
|
+
.pick-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;color:var(--muted)}
|
|
147
|
+
.pick-row:hover{background:var(--surface2);color:var(--ink)}
|
|
148
|
+
.pick-row .ic{width:14px;text-align:center;color:var(--faint)}
|
|
149
|
+
.pick-row.up .ic{color:var(--muted)}
|
|
150
|
+
.pick-row .rp{margin-left:auto;font-size:9px;color:var(--brand);border:1px solid rgba(78,201,176,.3);border-radius:999px;padding:0 6px}
|
|
151
|
+
.pick-f{display:flex;align-items:center;gap:10px;padding:12px 15px;border-top:1px solid var(--line)}
|
|
152
|
+
.pick-f .go{margin-left:auto;font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 15px;cursor:pointer}
|
|
153
|
+
.pick-f .home{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:7px;padding:7px 11px;cursor:pointer}
|
|
154
|
+
.pick-f .home:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
155
|
+
|
|
156
|
+
.lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
|
|
157
|
+
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
158
|
+
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
159
|
+
.tnode.session .p{color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis}
|
|
160
|
+
|
|
136
161
|
.toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
|
|
137
162
|
font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
|
|
138
163
|
.toast.show{opacity:1}
|
|
@@ -204,7 +229,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
204
229
|
<h1>여기서 작업을 시작하세요</h1>
|
|
205
230
|
<p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도 그 터미널이 페인으로 열립니다.</p>
|
|
206
231
|
<button class="cta" id="sessionCta">+ 새 작업 세션 열기</button>
|
|
207
|
-
<div class="hint">세션 =
|
|
232
|
+
<div class="hint">세션 = <b>지정한 폴더</b>의 tmux 셸(특정 프로젝트에 소속되지 않음). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
|
|
208
233
|
</div>
|
|
209
234
|
</div>
|
|
210
235
|
<div class="reqbar">
|
|
@@ -243,6 +268,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
243
268
|
<div class="rv-cols" id="rvCols"></div>
|
|
244
269
|
</section>
|
|
245
270
|
|
|
271
|
+
<div class="modal" id="pickModal">
|
|
272
|
+
<div class="pick">
|
|
273
|
+
<div class="pick-h"><span class="t">세션 폴더 지정</span><button class="x" id="pickClose" title="닫기">×</button></div>
|
|
274
|
+
<div class="pick-path" id="pickPath">…</div>
|
|
275
|
+
<div class="pick-list" id="pickList"></div>
|
|
276
|
+
<div class="pick-f">
|
|
277
|
+
<button class="home" id="pickHome" title="홈으로">⌂ home</button>
|
|
278
|
+
<span style="font-size:11px;color:var(--faint)">이 폴더에서 세션 시작</span>
|
|
279
|
+
<button class="go" id="pickGo">여기서 열기</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
246
284
|
<div class="toast" id="toast"></div>
|
|
247
285
|
|
|
248
286
|
<script src="/vendor/xterm.js"></script>
|
|
@@ -287,7 +325,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
287
325
|
if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
|
|
288
326
|
}
|
|
289
327
|
function populateReq(){
|
|
290
|
-
var repos = fleet.repos||[];
|
|
328
|
+
var repos = (fleet.repos||[]).filter(function(r){ return r.kind!=='sessions'; });
|
|
291
329
|
fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
|
|
292
330
|
// repo 미선택 상태면 포커스 페인의 repo 로 기본
|
|
293
331
|
if (focusId){ var rp = repoOfRun((panes.find(function(p){return p.id===focusId;})||{}).runId); if (rp!=null) $('reqRepo').value=String(rp); }
|
|
@@ -303,9 +341,29 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
303
341
|
var groupById = {}; groups.forEach(function(g){ groupById[g.id]=g; });
|
|
304
342
|
var tasksByRepo = {}; tasks.forEach(function(t){ (tasksByRepo[t.repoId]=tasksByRepo[t.repoId]||[]).push(t); });
|
|
305
343
|
var runsByTask = {}; runs.forEach(function(r){ (runsByTask[r.taskId]=runsByTask[r.taskId]||[]).push(r); });
|
|
306
|
-
|
|
344
|
+
// 세션 버킷(kind='sessions') 분리 — 프로젝트 트리와 별개 SESSIONS 섹션
|
|
345
|
+
var sessionRepoIds = {}; repos.forEach(function(r){ if (r.kind==='sessions') sessionRepoIds[r.id]=true; });
|
|
346
|
+
var realRepos = repos.filter(function(r){ return r.kind!=='sessions'; });
|
|
347
|
+
var sessRuns = [];
|
|
348
|
+
tasks.forEach(function(t){ if (sessionRepoIds[t.repoId]) (runsByTask[t.id]||[]).forEach(function(r){ sessRuns.push({run:r, title:t.title}); }); });
|
|
349
|
+
sessRuns.sort(function(a,b){ return b.run.id-a.run.id; });
|
|
350
|
+
|
|
307
351
|
var html = '';
|
|
308
|
-
|
|
352
|
+
// ── SESSIONS (자유 세션) ──
|
|
353
|
+
html += '<div class="lbl"><span>Sessions</span><span class="lnk" data-newsession="1">+ 새 세션</span></div>';
|
|
354
|
+
if (sessRuns.length){
|
|
355
|
+
sessRuns.forEach(function(s){
|
|
356
|
+
var r=s.run; var open = paneByRun[r.id] ? ' open' : '';
|
|
357
|
+
html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
358
|
+
+ '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span></div>';
|
|
359
|
+
});
|
|
360
|
+
} else {
|
|
361
|
+
html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
|
|
362
|
+
}
|
|
363
|
+
html += '<div class="tree-sep"></div>';
|
|
364
|
+
html += '<div class="lbl"><span>Projects</span></div>';
|
|
365
|
+
if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; }
|
|
366
|
+
realRepos.forEach(function(repo){
|
|
309
367
|
var rk = 'repo'+repo.id;
|
|
310
368
|
var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
|
|
311
369
|
var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
|
|
@@ -338,7 +396,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
338
396
|
return s;
|
|
339
397
|
}
|
|
340
398
|
$('tree').addEventListener('click', function(e){
|
|
341
|
-
|
|
399
|
+
if (e.target.closest('[data-newsession]')){ openSession(); return; }
|
|
400
|
+
var run = e.target.closest('[data-run]');
|
|
342
401
|
if (run){ openRunPane(+run.dataset.run); return; }
|
|
343
402
|
var fn = e.target.closest('[data-fold]');
|
|
344
403
|
if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
|
|
@@ -461,27 +520,43 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
461
520
|
}
|
|
462
521
|
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
463
522
|
|
|
464
|
-
// ── 자유
|
|
523
|
+
// ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
|
|
524
|
+
var pickPathCur = '';
|
|
525
|
+
function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
|
|
526
|
+
function openSession(){ $('pickModal').classList.add('on'); browseTo(''); }
|
|
527
|
+
function closePicker(){ $('pickModal').classList.remove('on'); }
|
|
528
|
+
async function browseTo(p){
|
|
529
|
+
try{
|
|
530
|
+
var d = await (await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):''))).json();
|
|
531
|
+
pickPathCur = d.path; $('pickPath').textContent = d.path;
|
|
532
|
+
var html = '';
|
|
533
|
+
if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-go="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
|
|
534
|
+
(d.dirs||[]).forEach(function(e){
|
|
535
|
+
var full = d.path==='/' ? '/'+e.name : d.path+'/'+e.name;
|
|
536
|
+
html += '<div class="pick-row" data-go="'+esc(full)+'"><span class="ic">'+(e.isRepo?'◆':'▸')+'</span>'
|
|
537
|
+
+ '<span>'+esc(e.name)+'</span>'+(e.isRepo?'<span class="rp">git</span>':'')+'</div>';
|
|
538
|
+
});
|
|
539
|
+
if (!(d.dirs||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">하위 폴더 없음 — 이 폴더에서 열 수 있습니다</div>';
|
|
540
|
+
$('pickList').innerHTML = html;
|
|
541
|
+
}catch(e){ $('pickList').innerHTML = '<div class="pick-row" style="color:var(--failed)">폴더를 읽을 수 없습니다</div>'; }
|
|
542
|
+
}
|
|
543
|
+
$('pickList').addEventListener('click', function(e){ var r=e.target.closest('[data-go]'); if (r) browseTo(r.dataset.go); });
|
|
544
|
+
$('pickClose').addEventListener('click', closePicker);
|
|
545
|
+
$('pickHome').addEventListener('click', function(){ browseTo(''); });
|
|
546
|
+
$('pickModal').addEventListener('click', function(e){ if (e.target===this) closePicker(); });
|
|
465
547
|
var openingSession = false;
|
|
466
|
-
async function
|
|
467
|
-
if (openingSession) return;
|
|
468
|
-
|
|
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;
|
|
548
|
+
$('pickGo').addEventListener('click', async function(){
|
|
549
|
+
if (openingSession || !pickPathCur) return;
|
|
550
|
+
openingSession=true; $('pickGo').disabled=true;
|
|
473
551
|
try{
|
|
474
|
-
var res = await fetch('/api/
|
|
475
|
-
body:JSON.stringify({
|
|
552
|
+
var res = await fetch('/api/session',{method:'POST',headers:{'content-type':'application/json'},
|
|
553
|
+
body:JSON.stringify({machineSlug:machineSlug(), path:pickPathCur})});
|
|
476
554
|
var j = await res.json().catch(function(){return{};});
|
|
477
|
-
if (res.ok && j.runId){
|
|
478
|
-
|
|
479
|
-
openRunPane(j.runId);
|
|
480
|
-
toast('세션 열림'+(rp?(' · '+rp.name):'')+' — 이 터미널에서 직접 에이전트를 구동하세요');
|
|
481
|
-
} else { toast('세션 실패: '+(j.detail||j.error||res.status)); }
|
|
555
|
+
if (res.ok && j.runId){ closePicker(); await hydrate(); openRunPane(j.runId); toast('세션 열림 · '+pickPathCur); }
|
|
556
|
+
else toast('세션 실패: '+(j.detail||j.error||res.status));
|
|
482
557
|
}catch(e){ toast('세션 실패: '+e); }
|
|
483
|
-
finally{ openingSession=false; $('
|
|
484
|
-
}
|
|
558
|
+
finally{ openingSession=false; $('pickGo').disabled=false; }
|
|
559
|
+
});
|
|
485
560
|
$('sessionBtn').addEventListener('click', openSession);
|
|
486
561
|
$('sessionCta').addEventListener('click', openSession);
|
|
487
562
|
|
|
@@ -559,8 +634,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
559
634
|
$('vtReview').addEventListener('click', showReview);
|
|
560
635
|
function reviewableTasks(){
|
|
561
636
|
var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
|
|
562
|
-
return (fleet.tasks||[]).filter(function(t){
|
|
563
|
-
.
|
|
637
|
+
return (fleet.tasks||[]).filter(function(t){
|
|
638
|
+
var rp = repoById[t.repoId]; if (rp && rp.kind==='sessions') return false; // 세션은 리뷰 대상 아님
|
|
639
|
+
return (byTask[t.id]||0)>=1;
|
|
640
|
+
}).sort(function(a,b){ return b.id-a.id; });
|
|
564
641
|
}
|
|
565
642
|
function renderReviewPicker(){
|
|
566
643
|
var tasks = reviewableTasks(); var sel=$('rvTask');
|
package/src/db/index.ts
CHANGED
|
@@ -106,4 +106,5 @@ export async function ensureSchema(): Promise<void> {
|
|
|
106
106
|
try { await client.execute("ALTER TABLE repos ADD COLUMN verify_cmd TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
107
107
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
108
108
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
109
|
+
try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
|
|
109
110
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const repos = sqliteTable('repos', {
|
|
|
20
20
|
name: text('name').notNull(),
|
|
21
21
|
defaultBranch: text('default_branch').notNull().default('main'),
|
|
22
22
|
verifyCmd: text('verify_cmd').notNull().default(''), // 정착한 run 을 검증하는 명령(테스트·빌드). 빈값 = 검증 없음
|
|
23
|
+
kind: text('kind').notNull().default('git'), // 'git' = 실제 repo | 'sessions' = 자유 세션 담는 가상 버킷(프로젝트 아님)
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
/** Design Mode 캡처 — 북마클릿 인스펙터가 보낸 UI 요소 컨텍스트. */
|
package/src/orchestrator.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from '
|
|
|
5
5
|
import { mkdir, copyFile, readFile, writeFile, rm, unlink } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import type { ChildProcess } from 'node:child_process';
|
|
8
|
-
import { eq, inArray } from 'drizzle-orm';
|
|
8
|
+
import { eq, inArray, and } from 'drizzle-orm';
|
|
9
9
|
import { config } from './config';
|
|
10
10
|
import { db } from './db';
|
|
11
11
|
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
|
|
@@ -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,93 @@ 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 };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 머신별 가상 "Sessions" 버킷(kind='sessions') 찾기-또는-만들기.
|
|
1030
|
+
* 자유 세션은 실제 프로젝트(repo)에 소속되지 않도록 이 버킷 밑에 담긴다 — 트리에서 별도 SESSIONS 섹션.
|
|
1031
|
+
*/
|
|
1032
|
+
async function ensureSessionsRepo(machineId: number): Promise<typeof repos.$inferSelect> {
|
|
1033
|
+
const found = await db.select().from(repos).where(and(eq(repos.machineId, machineId), eq(repos.kind, 'sessions'))).limit(1);
|
|
1034
|
+
if (found[0]) return found[0];
|
|
1035
|
+
const ins = await db.insert(repos).values({ machineId, path: homedir(), name: 'Sessions', defaultBranch: '', kind: 'sessions' }).returning();
|
|
1036
|
+
return ins[0]!;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* 자유 세션 — 임의 폴더에서 tmux 셸을 연다. 특정 프로젝트에 소속되지 않음(가상 Sessions 버킷).
|
|
1041
|
+
* git worktree 아님(branch=''), merge 자동 거부·cleanup 은 tmux 만 정리(폴더 보존).
|
|
1042
|
+
*/
|
|
1043
|
+
export async function openSessionAt(machineSlug: string, path: string, title: string): Promise<{
|
|
1044
|
+
ok: boolean; detail: string; taskId?: number; runId?: number;
|
|
1045
|
+
}> {
|
|
1046
|
+
const dir = (path || '').trim();
|
|
1047
|
+
if (!dir.startsWith('/')) return { ok: false, detail: 'absolute path required' };
|
|
1048
|
+
const mr = await db.select().from(machines).where(eq(machines.slug, machineSlug)).limit(1);
|
|
1049
|
+
const m = mr[0];
|
|
1050
|
+
if (!m) return { ok: false, detail: 'machine not found' };
|
|
1051
|
+
const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
1052
|
+
const chk = await runShellOn(machine, `test -d ${shq(dir)} && echo yes`, 8000);
|
|
1053
|
+
if (!/yes/.test(chk.stdout)) return { ok: false, detail: 'folder not found: ' + dir };
|
|
1054
|
+
|
|
1055
|
+
const bucket = await ensureSessionsRepo(m.id);
|
|
1056
|
+
const name = title || dir.split('/').filter(Boolean).pop() || dir;
|
|
1057
|
+
const tIns = await db.insert(tasks).values({ repoId: bucket.id, title: name, prompt: '(session)' }).returning();
|
|
1058
|
+
const task = tIns[0]!;
|
|
1059
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'session', status: 'pending' }).returning();
|
|
1060
|
+
const run = rIns[0]!;
|
|
1061
|
+
const runId = run.id;
|
|
1062
|
+
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'session', branch: '', filesChanged: 0 });
|
|
1063
|
+
|
|
1064
|
+
const session = `coxpit-r${runId}`;
|
|
1065
|
+
const prep = await runShellOn(
|
|
1066
|
+
machine,
|
|
1067
|
+
`export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1068
|
+
` && tmux new-session -d -s ${shq(session)} -c ${shq(dir)}`,
|
|
1069
|
+
15000,
|
|
1070
|
+
);
|
|
1071
|
+
if (!prep.ok) {
|
|
1072
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'session prep failed' });
|
|
1073
|
+
return { ok: false, detail: (prep.stderr || prep.stdout).trim().slice(0, 300) };
|
|
1074
|
+
}
|
|
1075
|
+
await setRun(runId, { status: 'open', branch: '', worktreePath: dir, tmuxWindow: session, startedAt: new Date() });
|
|
1076
|
+
await recordEvent(runId, 'meta', JSON.stringify({ session: true, path: dir }));
|
|
1077
|
+
return { ok: true, detail: 'session open', taskId: task.id, runId };
|
|
1019
1078
|
}
|
|
1020
1079
|
|
|
1021
1080
|
/**
|
|
@@ -1796,6 +1855,12 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
|
|
|
1796
1855
|
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
|
1797
1856
|
}
|
|
1798
1857
|
await runShellOn(ctx.machine, `tmux kill-session -t ${shq(`=coxpit-r${runId}`)} 2>/dev/null || true`, 8000);
|
|
1858
|
+
// 루트 세션(branch='' 또는 worktreePath=repo 실체)은 격리 worktree 가 아니다 —
|
|
1859
|
+
// git worktree remove 를 메인 체크아웃에 걸면 안 되므로 tmux 만 정리하고 포인터를 비운다.
|
|
1860
|
+
if (!run.branch || run.worktreePath === ctx.repoPath) {
|
|
1861
|
+
await setRun(runId, { worktreePath: '', tmuxWindow: '' });
|
|
1862
|
+
return { ok: true, detail: 'root session closed (checkout preserved)' };
|
|
1863
|
+
}
|
|
1799
1864
|
const rm = await runShellOn(
|
|
1800
1865
|
ctx.machine,
|
|
1801
1866
|
`git -C ${shq(ctx.repoPath)} worktree remove --force ${shq(run.worktreePath)} 2>&1` +
|
package/src/server.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { db } from './db';
|
|
|
19
19
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
20
20
|
import { BOOKMARKLET_JS } from './design';
|
|
21
21
|
import { runShellOn, shq } from './exec';
|
|
22
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun } from './orchestrator';
|
|
22
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt } from './orchestrator';
|
|
23
23
|
import { openTerm } from './term';
|
|
24
24
|
import { addSink, removeSink, broadcast } from './hub';
|
|
25
25
|
import { getProvider, listProviders } from './providers';
|
|
@@ -1010,12 +1010,23 @@ 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
|
+
if (!res.ok) return reply.code(422).send(res);
|
|
1020
|
+
return reply.code(201).send(res);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
// 자유 세션 — 임의 폴더에서 tmux 셸(프로젝트 비소속, 가상 Sessions 버킷).
|
|
1024
|
+
app.post('/api/session', async (req, reply) => {
|
|
1025
|
+
const b = (req.body ?? {}) as { machineSlug?: string; path?: string; title?: string };
|
|
1026
|
+
const machineSlug = (b.machineSlug ?? '').trim();
|
|
1027
|
+
const path = (b.path ?? '').trim();
|
|
1028
|
+
if (!machineSlug || !path) return reply.code(400).send({ error: 'machineSlug and path required' });
|
|
1029
|
+
const res = await openSessionAt(machineSlug, path, (b.title ?? '').trim());
|
|
1019
1030
|
if (!res.ok) return reply.code(422).send(res);
|
|
1020
1031
|
return reply.code(201).send(res);
|
|
1021
1032
|
});
|