coxpit 3.8.0 → 4.0.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 +5 -1
- package/package.json +1 -1
- package/src/auth.ts +4 -1
- package/src/board.ts +52 -1
- package/src/config.ts +3 -0
- package/src/db/index.ts +7 -0
- package/src/db/schema.ts +9 -0
- package/src/orchestrator.ts +164 -5
- package/src/server.ts +181 -2
package/README.md
CHANGED
|
@@ -20,6 +20,9 @@ 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, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
23
26
|
|
|
24
27
|
External tools are spawned, never vendored: `git`, `tmux`, your agent CLI. No editor bundled — terminal-first.
|
|
25
28
|
|
|
@@ -74,6 +77,7 @@ Your keys and login never touch coxpit's config or database.
|
|
|
74
77
|
| `COXPIT_AGENT_PERM` | `acceptEdits` | Claude Code headless permission mode |
|
|
75
78
|
| `COXPIT_CODEX_BIN` | `codex` | Codex CLI command (optional second provider) |
|
|
76
79
|
| `COXPIT_CODEX_SANDBOX` | `workspace-write` | Codex sandbox policy (`danger-full-access` for full autonomy) |
|
|
80
|
+
| `COXPIT_AGENT_ORCH` | on | `0` disables agent self-orchestration (the `.coxpit/spawn.json` protocol + prompt note) |
|
|
77
81
|
| `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
|
|
78
82
|
| `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
83
|
|
|
@@ -105,7 +109,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
105
109
|
|
|
106
110
|
## Status
|
|
107
111
|
|
|
108
|
-
`
|
|
112
|
+
`v4.0` — fleet, two providers (Claude Code · Codex), compare/merge + AI review + doc mode, 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 — all shipped and e2e-tested (30 checks). Roadmap: ROADMAP.md.
|
|
109
113
|
|
|
110
114
|
## License
|
|
111
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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
|
@@ -399,6 +399,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
399
399
|
<div id="panelTask" style="display:flex;flex-direction:column;gap:8px">
|
|
400
400
|
<input id="taskTitle" placeholder="Task title" />
|
|
401
401
|
<textarea id="taskPrompt" placeholder="Prompt — target files, constraints, how to verify"></textarea>
|
|
402
|
+
<button type="button" class="btn-ghost sm" id="ghImport">From GitHub issue / PR…</button>
|
|
402
403
|
<div class="seg" id="provSeg" role="group" aria-label="agent provider">
|
|
403
404
|
<button type="button" class="seg-opt on" data-agent="claude-code">Claude</button>
|
|
404
405
|
<button type="button" class="seg-opt" data-agent="codex">Codex</button>
|
|
@@ -478,6 +479,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
478
479
|
<button class="btn-ghost sm" id="mCompare">Compare runs</button>
|
|
479
480
|
<button class="btn-ghost sm" id="mExport">Export files…</button>
|
|
480
481
|
<button class="btn-ghost sm" id="mSync">Sync base</button>
|
|
482
|
+
<button class="btn-ghost sm" id="mShare" title="create a read-only share link (no auth, snapshot view)">Share</button>
|
|
481
483
|
<span class="spacer"></span>
|
|
482
484
|
<button class="btn-danger sm" id="mStop">Stop</button>
|
|
483
485
|
<button class="btn-ghost sm" id="mCleanup">Cleanup</button>
|
|
@@ -548,6 +550,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
548
550
|
<button class="btn-ghost sm" id="selCancel">Cancel</button>
|
|
549
551
|
</div>
|
|
550
552
|
|
|
553
|
+
<div class="overlay" id="ghOverlay">
|
|
554
|
+
<div class="cfm">
|
|
555
|
+
<div class="cfm-b">
|
|
556
|
+
<div class="m">Start from a GitHub issue or pull request</div>
|
|
557
|
+
<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>
|
|
558
|
+
<p class="flabel" style="margin-top:12px">issue / PR url</p>
|
|
559
|
+
<input id="ghUrl" placeholder="https://github.com/owner/repo/issues/123" />
|
|
560
|
+
</div>
|
|
561
|
+
<div class="cfm-f">
|
|
562
|
+
<button class="btn-ghost sm" id="ghCancel">Cancel</button>
|
|
563
|
+
<button class="btn sm" id="ghOk">Fetch</button>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
551
568
|
<div class="overlay" id="expOverlay">
|
|
552
569
|
<div class="cfm">
|
|
553
570
|
<div class="cfm-b">
|
|
@@ -726,6 +743,7 @@ function humanize(e){
|
|
|
726
743
|
}
|
|
727
744
|
if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
|
|
728
745
|
if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
|
|
746
|
+
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
747
|
if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
|
|
730
748
|
return { k:kind, t:payload.slice(0,140) };
|
|
731
749
|
}catch{
|
|
@@ -851,6 +869,7 @@ function cardHTML(r){
|
|
|
851
869
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
852
870
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
853
871
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
872
|
+
+ (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
|
|
854
873
|
+ (r.sessionId && ['done','failed','stopped'].includes(r.status)
|
|
855
874
|
? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
|
|
856
875
|
+ (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
|
|
@@ -1106,7 +1125,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1106
1125
|
});
|
|
1107
1126
|
$('mClose').addEventListener('click', closeModal);
|
|
1108
1127
|
$('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'); } });
|
|
1128
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1110
1129
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1111
1130
|
$('mExport').addEventListener('click', ()=>{
|
|
1112
1131
|
if (openRunId==null) return;
|
|
@@ -1530,6 +1549,38 @@ let savedAgent = null;
|
|
|
1530
1549
|
try { savedAgent = localStorage.getItem('coxpit.agent'); } catch {}
|
|
1531
1550
|
if (savedAgent === 'codex') setProvider('codex', false);
|
|
1532
1551
|
|
|
1552
|
+
/* ── GitHub 이슈/PR → 태스크 초안 ── */
|
|
1553
|
+
$('ghImport').addEventListener('click', ()=>{ $('ghUrl').value=''; $('ghOverlay').classList.add('open'); $('ghUrl').focus(); });
|
|
1554
|
+
$('ghCancel').addEventListener('click', ()=>$('ghOverlay').classList.remove('open'));
|
|
1555
|
+
$('ghOverlay').addEventListener('click',(e)=>{ if(e.target===$('ghOverlay')) $('ghOverlay').classList.remove('open'); });
|
|
1556
|
+
async function ghFetch(){
|
|
1557
|
+
const url = $('ghUrl').value.trim();
|
|
1558
|
+
if (!url) return;
|
|
1559
|
+
$('ghOk').disabled = true; $('ghOk').textContent = 'Fetching…';
|
|
1560
|
+
try{
|
|
1561
|
+
const res = await fetch('/api/tasks/from-github',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url})});
|
|
1562
|
+
const j = await res.json().catch(()=>({}));
|
|
1563
|
+
if (!res.ok){ toast('github: '+(j.error||res.status), 'error'); return; }
|
|
1564
|
+
$('taskTitle').value = j.title; $('taskPrompt').value = j.prompt;
|
|
1565
|
+
$('ghOverlay').classList.remove('open');
|
|
1566
|
+
toast('drafted from GitHub — review, then Run fleet', 'ok');
|
|
1567
|
+
} finally { $('ghOk').disabled = false; $('ghOk').textContent = 'Fetch'; }
|
|
1568
|
+
}
|
|
1569
|
+
$('ghOk').addEventListener('click', ghFetch);
|
|
1570
|
+
$('ghUrl').addEventListener('keydown',(e)=>{ if(e.key==='Enter') ghFetch(); });
|
|
1571
|
+
|
|
1572
|
+
/* ── 읽기 전용 공유 링크 ── */
|
|
1573
|
+
$('mShare').addEventListener('click', async ()=>{
|
|
1574
|
+
if (openRunId==null) return;
|
|
1575
|
+
const res = await fetch('/api/runs/'+openRunId+'/share',{method:'POST'});
|
|
1576
|
+
const j = await res.json().catch(()=>({}));
|
|
1577
|
+
if (!res.ok){ toast('share: '+(j.error||res.status), 'error'); return; }
|
|
1578
|
+
const url = location.origin + j.url;
|
|
1579
|
+
let copied = false;
|
|
1580
|
+
try{ await navigator.clipboard.writeText(url); copied = true; }catch{}
|
|
1581
|
+
toast((j.existing?'share link (existing)':'share link created')+(copied?' — copied':'')+': '+url, 'ok');
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1533
1584
|
/* ── mobile drawer ── */
|
|
1534
1585
|
const asideEl = document.querySelector('aside');
|
|
1535
1586
|
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,16 @@ 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
|
+
);
|
|
72
78
|
`);
|
|
73
79
|
// 기존 DB 마이그레이션(멱등)
|
|
74
80
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
|
75
81
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
76
82
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN pr_url TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
83
|
+
try { await client.execute('ALTER TABLE tasks ADD COLUMN parent_run_id INTEGER'); } catch { /* exists */ }
|
|
77
84
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -40,6 +40,15 @@ 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
|
+
/** 읽기 전용 공유 링크 — run 스냅샷을 무인증으로 보여준다(토큰 = capability). */
|
|
48
|
+
export const shareLinks = sqliteTable('share_links', {
|
|
49
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
50
|
+
runId: integer('run_id').notNull(),
|
|
51
|
+
token: text('token').notNull().unique(),
|
|
43
52
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
44
53
|
});
|
|
45
54
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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';
|
|
@@ -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, 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 를 조회해 그룹째 죽인다
|
|
@@ -155,8 +284,29 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
155
284
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
156
285
|
// 드라이런 모의 스트림은 claude 형태 — 파서도 claude 로 (배관 리허설은 프로바이더 불문)
|
|
157
286
|
const provider = useReal ? getProvider(ctx.agent) : getProvider('claude-code');
|
|
158
|
-
|
|
159
|
-
|
|
287
|
+
// 셀프 오케스트레이션 — real+로컬 run 에만 토큰/API env 와 능력 고지를 준다
|
|
288
|
+
// (원격은 127.0.0.1 이 데몬에 닿지 않음). COXPIT_AGENT_ORCH=0 으로 끌 수 있음.
|
|
289
|
+
let prompt = ctx.prompt;
|
|
290
|
+
let envPrefix = '';
|
|
291
|
+
if (useReal && !isRemote && config.agentOrch) {
|
|
292
|
+
const tok = issueAgentToken(runId);
|
|
293
|
+
envPrefix = `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(tok)}; `;
|
|
294
|
+
prompt += orchestrationNote();
|
|
295
|
+
}
|
|
296
|
+
const cmd = `cd ${shq(wtPath)} && ${envPrefix}${pidPrefix}{ ${agentCommand(provider, prompt, useReal)}; }`;
|
|
297
|
+
// 파일 오케스트레이션 — 로컬 run 이 사는 동안 .coxpit/spawn.json 감시.
|
|
298
|
+
// .coxpit/ 는 repo exclude 에 넣어 diff/머지를 오염시키지 않는다(멱등).
|
|
299
|
+
let orchTimer: NodeJS.Timeout | null = null;
|
|
300
|
+
if (!isRemote && config.agentOrch) {
|
|
301
|
+
await runShellOn(ctx.machine,
|
|
302
|
+
`EX=$(git -C ${shq(wtPath)} rev-parse --git-path info/exclude) && { grep -qxF '.coxpit/' "$EX" 2>/dev/null || echo '.coxpit/' >> "$EX"; }`, 8000);
|
|
303
|
+
orchTimer = startOrchWatch(runId, wtPath, useReal);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await runAgentChild(runId, ctx.machine, wtPath, cmd, provider);
|
|
307
|
+
} finally {
|
|
308
|
+
if (orchTimer) clearInterval(orchTimer);
|
|
309
|
+
}
|
|
160
310
|
} catch (e) {
|
|
161
311
|
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
162
312
|
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
@@ -255,9 +405,18 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
255
405
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
256
406
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
257
407
|
const provider = getProvider(ctx.agent);
|
|
408
|
+
// steer 세션도 셀프 오케스트레이션 유지(데몬 재시작으로 무효화된 토큰 재발급)
|
|
409
|
+
const envPrefix = (!isRemote && config.agentOrch)
|
|
410
|
+
? `export COXPIT_API=${shq(`http://127.0.0.1:${config.port}`)} COXPIT_TOKEN=${shq(issueAgentToken(runId))}; `
|
|
411
|
+
: '';
|
|
258
412
|
const resume = provider.resumeCmd(run.sessionId, finalMessage);
|
|
259
|
-
const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
|
|
260
|
-
|
|
413
|
+
const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
|
|
414
|
+
if (!isRemote && config.agentOrch) {
|
|
415
|
+
const orchTimer = startOrchWatch(runId, wt, true);
|
|
416
|
+
void runAgentChild(runId, ctx.machine, wt, cmd, provider).finally(() => clearInterval(orchTimer));
|
|
417
|
+
} else {
|
|
418
|
+
void runAgentChild(runId, ctx.machine, wt, cmd, provider);
|
|
419
|
+
}
|
|
261
420
|
return { ok: true, detail: 'steering' };
|
|
262
421
|
}
|
|
263
422
|
|
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, getRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench } from './orchestrator';
|
|
16
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, getRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken } 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,99 @@ 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
|
+
/** 보드 humanize 의 서버측 축약판 — 이벤트 한 줄을 {k, t} 로. null = 잡음. */
|
|
37
|
+
function shareLine(kind: string, payload: string): { k: string; t: string } | null {
|
|
38
|
+
if (kind === 'steer') return { k: 'steer', t: '→ ' + payload };
|
|
39
|
+
if (kind === 'ask') return { k: 'ask', t: '? ' + payload };
|
|
40
|
+
if (kind === 'sync' || kind === 'pr' || kind === 'export') return { k: kind, t: payload };
|
|
41
|
+
if (kind === 'stderr' || kind === 'error') return { k: kind, t: payload };
|
|
42
|
+
try {
|
|
43
|
+
const o = JSON.parse(payload) as {
|
|
44
|
+
type?: string; subtype?: string; text?: string; result?: string; worktree?: string;
|
|
45
|
+
message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, string> }> };
|
|
46
|
+
};
|
|
47
|
+
if (o.type === 'system') return o.subtype === 'init' || !o.subtype ? { k: 'session', t: 'started' } : null;
|
|
48
|
+
if (o.type === 'user') return null;
|
|
49
|
+
if (o.type === 'assistant' && o.message) {
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
for (const c of o.message.content ?? []) {
|
|
52
|
+
if (c.type === 'text' && c.text) parts.push(c.text);
|
|
53
|
+
else if (c.type === 'tool_use') {
|
|
54
|
+
const i = c.input ?? {};
|
|
55
|
+
const arg = i.file_path || i.command || i.path || i.pattern || '';
|
|
56
|
+
parts.push('▸ ' + (c.name ?? 'tool') + (arg ? ' — ' + String(arg).split('/').slice(-2).join('/').slice(0, 60) : ''));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return parts.length ? { k: 'agent', t: parts.join(' · ') } : null;
|
|
60
|
+
}
|
|
61
|
+
if (o.type === 'assistant' && o.text) return { k: 'said', t: o.text };
|
|
62
|
+
if (o.type === 'result') return { k: 'done', t: o.result || 'finished' };
|
|
63
|
+
if (kind === 'meta' && o.worktree) return { k: 'start', t: 'worktree ' + String(o.worktree).split('/').slice(-2).join('/') };
|
|
64
|
+
return null;
|
|
65
|
+
} catch { return payload.trim().startsWith('{') ? null : { k: kind, t: payload.slice(0, 160) }; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shareDiffHTML(text: string): string {
|
|
69
|
+
if (!text.trim()) return '<span style="color:#5c6675">no changes</span>';
|
|
70
|
+
return text.slice(0, 120_000).split('\n').map((l) => {
|
|
71
|
+
const e = escH(l);
|
|
72
|
+
if (l.startsWith('diff --git') || l.startsWith('+++') || l.startsWith('---')) return `<span class="f">${e}</span>`;
|
|
73
|
+
if (l.startsWith('@@')) return `<span class="h">${e}</span>`;
|
|
74
|
+
if (l.startsWith('+')) return `<span class="a">${e}</span>`;
|
|
75
|
+
if (l.startsWith('-')) return `<span class="d">${e}</span>`;
|
|
76
|
+
return e;
|
|
77
|
+
}).join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sharePageHTML(
|
|
81
|
+
run: { id: number; status: string; branch: string; agent: string; filesChanged: number; exitSummary: string },
|
|
82
|
+
taskTitle: string,
|
|
83
|
+
events: Array<{ kind: string; payload: string }>,
|
|
84
|
+
diff: string,
|
|
85
|
+
): string {
|
|
86
|
+
const lines = events.map((e) => shareLine(e.kind, e.payload)).filter((x): x is { k: string; t: string } => !!x);
|
|
87
|
+
const sc: Record<string, string> = { done: '#3fb970', merged: '#4ec9b0', failed: '#e5534b', error: '#e5534b', stopped: '#a371f7', running: '#4184e4', open: '#4ec9b0' };
|
|
88
|
+
const color = sc[run.status] ?? '#8792a2';
|
|
89
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
90
|
+
<title>coxpit · r${run.id} — ${escH(taskTitle)}</title>
|
|
91
|
+
<style>
|
|
92
|
+
body{margin:0;background:#0b0d12;color:#dee4ec;font-family:-apple-system,'Segoe UI',sans-serif;font-size:14px;line-height:1.55}
|
|
93
|
+
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 60px}
|
|
94
|
+
.hd{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:4px}
|
|
95
|
+
.mark{color:#4ec9b0;font-family:ui-monospace,monospace;font-weight:700}
|
|
96
|
+
.rid{color:#5c6675;font-family:ui-monospace,monospace}
|
|
97
|
+
h1{font-size:17px;margin:6px 0 2px}
|
|
98
|
+
.chip{display:inline-block;font-family:ui-monospace,monospace;font-size:11px;text-transform:uppercase;letter-spacing:.08em;
|
|
99
|
+
padding:2px 10px;border:1px solid ${color};border-radius:999px;color:${color}}
|
|
100
|
+
.meta{color:#5c6675;font-family:ui-monospace,monospace;font-size:11.5px;margin:8px 0 22px}
|
|
101
|
+
.sec{font-family:ui-monospace,monospace;font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:#5c6675;
|
|
102
|
+
border-bottom:1px solid #222835;padding-bottom:6px;margin:26px 0 10px}
|
|
103
|
+
.tl{font-family:ui-monospace,monospace;font-size:12px;display:flex;flex-direction:column;gap:6px}
|
|
104
|
+
.tl .k{color:#4ec9b0;display:inline-block;min-width:64px}
|
|
105
|
+
.tl .t{color:#8792a2;word-break:break-word}
|
|
106
|
+
pre{background:#0e1118;border:1px solid #222835;border-radius:10px;padding:14px;overflow-x:auto;
|
|
107
|
+
font-family:ui-monospace,monospace;font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
|
108
|
+
.f{color:#4ec9b0;font-weight:600}.h{color:#4184e4}.a{color:#3fb970}.d{color:#e5534b}
|
|
109
|
+
.sum{background:#12151c;border:1px solid #222835;border-radius:10px;padding:12px 14px;color:#8792a2;font-size:13px}
|
|
110
|
+
.ft{margin-top:40px;color:#3d4657;font-size:12px;font-family:ui-monospace,monospace}
|
|
111
|
+
.ft a{color:#4ec9b0;text-decoration:none}
|
|
112
|
+
</style></head><body><div class="wrap">
|
|
113
|
+
<div class="hd"><span class="mark">coxpit</span><span class="rid">r${run.id}</span><span class="chip">${escH(run.status)}</span></div>
|
|
114
|
+
<h1>${escH(taskTitle)}</h1>
|
|
115
|
+
<div class="meta">branch ${escH(run.branch || '—')} · ${run.filesChanged} file(s) changed · agent ${escH(run.agent)}</div>
|
|
116
|
+
${run.exitSummary ? `<div class="sum">${escH(run.exitSummary)}</div>` : ''}
|
|
117
|
+
<div class="sec">Timeline</div>
|
|
118
|
+
<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>
|
|
119
|
+
<div class="sec">Diff</div>
|
|
120
|
+
<pre>${shareDiffHTML(diff)}</pre>
|
|
121
|
+
<div class="ft">read-only snapshot shared via <a href="https://github.com/hanmariyang/coxpit-oss">coxpit</a></div>
|
|
122
|
+
</div></body></html>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
31
125
|
export async function buildServer(): Promise<FastifyInstance> {
|
|
32
126
|
const app = Fastify({ logger: true });
|
|
33
127
|
await app.register(websocket);
|
|
@@ -517,6 +611,91 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
517
611
|
return getRunDocs(id);
|
|
518
612
|
});
|
|
519
613
|
|
|
614
|
+
// ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
|
|
615
|
+
const agentAuth = (req: { headers: { authorization?: string } }): number | null => {
|
|
616
|
+
const h = req.headers.authorization ?? '';
|
|
617
|
+
if (!h.startsWith('Bearer ')) return null;
|
|
618
|
+
return resolveAgentToken(h.slice(7).trim());
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
app.post('/api/agent/subtasks', async (req, reply) => {
|
|
622
|
+
const rid = agentAuth(req);
|
|
623
|
+
if (rid == null) return reply.code(401).send({ error: 'invalid agent token' });
|
|
624
|
+
const b = (req.body ?? {}) as { title?: string; prompt?: string; count?: number };
|
|
625
|
+
if (!b.title?.trim() || !b.prompt?.trim()) return reply.code(400).send({ error: 'title and prompt required' });
|
|
626
|
+
const r = await spawnSubtasks(rid, b.title.trim(), b.prompt, Number(b.count) || 1);
|
|
627
|
+
if (!r.ok) return reply.code(409).send({ error: r.detail });
|
|
628
|
+
return reply.code(201).send(r);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
app.get('/api/agent/subtasks', async (req, reply) => {
|
|
632
|
+
const rid = agentAuth(req);
|
|
633
|
+
if (rid == null) return reply.code(401).send({ error: 'invalid agent token' });
|
|
634
|
+
return { subtasks: await listSubtasks(rid) };
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// ─── GitHub 이슈/PR → 태스크 초안 (자동 발사 아님 — 사람이 검토 후 Run fleet) ──
|
|
638
|
+
app.post('/api/tasks/from-github', async (req, reply) => {
|
|
639
|
+
const b = (req.body ?? {}) as { url?: string };
|
|
640
|
+
const m = (b.url ?? '').trim().match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)/);
|
|
641
|
+
if (!m) return reply.code(400).send({ error: 'expected a github.com issue or pull request URL' });
|
|
642
|
+
const [, owner, repo, kind, num] = m as unknown as [string, string, string, 'issues' | 'pull', string];
|
|
643
|
+
const isPr = kind === 'pull';
|
|
644
|
+
let title = '', body = '';
|
|
645
|
+
// gh CLI 우선(사설 repo 는 gh 인증이 필요) — 없거나 실패하면 공개 API 폴백
|
|
646
|
+
const local = { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
647
|
+
const ghCmd = `gh ${isPr ? 'pr' : 'issue'} view ${shq(b.url!.trim())} --json title,body`;
|
|
648
|
+
const g = await runShellOn(local, `command -v gh >/dev/null 2>&1 && ${ghCmd}`, 20000);
|
|
649
|
+
if (g.ok) {
|
|
650
|
+
try { const j = JSON.parse(g.stdout) as { title?: string; body?: string }; title = j.title ?? ''; body = j.body ?? ''; } catch { /* fall through */ }
|
|
651
|
+
}
|
|
652
|
+
if (!title) {
|
|
653
|
+
try {
|
|
654
|
+
const r = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}`, {
|
|
655
|
+
headers: { 'user-agent': 'coxpit', accept: 'application/vnd.github+json' },
|
|
656
|
+
signal: AbortSignal.timeout(10000),
|
|
657
|
+
});
|
|
658
|
+
if (r.ok) { const j = await r.json() as { title?: string; body?: string }; title = j.title ?? ''; body = j.body ?? ''; }
|
|
659
|
+
} catch { /* unreachable/private */ }
|
|
660
|
+
}
|
|
661
|
+
if (!title) return reply.code(502).send({ error: 'could not fetch it — private repo needs the gh CLI signed in on the daemon machine' });
|
|
662
|
+
return {
|
|
663
|
+
ok: true,
|
|
664
|
+
title: `${repo}#${num} · ${title}`.slice(0, 140),
|
|
665
|
+
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.`,
|
|
666
|
+
};
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ─── 읽기 전용 공유 링크 — 토큰 URL 이 곧 능력(스냅샷 뷰, 액션 없음) ──
|
|
670
|
+
app.post('/api/runs/:id/share', async (req, reply) => {
|
|
671
|
+
const id = Number((req.params as { id: string }).id);
|
|
672
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
673
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
674
|
+
const ex = await db.select().from(shareLinks).where(eq(shareLinks.runId, id));
|
|
675
|
+
if (ex[0]) return { ok: true, url: `/share/${ex[0].token}`, existing: true };
|
|
676
|
+
const token = randomBytes(12).toString('base64url');
|
|
677
|
+
await db.insert(shareLinks).values({ runId: id, token });
|
|
678
|
+
return reply.code(201).send({ ok: true, url: `/share/${token}` });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
app.delete('/api/runs/:id/share', async (req, reply) => {
|
|
682
|
+
const id = Number((req.params as { id: string }).id);
|
|
683
|
+
await db.delete(shareLinks).where(eq(shareLinks.runId, id));
|
|
684
|
+
return { ok: true };
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
app.get('/share/:token', async (req, reply) => {
|
|
688
|
+
const { token } = req.params as { token: string };
|
|
689
|
+
const sl = (await db.select().from(shareLinks).where(eq(shareLinks.token, token)).limit(1))[0];
|
|
690
|
+
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>');
|
|
691
|
+
const run = (await db.select().from(agentRuns).where(eq(agentRuns.id, sl.runId)).limit(1))[0];
|
|
692
|
+
if (!run) return reply.code(404).send({ error: 'run gone' });
|
|
693
|
+
const task = (await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1))[0];
|
|
694
|
+
const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
|
|
695
|
+
const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
|
|
696
|
+
return reply.type('text/html').send(sharePageHTML(run, task?.title ?? `task ${run.taskId}`, evs, d.ok ? d.diff : ''));
|
|
697
|
+
});
|
|
698
|
+
|
|
520
699
|
// 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
|
|
521
700
|
app.get('/ws', { websocket: true }, (socket) => {
|
|
522
701
|
addSink(socket);
|