coxpit 4.6.0 → 4.8.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/src/db/index.ts CHANGED
@@ -44,6 +44,7 @@ export async function ensureSchema(): Promise<void> {
44
44
  prompt TEXT NOT NULL DEFAULT '',
45
45
  status TEXT NOT NULL DEFAULT 'open',
46
46
  design_capture_id INTEGER,
47
+ outputs TEXT NOT NULL DEFAULT '[]',
47
48
  created_at INTEGER DEFAULT (unixepoch())
48
49
  );
49
50
  CREATE TABLE IF NOT EXISTS agent_runs (
@@ -99,4 +100,5 @@ export async function ensureSchema(): Promise<void> {
99
100
  try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
100
101
  try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
101
102
  try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
103
+ try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
102
104
  }
package/src/db/schema.ts CHANGED
@@ -49,6 +49,7 @@ export const tasks = sqliteTable('tasks', {
49
49
  prompt: text('prompt').notNull().default(''),
50
50
  status: text('status').notNull().default('open'), // open | done
51
51
  designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
52
+ outputs: text('outputs').notNull().default('[]'), // 산출물 계약 — 선언한 타입 JSON 배열(answer|code|doc|page|file). 빈 배열 = 계약 없음.
52
53
  parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
53
54
  groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
54
55
  closedAt: integer('closed_at', { mode: 'timestamp' }), // 닫힌 시각(아카이브 정렬·표시)
package/src/icons.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Lucide 아이콘 스프라이트(ISC 라이선스 · CDN 0 · 인라인). currentColor 상속.
2
+ // board.ts / login.ts 가 공유한다. 경로는 전부 진짜 Lucide SVG(licenses/lucide.txt).
3
+ // 사용: 정적 <svg class="ic"><use href="#i-…"/></svg> · 클라이언트 동적은 ic('name') 헬퍼.
4
+
5
+ /** 숨김 <symbol> 스프라이트. <body> 최상단에 1벌 인라인. */
6
+ export const ICON_SPRITE = `<svg style="position:absolute;width:0;height:0" aria-hidden="true">
7
+ <symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
8
+ <symbol id="i-unlock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></symbol>
9
+ <symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></symbol>
10
+ <symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
11
+ <symbol id="i-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol>
12
+ <symbol id="i-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
13
+ <symbol id="i-play" viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3"/></symbol>
14
+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
15
+ <symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
16
+ <symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
17
+ <symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></symbol>
18
+ <symbol id="i-code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol>
19
+ <symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/></symbol>
20
+ <symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
21
+ <symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
22
+ <symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
23
+ <symbol id="i-pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></symbol>
24
+ <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
25
+ <symbol id="i-recycle" viewBox="0 0 24 24"><path d="M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5"/><path d="M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12"/><path d="m14 16-3 3 3 3"/><path d="M8.293 13.596 4.875 9.5 1.5 13.5"/><path d="m9 12 3-3-3-3"/><path d="M13.5 3.5 17 9.5l-3.5 2"/></symbol>
26
+ <symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
27
+ <symbol id="i-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
28
+ <symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
29
+ <symbol id="i-alert-triangle" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
30
+ <symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></symbol>
31
+ <symbol id="i-bell-off" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742"/><path d="m2 2 20 20"/><path d="M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05"/></symbol>
32
+ <symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
33
+ <symbol id="i-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></symbol>
34
+ </svg>`;
35
+
36
+ /** .ic 아이콘 스타일(1em·currentColor 상속·stroke). board/login 공통. */
37
+ export const ICON_CSS =
38
+ `.ic{width:1em;height:1em;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.125em;flex:none}`;
39
+
40
+ // 클라이언트에서 동적 HTML 문자열을 만들 때 쓰는 헬퍼 소스(보드 스크립트에 그대로 삽입).
41
+ // ic('x') => '<svg class="ic"><use href="#i-x"/></svg>'
42
+ export const ICON_JS_HELPER = `function ic(n){ return '<svg class="ic"><use href="#i-'+n+'"/></svg>'; }`;
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { config } from './config';
2
+ import { authMode, ensureSetupToken, isExposedBind } from './authkey';
2
3
  import { db, ensureSchema } from './db';
3
4
  import { machines } from './db/schema';
4
5
  import { acquireDaemonLock } from './lock';
@@ -40,10 +41,25 @@ try {
40
41
  throw e;
41
42
  }
42
43
 
43
- // 인증 켜졌는데 비번이 비면 fail-closed( 요청 401) 실행자가 401 보고 당황하지 않게 명시.
44
- if (!config.auth.disabled && config.auth.pass === '') {
45
- console.warn(
46
- '[coxpit] auth is ON but COXPIT_AUTH_PASS is empty — every request will be rejected (401).\n' +
47
- '[coxpit] Set COXPIT_AUTH_PASS to a password, or COXPIT_AUTH_DISABLED=1 for local dev.',
48
- );
44
+ // 접근키 인증 상태 안내. 실행( 미설정)이면 브랜디드 셋업 페이지 + 1회용 셋업 토큰을 찍는다.
45
+ // (토큰 = Jupyter 스타일: 로그 접근자 = 머신 소유자. 터널/타넷/로컬 어디서 붙어도 이 토큰으로 셋업 가능.)
46
+ {
47
+ const m = authMode();
48
+ if (m.mode === 'disabled') {
49
+ if (config.auth.disabled) {
50
+ console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
51
+ } else if (!isExposedBind()) {
52
+ console.log(`[coxpit] loopback-only bind (${config.host}) — trusted local, no login required. Bind to 0.0.0.0 to require an access key.`);
53
+ }
54
+ } else if (m.mode === 'setup') {
55
+ const token = ensureSetupToken();
56
+ console.log(
57
+ '[coxpit] no access key configured yet — open the board to set one (first-run setup).\n' +
58
+ `[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${config.port} directly): ${token}`,
59
+ );
60
+ } else if (m.mode === 'env') {
61
+ console.log('[coxpit] access-key auth ON (COXPIT_AUTH_PASS) — the branded unlock page asks for that key.');
62
+ } else {
63
+ console.log('[coxpit] access-key auth ON (stored key) — the branded unlock page asks for your key.');
64
+ }
49
65
  }
package/src/login.ts ADDED
@@ -0,0 +1,124 @@
1
+ // 브랜디드 접근키 언락/셋업 페이지(단일 자가완결 HTML — 빌드 0, 보드 토큰 매치).
2
+ // 브라우저 basic-auth 팝업을 대체한다. fetch 로 A2/A3 엔드포인트 POST → 성공 시 보드로 reload.
3
+ // setup=true → 첫 실행(키 설정, confirm 필드+토큰 힌트), false → 언락(키 1개).
4
+ import { ICON_SPRITE, ICON_CSS } from './icons.js';
5
+
6
+ /** login/setup 페이지 HTML. setup 이면 셋업(키+확인), 아니면 언락. */
7
+ export function loginPageHTML(setup: boolean): string {
8
+ const title = setup ? 'set an access key' : 'unlock';
9
+ return /* html */ `<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
14
+ <title>coxpit · ${title}</title>
15
+ <style>
16
+ :root{
17
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
18
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
19
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
20
+ --s-failed:#e25b67; --s-preparing:#d6a249;
21
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
22
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
23
+ --r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
24
+ }
25
+ *{box-sizing:border-box}
26
+ [hidden]{display:none !important}
27
+ html,body{height:100%}
28
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
29
+ -webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;padding:24px}
30
+ ::selection{background:var(--brand-dim)}
31
+ .card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
32
+ border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
33
+ .mark{font-family:var(--mono);font-weight:700;color:var(--brand);font-size:17px;letter-spacing:.02em;
34
+ display:flex;align-items:center;gap:9px}
35
+ .glyph{font-size:18px;line-height:1}
36
+ h1{font-size:15px;font-weight:600;margin:16px 0 4px;color:var(--ink)}
37
+ .sub{color:var(--muted);font-size:12.5px;margin:0 0 18px;line-height:1.5}
38
+ .flabel{display:block;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.12em;
39
+ color:var(--faint);margin:0 0 6px}
40
+ input[type=password]{width:100%;background:#0e1118;border:1px solid var(--line);border-radius:var(--r-ctl);
41
+ color:var(--ink);font-family:var(--mono);font-size:14px;padding:10px 12px;outline:none}
42
+ input[type=password]:focus{border-color:var(--brand)}
43
+ input::placeholder{color:var(--faint)}
44
+ .fld{margin-bottom:14px}
45
+ .row{display:flex;align-items:center;gap:8px;margin:2px 0 16px;color:var(--muted);font-size:12.5px;user-select:none;cursor:pointer}
46
+ .row input{accent-color:var(--brand);width:15px;height:15px}
47
+ .btn{width:100%;background:var(--brand);color:var(--brand-ink);border:0;border-radius:var(--r-ctl);
48
+ font-family:var(--sans);font-weight:600;font-size:14px;padding:11px;cursor:pointer;
49
+ display:flex;align-items:center;justify-content:center;gap:8px}
50
+ .btn:disabled{opacity:.55;cursor:default}
51
+ .err{color:var(--s-failed);font-size:12.5px;min-height:17px;margin:0 0 12px;font-family:var(--mono)}
52
+ .hint{background:var(--surface2);border:1px solid var(--line);border-radius:var(--r-ctl);
53
+ padding:9px 11px;color:var(--muted);font-size:11.5px;line-height:1.55;margin:0 0 16px}
54
+ .hint code{font-family:var(--mono);color:var(--brand);font-size:11px}
55
+ .ft{margin-top:18px;padding-top:14px;border-top:1px solid var(--line);text-align:center}
56
+ .ft a{color:var(--faint);text-decoration:none;font-size:11.5px}
57
+ .ft a:hover{color:var(--muted)}
58
+ ${ICON_CSS}
59
+ .mark .ic{width:18px;height:18px}
60
+ .btn .ic{width:16px;height:16px}
61
+ </style>
62
+ </head>
63
+ <body>
64
+ ${ICON_SPRITE}
65
+ <form class="card" id="f" autocomplete="off">
66
+ <div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span>coxpit</span></div>
67
+ <h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
68
+ <p class="sub">${setup
69
+ ? 'Set an access key. You&#39;ll enter it once per device — no accounts, no username.'
70
+ : 'Enter your access key. One key, one owner — no username.'}</p>
71
+ ${setup ? `<div class="hint">To prove you own this machine, this first-time setup needs the one-time
72
+ <code>setup token</code> printed in the daemon log &mdash; unless you&#39;re on
73
+ <code>http://127.0.0.1</code> directly. Paste it below if asked.</div>` : ''}
74
+ <div class="err" id="err"></div>
75
+ ${setup ? `<div class="fld"><label class="flabel" for="tok">setup token (from the daemon log)</label>
76
+ <input id="tok" type="password" placeholder="paste if not on localhost" autocomplete="off"></div>` : ''}
77
+ <div class="fld"><label class="flabel" for="key">access key</label>
78
+ <input id="key" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="${setup ? 'new-password' : 'current-password'}" autofocus></div>
79
+ ${setup ? `<div class="fld"><label class="flabel" for="key2">confirm access key</label>
80
+ <input id="key2" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="new-password"></div>` : ''}
81
+ <label class="row"><input id="rem" type="checkbox"${setup ? ' checked' : ''}><span>Remember this device</span></label>
82
+ <button class="btn" id="go" type="submit"><svg class="ic"><use href="#i-${setup ? 'lock' : 'unlock'}"/></svg><span>${setup ? 'Set key &amp; enter' : 'Unlock'}</span></button>
83
+ <div class="ft"><a href="https://github.com/hanmariyang/coxpit-oss#remote-access" target="_blank" rel="noopener">Fronting with Cloudflare Access / Tailscale? &rarr;</a></div>
84
+ </form>
85
+ <script>
86
+ (function(){
87
+ var SETUP = ${setup ? 'true' : 'false'};
88
+ var f = document.getElementById('f');
89
+ var err = document.getElementById('err');
90
+ var go = document.getElementById('go');
91
+ function show(m){ err.textContent = m || ''; }
92
+ f.addEventListener('submit', function(ev){
93
+ ev.preventDefault();
94
+ show('');
95
+ var key = document.getElementById('key').value;
96
+ var rem = document.getElementById('rem').checked;
97
+ if (!key){ show('access key required'); return; }
98
+ var body, url;
99
+ if (SETUP){
100
+ var key2 = document.getElementById('key2').value;
101
+ if (key !== key2){ show('keys do not match'); return; }
102
+ if (key.length < 6){ show('use at least 6 characters'); return; }
103
+ url = '/api/auth/setup';
104
+ body = { key: key, token: document.getElementById('tok').value, remember: rem };
105
+ } else {
106
+ url = '/api/auth/unlock';
107
+ body = { key: key, remember: rem };
108
+ }
109
+ go.disabled = true;
110
+ fetch(url, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) })
111
+ .then(function(r){ return r.json().then(function(j){ return { s:r.status, j:j }; }); })
112
+ .then(function(res){
113
+ if (res.s >= 200 && res.s < 300 && res.j && res.j.ok){ location.replace('/'); return; }
114
+ go.disabled = false;
115
+ var m = (res.j && (res.j.detail || res.j.error)) || ('error ' + res.s);
116
+ show(m);
117
+ })
118
+ .catch(function(){ go.disabled = false; show('network error'); });
119
+ });
120
+ })();
121
+ </script>
122
+ </body>
123
+ </html>`;
124
+ }
@@ -13,6 +13,57 @@ import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
13
13
  import { broadcast } from './hub';
14
14
  import { getProvider, type Provider } from './providers';
15
15
 
16
+ // ── 산출물 계약(deliverable contract) ─────────────────────────
17
+ /** 산출물 타입 5종 — 태스크가 선언할 수 있는 계약 항목. */
18
+ export const OUTPUT_TYPES = ['answer', 'code', 'doc', 'page', 'file'] as const;
19
+ export type OutputType = (typeof OUTPUT_TYPES)[number];
20
+ const OUTPUT_SET = new Set<string>(OUTPUT_TYPES);
21
+
22
+ /** 임의 입력 → 유효한 산출물 타입 배열(중복 제거, 순서 보존). API·저장 공용. */
23
+ export function normalizeOutputs(input: unknown): OutputType[] {
24
+ if (!Array.isArray(input)) return [];
25
+ const seen = new Set<string>();
26
+ const out: OutputType[] = [];
27
+ for (const v of input) {
28
+ if (typeof v === 'string' && OUTPUT_SET.has(v) && !seen.has(v)) {
29
+ seen.add(v);
30
+ out.push(v as OutputType);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** tasks.outputs(JSON 문자열) → 산출물 타입 배열. 파싱 실패는 빈 배열. */
37
+ export function parseOutputs(raw: string | null | undefined): OutputType[] {
38
+ if (!raw) return [];
39
+ try { return normalizeOutputs(JSON.parse(raw)); } catch { return []; }
40
+ }
41
+
42
+ /** 프롬프트에 붙는 Deliverables 블록(A3). declared 는 비어있지 않다. */
43
+ function deliverablesNote(declared: OutputType[]): string {
44
+ const human: Record<OutputType, string> = {
45
+ answer: 'an answer', code: 'code changes', doc: 'a Markdown doc',
46
+ page: 'an HTML page', file: 'a file',
47
+ };
48
+ const list = declared.map((t) => human[t]).join(', ');
49
+ return '\n\n--- COXPIT DELIVERABLES (required) ---\n' +
50
+ `Deliverables (required): ${list}.\n` +
51
+ 'Produce each as a real file in the repo (docs as .md, pages as .html). Register every ' +
52
+ 'deliverable in .coxpit/outputs.json as a JSON array of {path,type,title}. End with a final ' +
53
+ 'message stating the answer.\n' +
54
+ '--- END COXPIT DELIVERABLES ---';
55
+ }
56
+
57
+ /** 계산된 출력 카드(computeRunOutputs 반환) — 보드가 오른쪽 컬럼에 렌더. */
58
+ export interface RunOutputCard {
59
+ type: OutputType;
60
+ title: string;
61
+ path?: string;
62
+ required: boolean;
63
+ present: boolean;
64
+ meta: string;
65
+ }
66
+
16
67
  /** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
17
68
  function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
18
69
  if (real) return provider.launchCmd(prompt, model || undefined);
@@ -241,6 +292,11 @@ async function loadContext(runId: number): Promise<RunContext | null> {
241
292
  }
242
293
  }
243
294
 
295
+ // 산출물 계약 — task.outputs 가 비어있지 않으면 Deliverables 블록 주입(디자인 캡처와 같은 시임).
296
+ // launchRun/launchGroupTask 는 모두 loadContext 를 거치므로 여기서 한 번에 커버.
297
+ const declared = parseOutputs(task.outputs);
298
+ if (declared.length) prompt += deliverablesNote(declared);
299
+
244
300
  return {
245
301
  runId,
246
302
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
@@ -554,6 +610,152 @@ export async function getRunDocs(runId: number): Promise<{
554
610
  return { ok: true, docs };
555
611
  }
556
612
 
613
+ // ── computeRunOutputs — 산출물 카드 계산(A2/A4) ──────────────────
614
+ interface OutputsManifestItem { path?: string; type?: string; title?: string }
615
+
616
+ /** run 의 최종 답변 텍스트 — result 이벤트(payload JSON) 우선, 없으면 exitSummary. */
617
+ async function runAnswerText(run: typeof agentRuns.$inferSelect): Promise<string> {
618
+ const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
619
+ for (let i = evs.length - 1; i >= 0; i--) {
620
+ if (evs[i]!.kind !== 'result') continue;
621
+ try {
622
+ const o = JSON.parse(evs[i]!.payload) as { result?: string };
623
+ if (typeof o.result === 'string' && o.result.trim()) return o.result.trim();
624
+ } catch { /* 비-JSON result — exitSummary 로 폴백 */ }
625
+ }
626
+ return (run.exitSummary || '').trim();
627
+ }
628
+
629
+ /** 확장자 → 파생 카드 타입. code/file 은 group 만, 실제 타입은 code|page|doc|file. */
630
+ function extType(p: string): OutputType {
631
+ if (/\.(md|markdown)$/i.test(p)) return 'doc';
632
+ if (/\.(html?|htm)$/i.test(p)) return 'page';
633
+ if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(p)) return 'file';
634
+ return 'code';
635
+ }
636
+
637
+ /** status --porcelain 한 줄 → 상대경로(따옴표·상태코드 제거). */
638
+ function porcelainPath(line: string): string {
639
+ return line.slice(3).trim().replace(/^"|"$/g, '');
640
+ }
641
+
642
+ /**
643
+ * run 의 산출물 카드 목록을 계산(주문형, A4). 병합 순서:
644
+ * (a) worktree 의 .coxpit/outputs.json 매니페스트(있으면) — declared 딜리버러블
645
+ * (b) git status --porcelain → 확장자 분류(.md=doc·.html=page·이미지=file·그 외=code 집계 1장)
646
+ * (c) result 이벤트의 answer 텍스트
647
+ * 각 카드: required = type ∈ task.outputs. declared 인데 산출물 없으면 present:false 플레이스홀더.
648
+ * worktree 소멸 → doc/page 는 loadRunDocs 스냅샷 폴백, code/file 은 unavailable, answer 는 이벤트.
649
+ */
650
+ export async function computeRunOutputs(runId: number): Promise<RunOutputCard[]> {
651
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
652
+ const run = rr[0];
653
+ if (!run) return [];
654
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
655
+ const task = tr[0];
656
+ const declared = parseOutputs(task?.outputs);
657
+ const declaredSet = new Set<OutputType>(declared);
658
+
659
+ const ctx = await loadContext(runId);
660
+ const wt = run.worktreePath;
661
+ // worktree 가 실제로 머신에 살아있는가(정리 후에는 스냅샷 폴백).
662
+ let wtAlive = false;
663
+ if (ctx && wt) {
664
+ const t = await runShellOn(ctx.machine, `test -d ${shq(wt)} && echo yes`, 8000).catch(() => ({ stdout: '' }));
665
+ wtAlive = (t.stdout || '').includes('yes');
666
+ }
667
+
668
+ const cards: RunOutputCard[] = [];
669
+ const present = new Set<OutputType>();
670
+ const codeFiles: string[] = [];
671
+ const fileCards: RunOutputCard[] = [];
672
+ const docByPath = new Map<string, RunOutputCard>();
673
+
674
+ // (c) answer — 이벤트에서(worktree 생사 무관하게 항상 회수 가능)
675
+ const answer = await runAnswerText(run);
676
+ if (answer) {
677
+ present.add('answer');
678
+ cards.push({ type: 'answer', title: 'Final answer', required: declaredSet.has('answer'), present: true, meta: 'from the run\'s final message' });
679
+ }
680
+
681
+ // (a) 매니페스트 — path→title 힌트로 파생 카드 제목을 예쁘게(있으면). 없으면 git 분류로 폴백.
682
+ const manifestTitle = new Map<string, string>();
683
+ if (wtAlive && ctx && wt) {
684
+ const mf = await runShellOn(ctx.machine, `head -c 200000 ${shq(ppath.join(wt, '.coxpit/outputs.json'))} 2>/dev/null`, 8000)
685
+ .catch(() => ({ ok: false, stdout: '' }));
686
+ if (mf.ok && mf.stdout.trim()) {
687
+ try {
688
+ const arr = JSON.parse(mf.stdout) as unknown;
689
+ for (const it of (Array.isArray(arr) ? arr : []) as OutputsManifestItem[]) {
690
+ if (it && typeof it === 'object' && typeof it.path === 'string' && typeof it.title === 'string') {
691
+ manifestTitle.set(it.path, it.title);
692
+ }
693
+ }
694
+ } catch { /* 매니페스트 깨짐 — git 분류로 폴백 */ }
695
+ }
696
+ }
697
+
698
+ if (wtAlive && ctx && wt) {
699
+ // (b) git status --porcelain 분류
700
+ const ls = await runShellOn(ctx.machine, `git -C ${shq(wt)} status --porcelain`, 15000).catch(() => ({ ok: false, stdout: '' }));
701
+ if (ls.ok) {
702
+ const paths = ls.stdout.split('\n').map(porcelainPath).filter(Boolean);
703
+ for (const p of paths) {
704
+ if (p.startsWith('.coxpit/')) continue; // 오케스트레이션 파일은 산출물 아님
705
+ const t = extType(p);
706
+ if (t === 'doc' || t === 'page') {
707
+ present.add(t);
708
+ docByPath.set(p, { type: t, title: manifestTitle.get(p) || p, path: p, required: declaredSet.has(t), present: true, meta: t === 'doc' ? 'rendered markdown' : 'live HTML page' });
709
+ } else if (t === 'file') {
710
+ present.add('file');
711
+ fileCards.push({ type: 'file', title: manifestTitle.get(p) || p, path: p, required: declaredSet.has('file'), present: true, meta: 'file preview' });
712
+ } else {
713
+ codeFiles.push(p);
714
+ }
715
+ }
716
+ }
717
+ } else {
718
+ // worktree 소멸 — doc/page 는 스냅샷 폴백, code/file 은 unavailable.
719
+ const snap = await loadRunDocs(runId);
720
+ for (const d of snap.docs) {
721
+ const t: OutputType = d.kind === 'html' ? 'page' : 'doc';
722
+ present.add(t);
723
+ docByPath.set(d.path, { type: t, title: manifestTitle.get(d.path) || d.path, path: d.path, required: declaredSet.has(t), present: true, meta: 'worktree cleaned — snapshot only' });
724
+ }
725
+ if (run.filesChanged > 0) {
726
+ // 변경은 있었으나 worktree 가 사라져 code/file diff 를 못 준다.
727
+ if (declaredSet.has('code')) cards.push({ type: 'code', title: 'Code changes', required: true, present: false, meta: 'worktree cleaned — diff unavailable' });
728
+ if (declaredSet.has('file')) cards.push({ type: 'file', title: 'File', required: true, present: false, meta: 'worktree cleaned — file unavailable' });
729
+ }
730
+ }
731
+
732
+ // doc/page 카드 편입(경로 순)
733
+ for (const c of docByPath.values()) cards.push(c);
734
+ // file 카드 편입
735
+ for (const c of fileCards) cards.push(c);
736
+ // code — 여러 파일을 한 장의 diff 카드로 집계(worktree 라이브일 때만 생성됨)
737
+ if (codeFiles.length) {
738
+ present.add('code');
739
+ cards.push({
740
+ type: 'code',
741
+ title: 'Code changes',
742
+ required: declaredSet.has('code'),
743
+ present: true,
744
+ meta: `${codeFiles.length} file(s) — colored diff`,
745
+ });
746
+ }
747
+
748
+ // declared 인데 산출물이 없으면 ⚠ present:false 플레이스홀더(soft policy).
749
+ // future: strict mode — auto-steer "produce the missing <type>"
750
+ for (const t of declared) {
751
+ if (present.has(t)) continue;
752
+ if (cards.some((c) => c.type === t && c.present === false)) continue; // worktree-gone 폴백이 이미 추가
753
+ cards.push({ type: t, title: `Missing ${t}`, required: true, present: false, meta: '산출물 미충족' });
754
+ }
755
+
756
+ return cards;
757
+ }
758
+
557
759
  /**
558
760
  * base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
559
761
  * 충돌 시 자동 abort — 그땐 steer 로 에이전트에게 머지를 맡기라고 안내.
@@ -1109,5 +1311,120 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
1109
1311
  ` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(run.branch)} 2>&1 || true`,
1110
1312
  20000,
1111
1313
  );
1314
+ // 세션·worktree 는 이제 없다 — 스테일 포인터를 비운다. 안 그러면 getRunTermInfo 가
1315
+ // 죽은 tmux 이름을 계속 돌려줘 /ws/term/:id 가 없는 세션에 attach 를 시도한다(closed task 버그).
1316
+ await setRun(runId, { worktreePath: '', tmuxWindow: '' });
1112
1317
  return { ok: true, detail: rm.stdout.trim().slice(0, 300) };
1113
1318
  }
1319
+
1320
+ // ── 고아 worktree 회수(reclaim) ────────────────────────────────
1321
+ // run worktree 는 종종 node_modules 를 품어 ~180MB 의 디스크 빚이 된다. Close 는
1322
+ // cleanupRun 으로 이미 정리하지만, 실패·에러·데몬 재시작으로 고아가 된 run 은
1323
+ // (검수용으로) worktree 를 남겨두므로 쌓인다. 이를 안전하게 되찾는 길.
1324
+ //
1325
+ // 안전 규칙(핵심): running/preparing/pending/'done' run 은 절대 대상 아님 —
1326
+ // 활성 작업이거나(진행 중), 성공했지만 아직 머지 안 됐을 수 있는 작업이므로.
1327
+
1328
+ /** 회수 대상 판정용 안전 상태 집합 — task 가 closed 이거나 run 상태가 이 중 하나. */
1329
+ const RECLAIM_STATUSES = new Set(['failed', 'error', 'stopped']);
1330
+
1331
+ export interface ReclaimableWorktree {
1332
+ runId: number;
1333
+ path: string;
1334
+ branch: string;
1335
+ taskId: number;
1336
+ reason: string; // 'task closed' | 'failed' | 'error' | 'stopped'
1337
+ exists: boolean; // worktree dir 가 아직 디스크에 있나(false = 이미 수동 삭제됨)
1338
+ sizeKb?: number; // best-effort du -sk (실패 시 생략)
1339
+ }
1340
+
1341
+ /**
1342
+ * 안전하게 회수 가능한 worktree 목록. worktreePath 가 비어있지 않은 모든 run 중
1343
+ * (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 인 것만.
1344
+ * running/preparing/pending/'done'/'open'/'merged' 는 절대 포함하지 않는다
1345
+ * (활성 또는 성공-미머지 가능성). exists=디스크 잔존 여부, sizeKb=best-effort du.
1346
+ */
1347
+ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]> {
1348
+ const allRuns = (await db.select().from(agentRuns)).filter((r) => !!r.worktreePath);
1349
+ // task 상태 룩업(closed 판정용)
1350
+ const taskById = new Map<number, typeof tasks.$inferSelect>();
1351
+ for (const t of await db.select().from(tasks)) taskById.set(t.id, t);
1352
+
1353
+ const out: ReclaimableWorktree[] = [];
1354
+ for (const run of allRuns) {
1355
+ if (isRunLive(run.id)) continue; // 라이브 자식 보유 = 실행 중, 절대 건드리지 않음
1356
+ const task = taskById.get(run.taskId);
1357
+ const taskClosed = task?.status === 'closed';
1358
+ const statusReclaim = RECLAIM_STATUSES.has(run.status);
1359
+ if (!taskClosed && !statusReclaim) continue; // done/open/merged/running/preparing/pending 제외
1360
+ const reason = taskClosed ? 'task closed' : run.status;
1361
+
1362
+ // worktree 잔존 여부 + best-effort 사이즈(로컬만 정확; 원격은 machine 경유).
1363
+ const ctx = await loadContext(run.id).catch(() => null);
1364
+ let exists = false;
1365
+ let sizeKb: number | undefined;
1366
+ if (ctx) {
1367
+ const chk = await runShellOn(ctx.machine, `test -d ${shq(run.worktreePath)} && echo yes`, 8000)
1368
+ .catch(() => ({ stdout: '' as string }));
1369
+ exists = (chk.stdout || '').includes('yes');
1370
+ if (exists) {
1371
+ // du 는 큰 트리에서 느릴 수 있어 타임아웃으로 가드 — 실패해도 목록은 낸다.
1372
+ const du = await runShellOn(ctx.machine, `du -sk ${shq(run.worktreePath)} 2>/dev/null | cut -f1`, 8000)
1373
+ .catch(() => ({ ok: false as boolean, stdout: '' as string }));
1374
+ const n = parseInt((du.stdout || '').trim(), 10);
1375
+ if (du.ok && Number.isFinite(n) && n > 0) sizeKb = n;
1376
+ }
1377
+ }
1378
+ out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb });
1379
+ }
1380
+ return out;
1381
+ }
1382
+
1383
+ /**
1384
+ * 회수 실행 — 대상 run(전체 또는 runIds 부분집합)의 worktree 를 되찾는다.
1385
+ * dir 가 아직 있으면 cleanupRun 재사용(tmux kill + git worktree remove + branch -D + 포인터 blank).
1386
+ * dir 가 이미 수동 삭제됐으면 git worktree prune + branch -D + DB 포인터 blank 만.
1387
+ * 마지막에 영향받은 repo 마다 git worktree prune 1회(스테일 메타데이터 정리).
1388
+ * 멱등 — 다시 돌려도 안전(이미 회수된 run 은 worktreePath 가 비어 목록에서 빠짐).
1389
+ */
1390
+ export async function pruneWorktrees(runIds?: number[]): Promise<{
1391
+ removed: Array<{ runId: number; detail: string }>; count: number;
1392
+ }> {
1393
+ const reclaimable = await listReclaimableWorktrees();
1394
+ const want = runIds && runIds.length ? new Set(runIds) : null;
1395
+ const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable;
1396
+
1397
+ const removed: Array<{ runId: number; detail: string }> = [];
1398
+ const affectedRepoPaths = new Map<string, MachineTarget>(); // repoPath -> machine (prune 대상)
1399
+
1400
+ for (const t of targets) {
1401
+ const ctx = await loadContext(t.runId).catch(() => null);
1402
+ if (ctx) affectedRepoPaths.set(ctx.repoPath, ctx.machine);
1403
+ if (t.exists) {
1404
+ // dir 잔존 — cleanupRun 이 tmux·worktree remove --force·branch -D·포인터 blank 를 모두 처리.
1405
+ const res = await cleanupRun(t.runId).catch((e) => ({ ok: false, detail: String(e).slice(0, 200) }));
1406
+ removed.push({ runId: t.runId, detail: res.detail || (res.ok ? 'removed' : 'cleanup failed') });
1407
+ } else if (ctx) {
1408
+ // dir 는 이미 수동 삭제 — git 이 여전히 스테일 worktree 를 물고 있다. prune + branch -D + 포인터 blank.
1409
+ const r = await runShellOn(
1410
+ ctx.machine,
1411
+ `git -C ${shq(ctx.repoPath)} worktree prune 2>&1` +
1412
+ `${t.branch ? ` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(t.branch)} 2>&1 || true` : ''}`,
1413
+ 20000,
1414
+ ).catch(() => ({ stdout: '' as string }));
1415
+ await setRun(t.runId, { worktreePath: '', tmuxWindow: '' });
1416
+ removed.push({ runId: t.runId, detail: (r.stdout || '').trim().slice(0, 200) || 'dir already gone — pruned metadata' });
1417
+ } else {
1418
+ // context 없음(repo/machine 소실) — 최소한 DB 포인터라도 비운다.
1419
+ await setRun(t.runId, { worktreePath: '', tmuxWindow: '' });
1420
+ removed.push({ runId: t.runId, detail: 'context missing — cleared DB pointer' });
1421
+ }
1422
+ }
1423
+
1424
+ // repo 마다 worktree prune 1회 — 스테일 메타데이터를 확실히 청소(멱등).
1425
+ for (const [repoPath, machine] of affectedRepoPaths) {
1426
+ await runShellOn(machine, `git -C ${shq(repoPath)} worktree prune 2>/dev/null || true`, 15000).catch(() => { /* best-effort */ });
1427
+ }
1428
+
1429
+ return { removed, count: removed.length };
1430
+ }