coxpit 3.8.0 → 4.1.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 +6 -1
- package/package.json +1 -1
- package/src/auth.ts +4 -1
- package/src/board.ts +154 -9
- package/src/config.ts +3 -0
- package/src/db/index.ts +16 -0
- package/src/db/schema.ts +20 -0
- package/src/orchestrator.ts +213 -9
- package/src/providers.ts +15 -11
- package/src/server.ts +254 -6
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@ Your machines. Your auth. Your code never leaves your network.
|
|
|
20
20
|
- **Multi-machine** — register remote machines over SSH (Tailscale/LAN); probe reachability (git·tmux), run fleets there.
|
|
21
21
|
- **Safe stops** — stop kills the whole process group; task close stops and cleans every worktree/branch.
|
|
22
22
|
- **Design Mode** — drag the `⌖ coxpit inspect` bookmarklet to your bar, click it on your running app, click any element: its selector, HTML and computed styles are captured and injected into the agents' prompt as design context.
|
|
23
|
+
- **Self-orchestrating agents** — every local run can spawn its own sub-agents by writing `.coxpit/spawn.json` in its worktree (works under default permissions — no network, no escalation). The daemon launches each subtask as an isolated sub-run and maintains `.coxpit/subtasks.json` with live status. Orchestration moves inside the agent's own reasoning loop.
|
|
24
|
+
- **Start from GitHub** — paste an issue/PR URL and the task form drafts itself from its title and body (gh CLI for private repos, public API otherwise). You review, pick a provider, Run fleet.
|
|
25
|
+
- **Share a run** — one click mints a read-only snapshot link (timeline + diff, and the rendered docs, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
26
|
+
- **The library** — a run's changed documents (Markdown/HTML) are snapshotted when it settles, so the Rendered view survives merge and Close task. Pick a model per launch (any name your CLI accepts), and a close guard warns before it deletes unmerged, unexported output.
|
|
23
27
|
|
|
24
28
|
External tools are spawned, never vendored: `git`, `tmux`, your agent CLI. No editor bundled — terminal-first.
|
|
25
29
|
|
|
@@ -74,6 +78,7 @@ Your keys and login never touch coxpit's config or database.
|
|
|
74
78
|
| `COXPIT_AGENT_PERM` | `acceptEdits` | Claude Code headless permission mode |
|
|
75
79
|
| `COXPIT_CODEX_BIN` | `codex` | Codex CLI command (optional second provider) |
|
|
76
80
|
| `COXPIT_CODEX_SANDBOX` | `workspace-write` | Codex sandbox policy (`danger-full-access` for full autonomy) |
|
|
81
|
+
| `COXPIT_AGENT_ORCH` | on | `0` disables agent self-orchestration (the `.coxpit/spawn.json` protocol + prompt note) |
|
|
77
82
|
| `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
|
|
78
83
|
| `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
|
|
79
84
|
|
|
@@ -105,7 +110,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
105
110
|
|
|
106
111
|
## Status
|
|
107
112
|
|
|
108
|
-
`
|
|
113
|
+
`v4.1` — fleet, two providers (Claude Code · Codex) with per-launch model choice, compare/merge + AI review + doc mode with settle-time snapshots, terminal (full-screen, session tabs, mobile input bar), swarm (plan fan-out · integrate · agent self-orchestration), sessions (steer/ask · resume), mobile board with deep links, GitHub import, read-only share links that render documents, a close guard, and per-repo base branch override — all shipped and e2e-tested (35 checks). Roadmap: ROADMAP.md.
|
|
109
114
|
|
|
110
115
|
## License
|
|
111
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.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/auth.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { config } from './config';
|
|
|
3
3
|
|
|
4
4
|
// /api/design/capture · /design/bookmarklet.js 는 외부 앱(북마클릿)에서 오므로
|
|
5
5
|
// basic 헤더를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
|
|
6
|
-
|
|
6
|
+
// /api/agent/subtasks 는 에이전트 Bearer 토큰(라우트 자체 검증), /share/* 는 토큰 URL 이 곧 능력.
|
|
7
|
+
const EXEMPT = new Set(['/api/health', '/api/design/capture', '/design/bookmarklet.js', '/api/agent/subtasks']);
|
|
8
|
+
const EXEMPT_PREFIX = ['/share/'];
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* 인증 게이트 — 현재 basic. 플러그형 좌석: 배포 시 앞단에 Cloudflare Access / Tailscale 을
|
|
@@ -13,6 +15,7 @@ export async function authGate(req: FastifyRequest, reply: FastifyReply): Promis
|
|
|
13
15
|
if (config.auth.disabled) return;
|
|
14
16
|
const path = req.url.split('?')[0] ?? '';
|
|
15
17
|
if (EXEMPT.has(path)) return;
|
|
18
|
+
if (EXEMPT_PREFIX.some((p) => path.startsWith(p))) return;
|
|
16
19
|
|
|
17
20
|
const h = req.headers.authorization ?? '';
|
|
18
21
|
if (h.startsWith('Basic ') && config.auth.pass !== '') {
|
package/src/board.ts
CHANGED
|
@@ -164,6 +164,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
164
164
|
.ev{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
165
165
|
.ev .k{color:var(--brand);min-width:78px;flex:none;opacity:.85}
|
|
166
166
|
.ev .t{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
|
167
|
+
/* ── closed card — 죽은 카드는 확실히 죽어 보이게(빗금 + CLOSED 스탬프) ── */
|
|
168
|
+
.card.closed{opacity:.6;filter:saturate(.45)}
|
|
169
|
+
.card.closed .log{position:relative}
|
|
170
|
+
.card.closed .log::after{content:'';position:absolute;inset:0;pointer-events:none;
|
|
171
|
+
background:repeating-linear-gradient(135deg,transparent 0 9px,rgba(255,255,255,.035) 9px 11px)}
|
|
172
|
+
.card.closed .log::before{content:'CLOSED';position:absolute;top:50%;left:50%;z-index:1;
|
|
173
|
+
transform:translate(-50%,-50%) rotate(-7deg);font-family:var(--mono);font-size:15px;
|
|
174
|
+
letter-spacing:.34em;color:var(--faint);border:1px solid var(--line-hi);
|
|
175
|
+
border-radius:6px;padding:4px 14px;background:rgba(11,13,18,.72)}
|
|
167
176
|
/* ── select mode (integrate) ── */
|
|
168
177
|
.toolbar{display:flex;justify-content:flex-end;gap:8px;margin-bottom:12px}
|
|
169
178
|
.card.selmode{cursor:copy}
|
|
@@ -291,6 +300,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
291
300
|
.cmp-review p{margin:0 0 8px}
|
|
292
301
|
|
|
293
302
|
/* ── doc mode (rendered output) ─────────── */
|
|
303
|
+
.doc-src{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-bottom:10px}
|
|
294
304
|
.doc-h{font-family:var(--mono);font-size:10.5px;color:var(--brand);padding:8px 0 4px;
|
|
295
305
|
border-bottom:1px solid var(--line);margin-bottom:8px;word-break:break-all}
|
|
296
306
|
.doc-md{font-size:13px;line-height:1.65;color:var(--muted);margin-bottom:16px;font-family:var(--sans)}
|
|
@@ -378,6 +388,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
378
388
|
<div class="row">
|
|
379
389
|
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
380
390
|
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
391
|
+
<button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
|
|
381
392
|
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
382
393
|
</div>
|
|
383
394
|
<form id="repoForm" hidden>
|
|
@@ -399,10 +410,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
399
410
|
<div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
|
|
400
411
|
<input id="taskTitle" placeholder="Task title" />
|
|
401
412
|
<textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
|
|
413
|
+
<button type="button" class="btn-ghost sm" id="ghImport">From GitHub issue / PR…</button>
|
|
402
414
|
<div class="seg" id="provSeg" role="group" aria-label="agent provider">
|
|
403
415
|
<button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
|
|
404
416
|
<button type="button" class="seg-opt" data-agent="codex">Codex</button>
|
|
405
417
|
</div>
|
|
418
|
+
<p class="flabel">model · optional</p>
|
|
419
|
+
<input id="taskModel" placeholder="CLI default" list="modelHist" autocomplete="off" />
|
|
420
|
+
<datalist id="modelHist"></datalist>
|
|
406
421
|
<p class="flabel">design capture · optional</p>
|
|
407
422
|
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
408
423
|
</div>
|
|
@@ -478,6 +493,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
478
493
|
<button class="btn-ghost sm" id="mCompare">Compare runs</button>
|
|
479
494
|
<button class="btn-ghost sm" id="mExport">Export files…</button>
|
|
480
495
|
<button class="btn-ghost sm" id="mSync">Sync base</button>
|
|
496
|
+
<button class="btn-ghost sm" id="mShare" title="create a read-only share link (no auth, snapshot view)">Share</button>
|
|
481
497
|
<span class="spacer"></span>
|
|
482
498
|
<button class="btn-danger sm" id="mStop">Stop</button>
|
|
483
499
|
<button class="btn-ghost sm" id="mCleanup">Cleanup</button>
|
|
@@ -548,6 +564,36 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
548
564
|
<button class="btn-ghost sm" id="selCancel">Cancel</button>
|
|
549
565
|
</div>
|
|
550
566
|
|
|
567
|
+
<div class="overlay" id="ghOverlay">
|
|
568
|
+
<div class="cfm">
|
|
569
|
+
<div class="cfm-b">
|
|
570
|
+
<div class="m">Start from a GitHub issue or pull request</div>
|
|
571
|
+
<div class="s">Fetches the title and body into the task form — you review, pick a provider, then Run fleet. Private repos need the gh CLI signed in on the daemon machine.</div>
|
|
572
|
+
<p class="flabel" style="margin-top:12px">issue / PR url</p>
|
|
573
|
+
<input id="ghUrl" placeholder="https://github.com/owner/repo/issues/123" />
|
|
574
|
+
</div>
|
|
575
|
+
<div class="cfm-f">
|
|
576
|
+
<button class="btn-ghost sm" id="ghCancel">Cancel</button>
|
|
577
|
+
<button class="btn sm" id="ghOk">Fetch</button>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
<div class="overlay" id="brOverlay">
|
|
583
|
+
<div class="cfm">
|
|
584
|
+
<div class="cfm-b">
|
|
585
|
+
<div class="m">Base branch for this repository</div>
|
|
586
|
+
<div class="s">Merge, Sync base and PR mode all target this branch. Set it to match your repo's flow (e.g. <span style="color:var(--brand);font-family:var(--mono)">develop</span>). Must already exist in the repo.</div>
|
|
587
|
+
<p class="flabel" style="margin-top:12px">branch name</p>
|
|
588
|
+
<input id="brInput" placeholder="main" />
|
|
589
|
+
</div>
|
|
590
|
+
<div class="cfm-f">
|
|
591
|
+
<button class="btn-ghost sm" id="brCancel">Cancel</button>
|
|
592
|
+
<button class="btn sm" id="brOk">Save</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
|
|
551
597
|
<div class="overlay" id="expOverlay">
|
|
552
598
|
<div class="cfm">
|
|
553
599
|
<div class="cfm-b">
|
|
@@ -726,6 +772,7 @@ function humanize(e){
|
|
|
726
772
|
}
|
|
727
773
|
if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
|
|
728
774
|
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
775
|
+
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(' '))+')' };
|
|
729
776
|
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
730
777
|
return { k:kind, t:payload.slice(0,140) };
|
|
731
778
|
}catch{
|
|
@@ -844,13 +891,15 @@ function cardHTML(r){
|
|
|
844
891
|
const evs = humanLines(r.events).slice(-8).map(h =>
|
|
845
892
|
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t).slice(0,140)+'</span></div>'
|
|
846
893
|
).join('') || '<div class="ev"><span class="t" style="color:var(--faint)">waiting…</span></div>';
|
|
847
|
-
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'');
|
|
894
|
+
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
|
|
848
895
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
849
896
|
+ '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
|
|
850
897
|
+ '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
|
|
851
898
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
852
899
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
853
900
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
901
|
+
+ (r.model ? '<span title="model">⚙ '+esc(r.model)+'</span>' : '')
|
|
902
|
+
+ (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
|
|
854
903
|
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
855
904
|
? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
|
|
856
905
|
+ (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
|
|
@@ -923,6 +972,26 @@ $('repoRemove').addEventListener('click', async ()=>{
|
|
|
923
972
|
if (res.ok){ toast('repo removed', 'ok'); hydrate(); }
|
|
924
973
|
else toast('remove: '+(j.detail||res.status), 'error');
|
|
925
974
|
});
|
|
975
|
+
/* ── 기본 브랜치 변경 ── */
|
|
976
|
+
$('repoBranch').addEventListener('click', ()=>{
|
|
977
|
+
const rid = $('taskRepo').value;
|
|
978
|
+
if (!rid){ toast('no repository selected', 'error'); return; }
|
|
979
|
+
const repo = repos.find(r=>String(r.id)===String(rid));
|
|
980
|
+
$('brInput').value = repo ? repo.defaultBranch : '';
|
|
981
|
+
$('brOverlay').classList.add('open'); $('brInput').focus();
|
|
982
|
+
});
|
|
983
|
+
$('brCancel').addEventListener('click', ()=>$('brOverlay').classList.remove('open'));
|
|
984
|
+
$('brOverlay').addEventListener('click',(e)=>{ if(e.target===$('brOverlay')) $('brOverlay').classList.remove('open'); });
|
|
985
|
+
async function brSave(){
|
|
986
|
+
const rid = $('taskRepo').value; const branch = $('brInput').value.trim();
|
|
987
|
+
if (!rid || !branch) return;
|
|
988
|
+
const res = await fetch('/api/repos/'+rid,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({defaultBranch:branch})});
|
|
989
|
+
const j = await res.json().catch(()=>({}));
|
|
990
|
+
if (res.ok){ $('brOverlay').classList.remove('open'); toast('base branch → '+j.defaultBranch, 'ok'); hydrate(); }
|
|
991
|
+
else toast('branch: '+(j.error||res.status), 'error');
|
|
992
|
+
}
|
|
993
|
+
$('brOk').addEventListener('click', brSave);
|
|
994
|
+
$('brInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') brSave(); });
|
|
926
995
|
$('repoManual').addEventListener('click', ()=>{
|
|
927
996
|
const f = $('repoForm'); f.hidden = !f.hidden;
|
|
928
997
|
if (!f.hidden) $('repoPath').focus();
|
|
@@ -1009,17 +1078,31 @@ function paintModal(){
|
|
|
1009
1078
|
async function loadDiff(){
|
|
1010
1079
|
if (openRunId==null || docMode) return;
|
|
1011
1080
|
const pre = $('mDiff'); if (!pre) return;
|
|
1081
|
+
const rid = openRunId;
|
|
1012
1082
|
pre.textContent = 'loading…'; $('mStat').textContent='';
|
|
1013
1083
|
try{
|
|
1014
|
-
const d = await fetch('/api/runs/'+
|
|
1015
|
-
if (!d.ok){
|
|
1084
|
+
const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
|
|
1085
|
+
if (!d.ok){
|
|
1086
|
+
pre.textContent = d.stat||'no worktree';
|
|
1087
|
+
// worktree 는 없지만 스냅샷 문서가 있으면 Rendered 토글 노출(머지·Close 후 뷰어)
|
|
1088
|
+
maybeShowDocsToggle(rid);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1016
1091
|
const files = d.stat ? d.stat.split('\\n').filter(Boolean).length : 0;
|
|
1017
1092
|
$('mStat').textContent = files ? '· '+files+' file'+(files>1?'s':'') : '· clean';
|
|
1018
|
-
// 변경분에 문서(md/html)가 있으면 Rendered 토글 노출
|
|
1019
|
-
$('mDocsTgl').hidden = !/\\.(md|markdown|html?|htm)$/im.test(d.stat||'');
|
|
1020
1093
|
pre.innerHTML = diffHTML(d.diff||'');
|
|
1094
|
+
// 변경분에 문서(md/html)가 있으면 즉시 노출, 없으면 스냅샷 확인
|
|
1095
|
+
if (/\\.(md|markdown|html?|htm)$/im.test(d.stat||'')) $('mDocsTgl').hidden = false;
|
|
1096
|
+
else maybeShowDocsToggle(rid);
|
|
1021
1097
|
}catch{ pre.textContent = 'diff failed'; }
|
|
1022
1098
|
}
|
|
1099
|
+
/* worktree 에 라이브 문서가 없을 때만 — 스냅샷이라도 있으면 토글을 켠다 */
|
|
1100
|
+
async function maybeShowDocsToggle(rid){
|
|
1101
|
+
try{
|
|
1102
|
+
const j = await fetch('/api/runs/'+rid+'/docs').then(x=>x.json());
|
|
1103
|
+
if (rid===openRunId && (j.docs||[]).length) $('mDocsTgl').hidden = false;
|
|
1104
|
+
}catch{}
|
|
1105
|
+
}
|
|
1023
1106
|
/* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
|
|
1024
1107
|
let docMode = false;
|
|
1025
1108
|
function docsHTML(docs){
|
|
@@ -1034,7 +1117,9 @@ async function paintDocs(){
|
|
|
1034
1117
|
$('mDiffWrap').innerHTML = '<span style="color:var(--faint)">rendering…</span>';
|
|
1035
1118
|
try{
|
|
1036
1119
|
const d = await fetch('/api/runs/'+openRunId+'/docs').then(x=>x.json());
|
|
1037
|
-
|
|
1120
|
+
const src = d.source==='snapshot'
|
|
1121
|
+
? '<div class="doc-src">worktree gone — snapshot taken at settle</div>' : '';
|
|
1122
|
+
$('mDiffWrap').innerHTML = src + docsHTML(d.docs||[]);
|
|
1038
1123
|
}catch{ $('mDiffWrap').textContent = 'docs failed'; }
|
|
1039
1124
|
}
|
|
1040
1125
|
function setDocMode(on){
|
|
@@ -1106,7 +1191,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1106
1191
|
});
|
|
1107
1192
|
$('mClose').addEventListener('click', closeModal);
|
|
1108
1193
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
1109
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1194
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1110
1195
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1111
1196
|
$('mExport').addEventListener('click', ()=>{
|
|
1112
1197
|
if (openRunId==null) return;
|
|
@@ -1176,7 +1261,18 @@ $('mCloseTask').addEventListener('click', async ()=>{
|
|
|
1176
1261
|
const yes = await confirmUI('Close this task?',
|
|
1177
1262
|
{ sub: 'Stops any live runs and removes every worktree and branch of the task.', danger: true, okLabel: 'Close task' });
|
|
1178
1263
|
if (!yes) return;
|
|
1179
|
-
|
|
1264
|
+
const close = (force)=>fetch('/api/tasks/'+r.taskId+'/close',{method:'POST',
|
|
1265
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({force})});
|
|
1266
|
+
let res = await close(false);
|
|
1267
|
+
if (res.status===409){
|
|
1268
|
+
const j = await res.json().catch(()=>({}));
|
|
1269
|
+
const risk = (j.atRisk||[]).map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
1270
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
1271
|
+
{ danger:true, sub: risk+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
1272
|
+
if (!ok) return;
|
|
1273
|
+
res = await close(true);
|
|
1274
|
+
}
|
|
1275
|
+
if (!res.ok){ toast('close failed ('+res.status+')', 'error'); return; }
|
|
1180
1276
|
toast('task closed — all runs cleaned', 'ok');
|
|
1181
1277
|
closeModal(); hydrate();
|
|
1182
1278
|
});
|
|
@@ -1492,11 +1588,28 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
1492
1588
|
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
|
1493
1589
|
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
1494
1590
|
tasks.set(t.task.id, t.task);
|
|
1591
|
+
const model = $('taskModel').value.trim();
|
|
1495
1592
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
1496
|
-
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent})});
|
|
1593
|
+
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
|
|
1594
|
+
if (model) rememberModel(model);
|
|
1497
1595
|
$('taskTitle').value=''; $('taskPrompt').value='';
|
|
1498
1596
|
});
|
|
1499
1597
|
|
|
1598
|
+
/* ── model 최근값 기억(기기별, 최대 5) ── */
|
|
1599
|
+
function rememberModel(m){
|
|
1600
|
+
try{
|
|
1601
|
+
const h = JSON.parse(localStorage.getItem('coxpit.models')||'[]');
|
|
1602
|
+
localStorage.setItem('coxpit.models', JSON.stringify([m, ...h.filter(x=>x!==m)].slice(0,5)));
|
|
1603
|
+
}catch{}
|
|
1604
|
+
paintModelHist();
|
|
1605
|
+
}
|
|
1606
|
+
function paintModelHist(){
|
|
1607
|
+
let h = [];
|
|
1608
|
+
try{ h = JSON.parse(localStorage.getItem('coxpit.models')||'[]'); }catch{}
|
|
1609
|
+
$('modelHist').innerHTML = h.map(m=>'<option value="'+escA(m)+'"></option>').join('');
|
|
1610
|
+
}
|
|
1611
|
+
paintModelHist();
|
|
1612
|
+
|
|
1500
1613
|
/* ── agent mode segmented control (mirrors hidden #taskReal) ── */
|
|
1501
1614
|
const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
|
|
1502
1615
|
function setMode(real, persist){
|
|
@@ -1530,6 +1643,38 @@ let savedAgent = null;
|
|
|
1530
1643
|
try { savedAgent = localStorage.getItem('coxpit.agent'); } catch {}
|
|
1531
1644
|
if (savedAgent === 'codex') setProvider('codex', false);
|
|
1532
1645
|
|
|
1646
|
+
/* ── GitHub 이슈/PR → 태스크 초안 ── */
|
|
1647
|
+
$('ghImport').addEventListener('click', ()=>{ $('ghUrl').value=''; $('ghOverlay').classList.add('open'); $('ghUrl').focus(); });
|
|
1648
|
+
$('ghCancel').addEventListener('click', ()=>$('ghOverlay').classList.remove('open'));
|
|
1649
|
+
$('ghOverlay').addEventListener('click',(e)=>{ if(e.target===$('ghOverlay')) $('ghOverlay').classList.remove('open'); });
|
|
1650
|
+
async function ghFetch(){
|
|
1651
|
+
const url = $('ghUrl').value.trim();
|
|
1652
|
+
if (!url) return;
|
|
1653
|
+
$('ghOk').disabled = true; $('ghOk').textContent = 'Fetching…';
|
|
1654
|
+
try{
|
|
1655
|
+
const res = await fetch('/api/tasks/from-github',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url})});
|
|
1656
|
+
const j = await res.json().catch(()=>({}));
|
|
1657
|
+
if (!res.ok){ toast('github: '+(j.error||res.status), 'error'); return; }
|
|
1658
|
+
$('taskTitle').value = j.title; $('taskPrompt').value = j.prompt;
|
|
1659
|
+
$('ghOverlay').classList.remove('open');
|
|
1660
|
+
toast('drafted from GitHub — review, then Run fleet', 'ok');
|
|
1661
|
+
} finally { $('ghOk').disabled = false; $('ghOk').textContent = 'Fetch'; }
|
|
1662
|
+
}
|
|
1663
|
+
$('ghOk').addEventListener('click', ghFetch);
|
|
1664
|
+
$('ghUrl').addEventListener('keydown',(e)=>{ if(e.key==='Enter') ghFetch(); });
|
|
1665
|
+
|
|
1666
|
+
/* ── 읽기 전용 공유 링크 ── */
|
|
1667
|
+
$('mShare').addEventListener('click', async ()=>{
|
|
1668
|
+
if (openRunId==null) return;
|
|
1669
|
+
const res = await fetch('/api/runs/'+openRunId+'/share',{method:'POST'});
|
|
1670
|
+
const j = await res.json().catch(()=>({}));
|
|
1671
|
+
if (!res.ok){ toast('share: '+(j.error||res.status), 'error'); return; }
|
|
1672
|
+
const url = location.origin + j.url;
|
|
1673
|
+
let copied = false;
|
|
1674
|
+
try{ await navigator.clipboard.writeText(url); copied = true; }catch{}
|
|
1675
|
+
toast((j.existing?'share link (existing)':'share link created')+(copied?' — copied':'')+': '+url, 'ok');
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1533
1678
|
/* ── mobile drawer ── */
|
|
1534
1679
|
const asideEl = document.querySelector('aside');
|
|
1535
1680
|
function setDrawer(on){ asideEl.classList.toggle('open', on); $('scrim').classList.toggle('on', on); }
|
package/src/config.ts
CHANGED
|
@@ -75,6 +75,9 @@ export const config = {
|
|
|
75
75
|
// 완전 자율은 bypassPermissions.
|
|
76
76
|
perm: process.env.COXPIT_AGENT_PERM ?? 'acceptEdits',
|
|
77
77
|
},
|
|
78
|
+
// 에이전트 셀프 오케스트레이션 — real+로컬 run 에 COXPIT_API/COXPIT_TOKEN env 와
|
|
79
|
+
// 능력 고지를 준다(에이전트가 /api/agent/subtasks 로 서브런 발사). '0' 으로 끔.
|
|
80
|
+
agentOrch: process.env.COXPIT_AGENT_ORCH !== '0',
|
|
78
81
|
codex: {
|
|
79
82
|
// 두 번째 프로바이더 — OpenAI Codex CLI (선택 설치). providers.ts 의 시임.
|
|
80
83
|
bin: process.env.COXPIT_CODEX_BIN ?? 'codex',
|
package/src/db/index.ts
CHANGED
|
@@ -69,9 +69,25 @@ export async function ensureSchema(): Promise<void> {
|
|
|
69
69
|
payload TEXT NOT NULL DEFAULT '',
|
|
70
70
|
ts INTEGER DEFAULT (unixepoch())
|
|
71
71
|
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS share_links (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
run_id INTEGER NOT NULL,
|
|
75
|
+
token TEXT NOT NULL UNIQUE,
|
|
76
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
77
|
+
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS doc_snapshots (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
run_id INTEGER NOT NULL,
|
|
81
|
+
path TEXT NOT NULL,
|
|
82
|
+
kind TEXT NOT NULL,
|
|
83
|
+
content TEXT NOT NULL DEFAULT '',
|
|
84
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
85
|
+
);
|
|
72
86
|
`);
|
|
73
87
|
// 기존 DB 마이그레이션(멱등)
|
|
74
88
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
75
89
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
76
90
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
91
|
+
try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
|
|
92
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
77
93
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -40,6 +40,25 @@ export const tasks = sqliteTable('tasks', {
|
|
|
40
40
|
prompt: text('prompt').notNull().default(''),
|
|
41
41
|
status: text('status').notNull().default('open'), // open | done
|
|
42
42
|
designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
|
|
43
|
+
parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
|
|
44
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** 정착·정리 시점에 회수한 문서(md/html) 스냅샷 — worktree 소멸 후에도 렌더 뷰 유지. */
|
|
48
|
+
export const docSnapshots = sqliteTable('doc_snapshots', {
|
|
49
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
50
|
+
runId: integer('run_id').notNull(),
|
|
51
|
+
path: text('path').notNull(),
|
|
52
|
+
kind: text('kind').notNull(), // 'md' | 'html'
|
|
53
|
+
content: text('content').notNull().default(''),
|
|
54
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** 읽기 전용 공유 링크 — run 스냅샷을 무인증으로 보여준다(토큰 = capability). */
|
|
58
|
+
export const shareLinks = sqliteTable('share_links', {
|
|
59
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
60
|
+
runId: integer('run_id').notNull(),
|
|
61
|
+
token: text('token').notNull().unique(),
|
|
43
62
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
44
63
|
});
|
|
45
64
|
|
|
@@ -55,6 +74,7 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
55
74
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
56
75
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
57
76
|
prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
|
|
77
|
+
model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
|
|
58
78
|
filesChanged: integer('files_changed').notNull().default(0),
|
|
59
79
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
60
80
|
endedAt: integer('ended_at', { mode: 'timestamp' }),
|
package/src/orchestrator.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { posix as ppath } from 'node:path';
|
|
2
3
|
import { createInterface } from 'node:readline';
|
|
3
4
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { mkdir, copyFile } from 'node:fs/promises';
|
|
5
|
+
import { mkdir, copyFile, readFile, writeFile, rm } from 'node:fs/promises';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
6
7
|
import type { ChildProcess } from 'node:child_process';
|
|
7
8
|
import { eq } from 'drizzle-orm';
|
|
8
9
|
import { config } from './config';
|
|
9
10
|
import { db } from './db';
|
|
10
|
-
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures } from './db/schema';
|
|
11
|
+
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots } from './db/schema';
|
|
11
12
|
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
12
13
|
import { broadcast } from './hub';
|
|
13
14
|
import { getProvider, type Provider } from './providers';
|
|
14
15
|
|
|
15
16
|
/** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
|
|
16
|
-
function agentCommand(provider: Provider, prompt: string, real: boolean): string {
|
|
17
|
-
if (real) return provider.launchCmd(prompt);
|
|
17
|
+
function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
|
|
18
|
+
if (real) return provider.launchCmd(prompt, model || undefined);
|
|
18
19
|
// 모의: init → assistant → (파일 변경) → result. claude stream-json 라인 형태
|
|
19
20
|
// (드라이런은 프로바이더 불문 배관 리허설 — claude 파서가 처리한다).
|
|
20
21
|
return [
|
|
@@ -40,6 +41,134 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
|
|
|
40
41
|
const liveChildren = new Map<number, ChildProcess>();
|
|
41
42
|
const stoppedRuns = new Set<number>();
|
|
42
43
|
|
|
44
|
+
// ── 에이전트 셀프 오케스트레이션 ──────────────────────────────
|
|
45
|
+
// run 마다 1회용 토큰을 발급해 에이전트 env 로 준다. 에이전트는 그 토큰으로
|
|
46
|
+
// /api/agent/subtasks 를 호출해 같은 repo 에 독립 서브런을 발사할 수 있다.
|
|
47
|
+
// 인메모리 = 데몬 재시작 시 무효(고아 정산과 같은 수명 철학).
|
|
48
|
+
const agentTokens = new Map<string, number>(); // token -> runId
|
|
49
|
+
|
|
50
|
+
function issueAgentToken(runId: number): string {
|
|
51
|
+
for (const [t, r] of agentTokens) if (r === runId) return t; // steer 재사용
|
|
52
|
+
const tok = randomBytes(16).toString('hex');
|
|
53
|
+
agentTokens.set(tok, runId);
|
|
54
|
+
return tok;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Bearer 토큰 → runId (없으면 null). server 의 /api/agent/* 가 사용. */
|
|
58
|
+
export function resolveAgentToken(token: string): number | null {
|
|
59
|
+
return agentTokens.get(token) ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
|
|
63
|
+
* 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
|
|
64
|
+
* 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
|
|
65
|
+
function orchestrationNote(): string {
|
|
66
|
+
return '\n\n--- COXPIT ORCHESTRATION (optional) ---\n' +
|
|
67
|
+
'You can parallelize genuinely independent subwork by spawning sub-agents. To spawn, write the file ' +
|
|
68
|
+
'`.coxpit/spawn.json` in your working directory:\n' +
|
|
69
|
+
' {"title": "short title", "prompt": "full agent prompt", "count": 1}\n' +
|
|
70
|
+
'(or an array of such objects, max 4). The daemon consumes it within ~2s and launches each subtask ' +
|
|
71
|
+
'as an isolated sub-run of this repository. It keeps `.coxpit/subtasks.json` updated with their live ' +
|
|
72
|
+
'status — read it to check progress. Prefer doing work yourself; spawn only clearly separable, ' +
|
|
73
|
+
'file-disjoint tasks. Do not busy-wait on sub-agents.\n' +
|
|
74
|
+
'--- END COXPIT ORCHESTRATION ---';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 파일 기반 오케스트레이션 워처 — run 이 사는 동안 worktree 의 .coxpit/spawn.json 을
|
|
79
|
+
* 소비해 서브태스크를 발사하고, .coxpit/subtasks.json 에 현황을 유지한다.
|
|
80
|
+
* 로컬 run 전용(원격은 데몬이 파일에 못 닿음). 반환된 타이머는 run 종료 시 정리.
|
|
81
|
+
*/
|
|
82
|
+
export function startOrchWatch(runId: number, wtPath: string, real: boolean): NodeJS.Timeout {
|
|
83
|
+
const dir = ppath.join(wtPath, '.coxpit');
|
|
84
|
+
let last = '';
|
|
85
|
+
let busy = false;
|
|
86
|
+
return setInterval(() => {
|
|
87
|
+
if (busy) return;
|
|
88
|
+
busy = true;
|
|
89
|
+
void (async () => {
|
|
90
|
+
try {
|
|
91
|
+
const spawnPath = ppath.join(dir, 'spawn.json');
|
|
92
|
+
const txt = await readFile(spawnPath, 'utf8').catch(() => null);
|
|
93
|
+
if (txt !== null) {
|
|
94
|
+
await rm(spawnPath).catch(() => { /* consumed */ });
|
|
95
|
+
try {
|
|
96
|
+
const req = JSON.parse(txt) as unknown;
|
|
97
|
+
const items = (Array.isArray(req) ? req : [req]) as Array<{ title?: string; prompt?: string; count?: number }>;
|
|
98
|
+
for (const it of items.slice(0, 4)) {
|
|
99
|
+
if (typeof it?.title === 'string' && typeof it?.prompt === 'string' && it.title && it.prompt) {
|
|
100
|
+
await spawnSubtasks(runId, it.title, it.prompt, Number(it.count) || 1, real);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
await recordEvent(runId, 'error', 'spawn.json was not valid JSON — nothing spawned');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 현황 파일 — 내용이 바뀔 때만 다시 쓴다
|
|
108
|
+
const subs = await listSubtasks(runId);
|
|
109
|
+
if (subs.length) {
|
|
110
|
+
const j = JSON.stringify(subs, null, 2);
|
|
111
|
+
if (j !== last) {
|
|
112
|
+
last = j;
|
|
113
|
+
await mkdir(dir, { recursive: true });
|
|
114
|
+
await writeFile(ppath.join(dir, 'subtasks.json'), j);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch { /* 워처 오류는 조용히 — 다음 틱에 재시도 */ }
|
|
118
|
+
busy = false;
|
|
119
|
+
})();
|
|
120
|
+
}, 1500);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 에이전트가 요청한 서브태스크 생성+발사. 부모 run 의 repo/머신/프로바이더 상속, real 고정
|
|
125
|
+
* (토큰은 real run 에만 발급되므로). 결과는 부모 타임라인에 meta 이벤트로 남는다.
|
|
126
|
+
*/
|
|
127
|
+
export async function spawnSubtasks(parentRunId: number, title: string, prompt: string, count: number, real = true): Promise<{
|
|
128
|
+
ok: boolean; detail: string; taskId?: number; runIds?: number[];
|
|
129
|
+
}> {
|
|
130
|
+
const pr = (await db.select().from(agentRuns).where(eq(agentRuns.id, parentRunId)).limit(1))[0];
|
|
131
|
+
if (!pr) return { ok: false, detail: 'parent run not found' };
|
|
132
|
+
const pt = (await db.select().from(tasks).where(eq(tasks.id, pr.taskId)).limit(1))[0];
|
|
133
|
+
if (!pt) return { ok: false, detail: 'parent task not found' };
|
|
134
|
+
// 폭주 가드 — 한 부모가 만들 수 있는 하위 태스크 상한
|
|
135
|
+
const siblings = await db.select().from(tasks).where(eq(tasks.parentRunId, parentRunId));
|
|
136
|
+
if (siblings.length >= 8) return { ok: false, detail: 'subtask limit reached (8 per run)' };
|
|
137
|
+
const n = Math.max(1, Math.min(4, count || 1));
|
|
138
|
+
const tIns = await db.insert(tasks).values({
|
|
139
|
+
repoId: pt.repoId, title: title.slice(0, 140), prompt, parentRunId,
|
|
140
|
+
}).returning();
|
|
141
|
+
const task = tIns[0]!;
|
|
142
|
+
const runIds: number[] = [];
|
|
143
|
+
for (let i = 0; i < n; i++) {
|
|
144
|
+
const rIns = await db.insert(agentRuns).values({
|
|
145
|
+
taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
|
|
146
|
+
}).returning();
|
|
147
|
+
const run = rIns[0]!;
|
|
148
|
+
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
149
|
+
void launchRun(run.id, real);
|
|
150
|
+
runIds.push(run.id);
|
|
151
|
+
}
|
|
152
|
+
await recordEvent(parentRunId, 'meta', JSON.stringify({ subtask: task.id, title: task.title, runs: runIds }));
|
|
153
|
+
return { ok: true, detail: `spawned task #${task.id} (${runIds.length} run(s))`, taskId: task.id, runIds };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 부모 run 이 발사한 서브태스크 현황 — 에이전트 폴링용. */
|
|
157
|
+
export async function listSubtasks(parentRunId: number): Promise<Array<{
|
|
158
|
+
id: number; title: string; runs: Array<{ id: number; status: string; filesChanged: number; exitSummary: string }>;
|
|
159
|
+
}>> {
|
|
160
|
+
const ts = await db.select().from(tasks).where(eq(tasks.parentRunId, parentRunId));
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const t of ts) {
|
|
163
|
+
const rs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
|
|
164
|
+
out.push({
|
|
165
|
+
id: t.id, title: t.title,
|
|
166
|
+
runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, exitSummary: r.exitSummary.slice(0, 200) })),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
43
172
|
/**
|
|
44
173
|
* 원격 에이전트 kill 스크립트 — pid 파일 기준.
|
|
45
174
|
* $$ 가 그룹 리더가 아닐 수 있어 ps 로 실제 pgid 를 조회해 그룹째 죽인다
|
|
@@ -69,6 +198,7 @@ interface RunContext {
|
|
|
69
198
|
prompt: string;
|
|
70
199
|
real: boolean;
|
|
71
200
|
agent: string;
|
|
201
|
+
model: string;
|
|
72
202
|
}
|
|
73
203
|
|
|
74
204
|
async function loadContext(runId: number): Promise<RunContext | null> {
|
|
@@ -108,6 +238,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
108
238
|
prompt,
|
|
109
239
|
real: config.agent.real,
|
|
110
240
|
agent: run.agent,
|
|
241
|
+
model: run.model,
|
|
111
242
|
};
|
|
112
243
|
}
|
|
113
244
|
|
|
@@ -155,8 +286,29 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
155
286
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
156
287
|
// 드라이런 모의 스트림은 claude 형태 — 파서도 claude 로 (배관 리허설은 프로바이더 불문)
|
|
157
288
|
const provider = useReal ? getProvider(ctx.agent) : getProvider('claude-code');
|
|
158
|
-
|
|
159
|
-
|
|
289
|
+
// 셀프 오케스트레이션 — real+로컬 run 에만 토큰/API env 와 능력 고지를 준다
|
|
290
|
+
// (원격은 127.0.0.1 이 데몬에 닿지 않음). COXPIT_AGENT_ORCH=0 으로 끌 수 있음.
|
|
291
|
+
let prompt = ctx.prompt;
|
|
292
|
+
let envPrefix = '';
|
|
293
|
+
if (useReal && !isRemote && config.agentOrch) {
|
|
294
|
+
const tok = issueAgentToken(runId);
|
|
295
|
+
envPrefix = `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(tok)}; `;
|
|
296
|
+
prompt += orchestrationNote();
|
|
297
|
+
}
|
|
298
|
+
const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal, ctx.model)}; }`;
|
|
299
|
+
// 파일 오케스트레이션 — 로컬 run 이 사는 동안 .coxpit/spawn.json 감시.
|
|
300
|
+
// .coxpit/ 는 repo exclude 에 넣어 diff/머지를 오염시키지 않는다(멱등).
|
|
301
|
+
let orchTimer: NodeJS.Timeout | null = null;
|
|
302
|
+
if (!isRemote && config.agentOrch) {
|
|
303
|
+
await runShellOn(ctx.machine,
|
|
304
|
+
`EX=$(git -C ${shq(wtPath)} rev-parse --git-path info/exclude) && { grep -qxF '.coxpit/' "$EX" 2>/dev/null || echo '.coxpit/' >> "$EX"; }`, 8000);
|
|
305
|
+
orchTimer = startOrchWatch(runId, wtPath, useReal);
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
await runAgentChild(runId, ctx.machine, wtPath, cmd, provider);
|
|
309
|
+
} finally {
|
|
310
|
+
if (orchTimer) clearInterval(orchTimer);
|
|
311
|
+
}
|
|
160
312
|
} catch (e) {
|
|
161
313
|
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
162
314
|
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
@@ -203,9 +355,33 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
|
|
|
203
355
|
const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
|
|
204
356
|
const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
|
|
205
357
|
await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
|
|
358
|
+
// 문서 산출물을 정착 시점에 스냅샷 — worktree 소멸(머지·Close) 후에도 렌더 뷰 유지. best-effort.
|
|
359
|
+
if (filesChanged > 0) void snapshotRunDocs(runId);
|
|
206
360
|
void notifySettle(runId, status, filesChanged, exitSummary);
|
|
207
361
|
}
|
|
208
362
|
|
|
363
|
+
/**
|
|
364
|
+
* 변경 문서(md/html)를 DB 에 스냅샷. 최신 우선(기존 행 삭제 후 재삽입).
|
|
365
|
+
* 빈 읽기(worktree 이미 소멸 등)는 기존 스냅샷을 지우지 않는다.
|
|
366
|
+
*/
|
|
367
|
+
export async function snapshotRunDocs(runId: number): Promise<void> {
|
|
368
|
+
const d = await getRunDocs(runId).catch(() => null);
|
|
369
|
+
if (!d?.ok || d.docs.length === 0) return;
|
|
370
|
+
await db.delete(docSnapshots).where(eq(docSnapshots.runId, runId));
|
|
371
|
+
for (const doc of d.docs) await db.insert(docSnapshots).values({ runId, path: doc.path, kind: doc.kind, content: doc.content });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** worktree(라이브) → 스냅샷 폴백 공용 로더. server 의 /api/runs/:id/docs·/share 가 사용. */
|
|
375
|
+
export async function loadRunDocs(runId: number): Promise<{
|
|
376
|
+
docs: Array<{ path: string; kind: string; content: string }>; source: 'worktree' | 'snapshot';
|
|
377
|
+
}> {
|
|
378
|
+
const live = await getRunDocs(runId).catch(() => null);
|
|
379
|
+
if (live?.ok && live.docs.length > 0) return { docs: live.docs, source: 'worktree' };
|
|
380
|
+
const snap = await db.select().from(docSnapshots).where(eq(docSnapshots.runId, runId));
|
|
381
|
+
if (snap.length > 0) return { docs: snap.map((s) => ({ path: s.path, kind: s.kind, content: s.content })), source: 'snapshot' };
|
|
382
|
+
return { docs: [], source: 'worktree' };
|
|
383
|
+
}
|
|
384
|
+
|
|
209
385
|
/** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
|
|
210
386
|
async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
|
|
211
387
|
if (!config.webhookUrl) return;
|
|
@@ -255,9 +431,18 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
255
431
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
256
432
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
257
433
|
const provider = getProvider(ctx.agent);
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
434
|
+
// steer 세션도 셀프 오케스트레이션 유지(데몬 재시작으로 무효화된 토큰 재발급)
|
|
435
|
+
const envPrefix = (!isRemote && config.agentOrch)
|
|
436
|
+
? `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(issueAgentToken(runId))}; `
|
|
437
|
+
: '';
|
|
438
|
+
const resume = provider.resumeCmd(run.sessionId, finalMessage, run.model || undefined);
|
|
439
|
+
const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
|
|
440
|
+
if (!isRemote && config.agentOrch) {
|
|
441
|
+
const orchTimer = startOrchWatch(runId, wt, true);
|
|
442
|
+
void runAgentChild(runId, ctx.machine, wt, cmd, provider).finally(() => clearInterval(orchTimer));
|
|
443
|
+
} else {
|
|
444
|
+
void runAgentChild(runId, ctx.machine, wt, cmd, provider);
|
|
445
|
+
}
|
|
261
446
|
return { ok: true, detail: 'steering' };
|
|
262
447
|
}
|
|
263
448
|
|
|
@@ -748,11 +933,30 @@ export async function reconcileOrphanRuns(): Promise<number> {
|
|
|
748
933
|
return stale.length;
|
|
749
934
|
}
|
|
750
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Close 가드 — 태스크 닫으면 worktree 가 삭제되므로, 아직 살릴 곳 없는 산출물을 경고.
|
|
938
|
+
* 위험 = 정착(done/failed/stopped) ∧ 변경있음 ∧ 미머지 ∧ export·PR 이벤트 없음.
|
|
939
|
+
*/
|
|
940
|
+
export async function taskCloseRisk(taskId: number): Promise<Array<{ runId: number; filesChanged: number }>> {
|
|
941
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, taskId));
|
|
942
|
+
const atRisk: Array<{ runId: number; filesChanged: number }> = [];
|
|
943
|
+
for (const r of trs) {
|
|
944
|
+
if (!['done', 'failed', 'stopped'].includes(r.status)) continue;
|
|
945
|
+
if (r.filesChanged <= 0) continue;
|
|
946
|
+
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, r.id));
|
|
947
|
+
if (evs.some((e) => e.kind === 'export' || e.kind === 'pr')) continue; // 산출물이 이미 탈출함
|
|
948
|
+
atRisk.push({ runId: r.id, filesChanged: r.filesChanged });
|
|
949
|
+
}
|
|
950
|
+
return atRisk;
|
|
951
|
+
}
|
|
952
|
+
|
|
751
953
|
export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
752
954
|
const ctx = await loadContext(runId);
|
|
753
955
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
754
956
|
const run = rr[0];
|
|
755
957
|
if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
|
|
958
|
+
// worktree 를 지우기 전에 문서 스냅샷(정착 안 하는 워크벤치·수정편집도 포착). best-effort.
|
|
959
|
+
await snapshotRunDocs(runId).catch(() => { /* 스냅샷 실패는 정리를 막지 않음 */ });
|
|
756
960
|
// 원격에 잔존 에이전트가 있으면 worktree 제거 전에 죽인다(파일 잠금·좀비 방지).
|
|
757
961
|
if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
|
|
758
962
|
await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
|
package/src/providers.ts
CHANGED
|
@@ -24,8 +24,9 @@ export interface Provider {
|
|
|
24
24
|
id: string;
|
|
25
25
|
label: string;
|
|
26
26
|
bin: string;
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** model 비었으면 CLI 기본값 사용(플래그 미첨부). */
|
|
28
|
+
launchCmd(prompt: string, model?: string): string;
|
|
29
|
+
resumeCmd(sessionId: string, message: string, model?: string): string;
|
|
29
30
|
/** null = 저장하지 않는 라인(스트림 잡음) */
|
|
30
31
|
parseLine(raw: string): ParsedEvent | null;
|
|
31
32
|
}
|
|
@@ -52,13 +53,14 @@ const claudeProvider: Provider = {
|
|
|
52
53
|
id: 'claude-code',
|
|
53
54
|
label: 'Claude Code',
|
|
54
55
|
get bin() { return config.agent.bin; },
|
|
55
|
-
launchCmd(prompt: string): string {
|
|
56
|
+
launchCmd(prompt: string, model?: string): string {
|
|
56
57
|
return `${config.agent.bin} -p ${shq(prompt)} --output-format stream-json --verbose` +
|
|
57
|
-
` --permission-mode ${config.agent.perm}
|
|
58
|
+
` --permission-mode ${config.agent.perm}` + (model ? ` --model ${shq(model)}` : '');
|
|
58
59
|
},
|
|
59
|
-
resumeCmd(sessionId: string, message: string): string {
|
|
60
|
+
resumeCmd(sessionId: string, message: string, model?: string): string {
|
|
60
61
|
return `${config.agent.bin} -p --resume ${shq(sessionId)} ${shq(message)}` +
|
|
61
|
-
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}
|
|
62
|
+
` --output-format stream-json --verbose --permission-mode ${config.agent.perm}` +
|
|
63
|
+
(model ? ` --model ${shq(model)}` : '');
|
|
62
64
|
},
|
|
63
65
|
parseLine(raw: string): ParsedEvent | null {
|
|
64
66
|
const s = raw.trim();
|
|
@@ -116,12 +118,14 @@ const codexProvider: Provider = {
|
|
|
116
118
|
id: 'codex',
|
|
117
119
|
label: 'Codex',
|
|
118
120
|
get bin() { return config.codex.bin; },
|
|
119
|
-
launchCmd(prompt: string): string {
|
|
120
|
-
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}
|
|
121
|
+
launchCmd(prompt: string, model?: string): string {
|
|
122
|
+
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
|
|
123
|
+
(model ? ` -m ${shq(model)}` : '') + ` ${shq(prompt)}`;
|
|
121
124
|
},
|
|
122
|
-
resumeCmd(sessionId: string, message: string): string {
|
|
123
|
-
// --sandbox 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
|
|
124
|
-
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}
|
|
125
|
+
resumeCmd(sessionId: string, message: string, model?: string): string {
|
|
126
|
+
// --sandbox·-m 는 exec 의 플래그(resume 서브커맨드는 안 받음) — 반드시 resume 앞에.
|
|
127
|
+
return `${config.codex.bin} exec --json --sandbox ${config.codex.sandbox}` +
|
|
128
|
+
(model ? ` -m ${shq(model)}` : '') + ` resume ${shq(sessionId)} ${shq(message)}`;
|
|
125
129
|
},
|
|
126
130
|
parseLine(raw: string): ParsedEvent | null {
|
|
127
131
|
const s = raw.trim();
|
package/src/server.ts
CHANGED
|
@@ -3,16 +3,17 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { resolve as presolve, dirname as pdirname, join as pjoin } from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
6
7
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
7
8
|
import websocket from '@fastify/websocket';
|
|
8
9
|
import { eq } from 'drizzle-orm';
|
|
9
10
|
import { authGate } from './auth';
|
|
10
11
|
import { config } from './config';
|
|
11
12
|
import { db } from './db';
|
|
12
|
-
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
|
|
13
|
+
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks } from './db/schema';
|
|
13
14
|
import { BOOKMARKLET_JS } from './design';
|
|
14
15
|
import { runShellOn, shq } from './exec';
|
|
15
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff,
|
|
16
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk } from './orchestrator';
|
|
16
17
|
import { openTerm } from './term';
|
|
17
18
|
import { addSink, removeSink, broadcast } from './hub';
|
|
18
19
|
import { getProvider, listProviders } from './providers';
|
|
@@ -28,6 +29,135 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
28
29
|
'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
// ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
|
|
33
|
+
const escH = (x: unknown): string =>
|
|
34
|
+
String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
|
35
|
+
|
|
36
|
+
/** 경량 마크다운 → HTML (보드 mdLite 의 서버측 판, 동일 문법). 입력은 먼저 escH. */
|
|
37
|
+
function mdLiteHTML(src: string): string {
|
|
38
|
+
let s = escH(src);
|
|
39
|
+
s = s.replace(/```[a-z]*\n([\s\S]*?)```/g, (_m, c: string) =>
|
|
40
|
+
'<pre style="background:#0e1118;border:1px solid #222835;border-radius:7px;padding:8px 10px;overflow-x:auto">' + c + '</pre>');
|
|
41
|
+
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
42
|
+
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
43
|
+
s = s.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
|
44
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
45
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
46
|
+
s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
47
|
+
s = s.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>');
|
|
48
|
+
s = s.split(/\n{2,}/).map((b) => /^<(h2|h3|ul|pre)/.test(b.trim()) ? b : (b.trim() ? '<p>' + b.replace(/\n/g, '<br>') + '</p>' : '')).join('');
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
|
|
53
|
+
function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
|
|
54
|
+
if (!docs.length) return '';
|
|
55
|
+
const body = docs.map((d) => d.kind === 'md'
|
|
56
|
+
? `<div class="doc"><div class="doc-h">${escH(d.path)}</div><div class="doc-b">${mdLiteHTML(d.content)}</div></div>`
|
|
57
|
+
: `<div class="doc"><div class="doc-h">${escH(d.path)}</div><iframe sandbox="" class="doc-frame" srcdoc="${escH(d.content)}"></iframe></div>`).join('');
|
|
58
|
+
return `<div class="sec">Documents</div>${body}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 보드 humanize 의 서버측 축약판 — 이벤트 한 줄을 {k, t} 로. null = 잡음. */
|
|
62
|
+
function shareLine(kind: string, payload: string): { k: string; t: string } | null {
|
|
63
|
+
if (kind === 'steer') return { k: 'steer', t: '→ ' + payload };
|
|
64
|
+
if (kind === 'ask') return { k: 'ask', t: '? ' + payload };
|
|
65
|
+
if (kind === 'sync' || kind === 'pr' || kind === 'export') return { k: kind, t: payload };
|
|
66
|
+
if (kind === 'stderr' || kind === 'error') return { k: kind, t: payload };
|
|
67
|
+
try {
|
|
68
|
+
const o = JSON.parse(payload) as {
|
|
69
|
+
type?: string; subtype?: string; text?: string; result?: string; worktree?: string;
|
|
70
|
+
message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, string> }> };
|
|
71
|
+
};
|
|
72
|
+
if (o.type === 'system') return o.subtype === 'init' || !o.subtype ? { k: 'session', t: 'started' } : null;
|
|
73
|
+
if (o.type === 'user') return null;
|
|
74
|
+
if (o.type === 'assistant' && o.message) {
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
for (const c of o.message.content ?? []) {
|
|
77
|
+
if (c.type === 'text' && c.text) parts.push(c.text);
|
|
78
|
+
else if (c.type === 'tool_use') {
|
|
79
|
+
const i = c.input ?? {};
|
|
80
|
+
const arg = i.file_path || i.command || i.path || i.pattern || '';
|
|
81
|
+
parts.push('▸ ' + (c.name ?? 'tool') + (arg ? ' — ' + String(arg).split('/').slice(-2).join('/').slice(0, 60) : ''));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return parts.length ? { k: 'agent', t: parts.join(' · ') } : null;
|
|
85
|
+
}
|
|
86
|
+
if (o.type === 'assistant' && o.text) return { k: 'said', t: o.text };
|
|
87
|
+
if (o.type === 'result') return { k: 'done', t: o.result || 'finished' };
|
|
88
|
+
if (kind === 'meta' && o.worktree) return { k: 'start', t: 'worktree ' + String(o.worktree).split('/').slice(-2).join('/') };
|
|
89
|
+
return null;
|
|
90
|
+
} catch { return payload.trim().startsWith('{') ? null : { k: kind, t: payload.slice(0, 160) }; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function shareDiffHTML(text: string): string {
|
|
94
|
+
if (!text.trim()) return '<span style="color:#5c6675">no changes</span>';
|
|
95
|
+
return text.slice(0, 120_000).split('\n').map((l) => {
|
|
96
|
+
const e = escH(l);
|
|
97
|
+
if (l.startsWith('diff --git') || l.startsWith('+++') || l.startsWith('---')) return `<span class="f">${e}</span>`;
|
|
98
|
+
if (l.startsWith('@@')) return `<span class="h">${e}</span>`;
|
|
99
|
+
if (l.startsWith('+')) return `<span class="a">${e}</span>`;
|
|
100
|
+
if (l.startsWith('-')) return `<span class="d">${e}</span>`;
|
|
101
|
+
return e;
|
|
102
|
+
}).join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sharePageHTML(
|
|
106
|
+
run: { id: number; status: string; branch: string; agent: string; filesChanged: number; exitSummary: string },
|
|
107
|
+
taskTitle: string,
|
|
108
|
+
events: Array<{ kind: string; payload: string }>,
|
|
109
|
+
diff: string,
|
|
110
|
+
docs: Array<{ path: string; kind: string; content: string }> = [],
|
|
111
|
+
): string {
|
|
112
|
+
const lines = events.map((e) => shareLine(e.kind, e.payload)).filter((x): x is { k: string; t: string } => !!x);
|
|
113
|
+
const sc: Record<string, string> = { done: '#3fb970', merged: '#4ec9b0', failed: '#e5534b', error: '#e5534b', stopped: '#a371f7', running: '#4184e4', open: '#4ec9b0' };
|
|
114
|
+
const color = sc[run.status] ?? '#8792a2';
|
|
115
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
116
|
+
<title>coxpit · r${run.id} — ${escH(taskTitle)}</title>
|
|
117
|
+
<style>
|
|
118
|
+
body{margin:0;background:#0b0d12;color:#dee4ec;font-family:-apple-system,'Segoe UI',sans-serif;font-size:14px;line-height:1.55}
|
|
119
|
+
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 60px}
|
|
120
|
+
.hd{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:4px}
|
|
121
|
+
.mark{color:#4ec9b0;font-family:ui-monospace,monospace;font-weight:700}
|
|
122
|
+
.rid{color:#5c6675;font-family:ui-monospace,monospace}
|
|
123
|
+
h1{font-size:17px;margin:6px 0 2px}
|
|
124
|
+
.chip{display:inline-block;font-family:ui-monospace,monospace;font-size:11px;text-transform:uppercase;letter-spacing:.08em;
|
|
125
|
+
padding:2px 10px;border:1px solid ${color};border-radius:999px;color:${color}}
|
|
126
|
+
.meta{color:#5c6675;font-family:ui-monospace,monospace;font-size:11.5px;margin:8px 0 22px}
|
|
127
|
+
.sec{font-family:ui-monospace,monospace;font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:#5c6675;
|
|
128
|
+
border-bottom:1px solid #222835;padding-bottom:6px;margin:26px 0 10px}
|
|
129
|
+
.tl{font-family:ui-monospace,monospace;font-size:12px;display:flex;flex-direction:column;gap:6px}
|
|
130
|
+
.tl .k{color:#4ec9b0;display:inline-block;min-width:64px}
|
|
131
|
+
.tl .t{color:#8792a2;word-break:break-word}
|
|
132
|
+
pre{background:#0e1118;border:1px solid #222835;border-radius:10px;padding:14px;overflow-x:auto;
|
|
133
|
+
font-family:ui-monospace,monospace;font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
|
134
|
+
.f{color:#4ec9b0;font-weight:600}.h{color:#4184e4}.a{color:#3fb970}.d{color:#e5534b}
|
|
135
|
+
.sum{background:#12151c;border:1px solid #222835;border-radius:10px;padding:12px 14px;color:#8792a2;font-size:13px}
|
|
136
|
+
.doc{margin-bottom:18px}
|
|
137
|
+
.doc-h{font-family:ui-monospace,monospace;font-size:10.5px;color:#4ec9b0;border-bottom:1px solid #222835;padding-bottom:5px;margin-bottom:8px;word-break:break-all}
|
|
138
|
+
.doc-b{font-size:13.5px;line-height:1.65;color:#8792a2}
|
|
139
|
+
.doc-b h1,.doc-b h2{font-size:15px;color:#dee4ec;margin:12px 0 6px}
|
|
140
|
+
.doc-b h3{font-size:13px;color:#dee4ec;margin:10px 0 4px}
|
|
141
|
+
.doc-b ul{margin:4px 0 8px;padding-left:18px}.doc-b li{margin-bottom:3px}
|
|
142
|
+
.doc-b strong{color:#dee4ec}.doc-b p{margin:0 0 8px}
|
|
143
|
+
.doc-b code{font-family:ui-monospace,monospace;font-size:.9em;background:#0e1118;padding:1px 5px;border-radius:4px;color:#4ec9b0}
|
|
144
|
+
.doc-frame{width:100%;height:420px;border:1px solid #222835;border-radius:8px;background:#fff}
|
|
145
|
+
.ft{margin-top:40px;color:#3d4657;font-size:12px;font-family:ui-monospace,monospace}
|
|
146
|
+
.ft a{color:#4ec9b0;text-decoration:none}
|
|
147
|
+
</style></head><body><div class="wrap">
|
|
148
|
+
<div class="hd"><span class="mark">coxpit</span><span class="rid">r${run.id}</span><span class="chip">${escH(run.status)}</span></div>
|
|
149
|
+
<h1>${escH(taskTitle)}</h1>
|
|
150
|
+
<div class="meta">branch ${escH(run.branch || '—')} · ${run.filesChanged} file(s) changed · agent ${escH(run.agent)}</div>
|
|
151
|
+
${run.exitSummary ? `<div class="sum">${escH(run.exitSummary)}</div>` : ''}
|
|
152
|
+
${shareDocsHTML(docs)}
|
|
153
|
+
<div class="sec">Timeline</div>
|
|
154
|
+
<div class="tl">${lines.map((l) => `<div><span class="k">${escH(l.k)}</span><span class="t">${escH(l.t.slice(0, 220))}</span></div>`).join('') || '<span style="color:#5c6675">no events</span>'}</div>
|
|
155
|
+
<div class="sec">Diff</div>
|
|
156
|
+
<pre>${shareDiffHTML(diff)}</pre>
|
|
157
|
+
<div class="ft">read-only snapshot shared via <a href="https://github.com/hanmariyang/coxpit-oss">coxpit</a></div>
|
|
158
|
+
</div></body></html>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
31
161
|
export async function buildServer(): Promise<FastifyInstance> {
|
|
32
162
|
const app = Fastify({ logger: true });
|
|
33
163
|
await app.register(websocket);
|
|
@@ -210,6 +340,26 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
210
340
|
return { ok: true };
|
|
211
341
|
});
|
|
212
342
|
|
|
343
|
+
// 기본 브랜치 변경 — merge·Sync base·PR 이 향할 대상. develop-flow repo 대응.
|
|
344
|
+
app.patch('/api/repos/:id', async (req, reply) => {
|
|
345
|
+
const id = Number((req.params as { id: string }).id);
|
|
346
|
+
const b = (req.body ?? {}) as { defaultBranch?: string };
|
|
347
|
+
const branch = (b.defaultBranch ?? '').trim();
|
|
348
|
+
if (!/^[\w.\-/]{1,80}$/.test(branch)) return reply.code(400).send({ error: 'invalid branch name' });
|
|
349
|
+
const rp = await db.select().from(repos).where(eq(repos.id, id)).limit(1);
|
|
350
|
+
const repo = rp[0];
|
|
351
|
+
if (!repo) return reply.code(404).send({ error: 'not found' });
|
|
352
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
353
|
+
const m = mr[0];
|
|
354
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
355
|
+
// branch 는 charset 가드 통과(셸 메타문자 없음). 전체 ref 를 인용해 전달.
|
|
356
|
+
const check = await runShellOn(m,
|
|
357
|
+
`git -C ${shq(repo.path)} rev-parse --verify --quiet ${shq('refs/heads/' + branch)} >/dev/null && echo OK`, 10000);
|
|
358
|
+
if (!check.stdout.includes('OK')) return reply.code(400).send({ error: `branch '${branch}' not found in the repository` });
|
|
359
|
+
await db.update(repos).set({ defaultBranch: branch }).where(eq(repos.id, id));
|
|
360
|
+
return { ok: true, defaultBranch: branch };
|
|
361
|
+
});
|
|
362
|
+
|
|
213
363
|
// 디렉토리 브라우저 — repo 등록용 파일 피커(로컬 머신 전용, 인증 게이트 뒤).
|
|
214
364
|
app.get('/api/browse', async (req) => {
|
|
215
365
|
const q = (req.query ?? {}) as { path?: string };
|
|
@@ -311,7 +461,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
311
461
|
// N개의 에이전트 run 을 만들고 각자 오케스트레이션 시작(fire-and-forget).
|
|
312
462
|
app.post('/api/tasks/:id/run', async (req, reply) => {
|
|
313
463
|
const id = Number((req.params as { id: string }).id);
|
|
314
|
-
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean };
|
|
464
|
+
const b = (req.body ?? {}) as { agent?: string; count?: number; real?: boolean; model?: string };
|
|
315
465
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
316
466
|
const task = tr[0];
|
|
317
467
|
if (!task) return reply.code(404).send({ error: 'task not found' });
|
|
@@ -321,10 +471,15 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
321
471
|
const count = Math.max(1, Math.min(8, Number(b.count) || 1));
|
|
322
472
|
// 미지의 값은 기본 프로바이더로 정규화(런처 조작·API 오타 방어)
|
|
323
473
|
const agent = getProvider(b.agent).id;
|
|
474
|
+
// 모델 지정(선택) — 셸 안전 문자만, 빈값 = CLI 기본
|
|
475
|
+
const model = (b.model ?? '').trim();
|
|
476
|
+
if (model && (model.length > 64 || !/^[\w.\-:/]*$/.test(model))) {
|
|
477
|
+
return reply.code(400).send({ error: 'invalid model name' });
|
|
478
|
+
}
|
|
324
479
|
const created: Array<typeof agentRuns.$inferSelect> = [];
|
|
325
480
|
for (let i = 0; i < count; i++) {
|
|
326
481
|
const ins = await db.insert(agentRuns)
|
|
327
|
-
.values({ taskId: id, machineId: rp[0].machineId, agent, status: 'pending' })
|
|
482
|
+
.values({ taskId: id, machineId: rp[0].machineId, agent, model, status: 'pending' })
|
|
328
483
|
.returning();
|
|
329
484
|
created.push(ins[0]!);
|
|
330
485
|
}
|
|
@@ -364,8 +519,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
364
519
|
// 태스크 닫기 — 살아있는 run 중지 후 소속 run 전체 worktree/브랜치 정리.
|
|
365
520
|
app.post('/api/tasks/:id/close', async (req, reply) => {
|
|
366
521
|
const id = Number((req.params as { id: string }).id);
|
|
522
|
+
const b = (req.body ?? {}) as { force?: boolean };
|
|
367
523
|
const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
|
368
524
|
if (!tr[0]) return reply.code(404).send({ error: 'task not found' });
|
|
525
|
+
// Close 가드 — 아직 살릴 곳 없는 산출물(미머지·미export·무PR)이 있으면 확인 요구.
|
|
526
|
+
if (!b.force) {
|
|
527
|
+
const atRisk = await taskCloseRisk(id);
|
|
528
|
+
if (atRisk.length) return reply.code(409).send({ error: 'unmerged output', atRisk });
|
|
529
|
+
}
|
|
369
530
|
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
|
|
370
531
|
|
|
371
532
|
let anyStopped = false;
|
|
@@ -509,12 +670,99 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
509
670
|
return getRunDiff(id);
|
|
510
671
|
});
|
|
511
672
|
|
|
512
|
-
// Doc 모드 — 변경된 문서(md/html)
|
|
673
|
+
// Doc 모드 — 변경된 문서(md/html) 내용째 (렌더 뷰). worktree 라이브 → 스냅샷 폴백.
|
|
513
674
|
app.get('/api/runs/:id/docs', async (req, reply) => {
|
|
514
675
|
const id = Number((req.params as { id: string }).id);
|
|
515
676
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
516
677
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
517
|
-
|
|
678
|
+
const { docs, source } = await loadRunDocs(id);
|
|
679
|
+
return { ok: true, docs, source };
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
|
|
683
|
+
const agentAuth = (req: { headers: { authorization?: string } }): number | null => {
|
|
684
|
+
const h = req.headers.authorization ?? '';
|
|
685
|
+
if (!h.startsWith('Bearer ')) return null;
|
|
686
|
+
return resolveAgentToken(h.slice(7).trim());
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
app.post('/api/agent/subtasks', async (req, reply) => {
|
|
690
|
+
const rid = agentAuth(req);
|
|
691
|
+
if (rid == null) return reply.code(401).send({ error: 'invalid agent token' });
|
|
692
|
+
const b = (req.body ?? {}) as { title?: string; prompt?: string; count?: number };
|
|
693
|
+
if (!b.title?.trim() || !b.prompt?.trim()) return reply.code(400).send({ error: 'title and prompt required' });
|
|
694
|
+
const r = await spawnSubtasks(rid, b.title.trim(), b.prompt, Number(b.count) || 1);
|
|
695
|
+
if (!r.ok) return reply.code(409).send({ error: r.detail });
|
|
696
|
+
return reply.code(201).send(r);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
app.get('/api/agent/subtasks', async (req, reply) => {
|
|
700
|
+
const rid = agentAuth(req);
|
|
701
|
+
if (rid == null) return reply.code(401).send({ error: 'invalid agent token' });
|
|
702
|
+
return { subtasks: await listSubtasks(rid) };
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ─── GitHub 이슈/PR → 태스크 초안 (자동 발사 아님 — 사람이 검토 후 Run fleet) ──
|
|
706
|
+
app.post('/api/tasks/from-github', async (req, reply) => {
|
|
707
|
+
const b = (req.body ?? {}) as { url?: string };
|
|
708
|
+
const m = (b.url ?? '').trim().match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)/);
|
|
709
|
+
if (!m) return reply.code(400).send({ error: 'expected a github.com issue or pull request URL' });
|
|
710
|
+
const [, owner, repo, kind, num] = m as unknown as [string, string, string, 'issues' | 'pull', string];
|
|
711
|
+
const isPr = kind === 'pull';
|
|
712
|
+
let title = '', body = '';
|
|
713
|
+
// gh CLI 우선(사설 repo 는 gh 인증이 필요) — 없거나 실패하면 공개 API 폴백
|
|
714
|
+
const local = { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
715
|
+
const ghCmd = `gh ${isPr ? 'pr' : 'issue'} view ${shq(b.url!.trim())} --json title,body`;
|
|
716
|
+
const g = await runShellOn(local, `command -v gh >/dev/null 2>&1 && ${ghCmd}`, 20000);
|
|
717
|
+
if (g.ok) {
|
|
718
|
+
try { const j = JSON.parse(g.stdout) as { title?: string; body?: string }; title = j.title ?? ''; body = j.body ?? ''; } catch { /* fall through */ }
|
|
719
|
+
}
|
|
720
|
+
if (!title) {
|
|
721
|
+
try {
|
|
722
|
+
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}`, {
|
|
723
|
+
headers: { 'user-agent': 'coxpit', accept: 'application/vnd.github+json' },
|
|
724
|
+
signal: AbortSignal.timeout(10000),
|
|
725
|
+
});
|
|
726
|
+
if (r.ok) { const j = await r.json() as { title?: string; body?: string }; title = j.title ?? ''; body = j.body ?? ''; }
|
|
727
|
+
} catch { /* unreachable/private */ }
|
|
728
|
+
}
|
|
729
|
+
if (!title) return reply.code(502).send({ error: 'could not fetch it — private repo needs the gh CLI signed in on the daemon machine' });
|
|
730
|
+
return {
|
|
731
|
+
ok: true,
|
|
732
|
+
title: `${repo}#${num} · ${title}`.slice(0, 140),
|
|
733
|
+
prompt: `GitHub ${isPr ? 'pull request' : 'issue'}: ${b.url!.trim()}\n\n# ${title}\n\n${(body || '(no description)').slice(0, 6000)}\n\n---\nWork in this repository to address the ${isPr ? 'pull request' : 'issue'} above. Keep the change minimal and verifiable, and say how to verify it in your final summary.`,
|
|
734
|
+
};
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ─── 읽기 전용 공유 링크 — 토큰 URL 이 곧 능력(스냅샷 뷰, 액션 없음) ──
|
|
738
|
+
app.post('/api/runs/:id/share', async (req, reply) => {
|
|
739
|
+
const id = Number((req.params as { id: string }).id);
|
|
740
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
741
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
742
|
+
const ex = await db.select().from(shareLinks).where(eq(shareLinks.runId, id));
|
|
743
|
+
if (ex[0]) return { ok: true, url: `/share/${ex[0].token}`, existing: true };
|
|
744
|
+
const token = randomBytes(12).toString('base64url');
|
|
745
|
+
await db.insert(shareLinks).values({ runId: id, token });
|
|
746
|
+
return reply.code(201).send({ ok: true, url: `/share/${token}` });
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
app.delete('/api/runs/:id/share', async (req, reply) => {
|
|
750
|
+
const id = Number((req.params as { id: string }).id);
|
|
751
|
+
await db.delete(shareLinks).where(eq(shareLinks.runId, id));
|
|
752
|
+
return { ok: true };
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
app.get('/share/:token', async (req, reply) => {
|
|
756
|
+
const { token } = req.params as { token: string };
|
|
757
|
+
const sl = (await db.select().from(shareLinks).where(eq(shareLinks.token, token)).limit(1))[0];
|
|
758
|
+
if (!sl) return reply.code(404).type('text/html').send('<!doctype html><meta charset="utf-8"><body style="background:#0b0d12;color:#8792a2;font-family:ui-monospace,monospace;padding:40px">share link not found or revoked</body>');
|
|
759
|
+
const run = (await db.select().from(agentRuns).where(eq(agentRuns.id, sl.runId)).limit(1))[0];
|
|
760
|
+
if (!run) return reply.code(404).send({ error: 'run gone' });
|
|
761
|
+
const task = (await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1))[0];
|
|
762
|
+
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
|
|
763
|
+
const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
|
|
764
|
+
const { docs } = await loadRunDocs(run.id);
|
|
765
|
+
return reply.type('text/html').send(sharePageHTML(run, task?.title ?? `task ${run.taskId}`, evs, d.ok ? d.diff : '', docs));
|
|
518
766
|
});
|
|
519
767
|
|
|
520
768
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|