coxpit 2.9.0 → 3.0.1

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 CHANGED
@@ -71,6 +71,7 @@ Your keys and login never touch coxpit's config or database.
71
71
  | `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
72
72
  | `COXPIT_AGENT_BIN` | `claude` | agent command |
73
73
  | `COXPIT_AGENT_PERM` | `acceptEdits` | headless permission mode passed to the agent |
74
+ | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
74
75
 
75
76
  Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep basic auth on — it exposes shells.
76
77
 
@@ -88,7 +89,7 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
88
89
 
89
90
  ## Status
90
91
 
91
- `v2.0` — agent fleet (P1), compare/review + terminal (P2), and Design Mode (P3) are implemented and tested. Roadmap: richer review tooling, more agent providers, packaging (Docker/npm).
92
+ `v3.0` — fleet, compare/merge, terminal, Design Mode, run destinations (merge · export · PR), swarm (plan fan-out + integrate with conflict-resolving agents), AI review, and sessions (work/ask + notifications) — all shipped and e2e-tested. Roadmap: ROADMAP.md.
92
93
 
93
94
  ## License
94
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "2.9.0",
3
+ "version": "3.0.1",
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/board.ts CHANGED
@@ -292,6 +292,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
292
292
  <header>
293
293
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
294
294
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
295
+ <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
295
296
  <div class="machines" id="machines"></div>
296
297
  </header>
297
298
  <div class="layout">
@@ -382,14 +383,19 @@ export const BOARD_HTML = /* html */ `<!doctype html>
382
383
  </div>
383
384
  </div>
384
385
  <div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
385
- <input id="steerInput" placeholder="Send follow-up instructions — continues in the same session &amp; worktree…" style="flex:1" />
386
- <button class="btn sm" id="steerSend">Steer</button>
386
+ <div class="seg" style="flex:0 0 132px" id="steerModeSeg">
387
+ <button type="button" class="seg-opt on" data-mode="work">Work</button>
388
+ <button type="button" class="seg-opt" data-mode="ask">Ask</button>
389
+ </div>
390
+ <input id="steerInput" placeholder="Next instruction — same session &amp; worktree…" style="flex:1" />
391
+ <button class="btn sm" id="steerSend">Send</button>
387
392
  </div>
388
393
  <div class="modal-f">
389
394
  <button class="btn-ghost sm" id="mTerm">Terminal</button>
390
395
  <button class="btn-ghost sm" id="mRefreshDiff">Refresh diff</button>
391
396
  <button class="btn-ghost sm" id="mCompare">Compare runs</button>
392
397
  <button class="btn-ghost sm" id="mExport">Export files…</button>
398
+ <button class="btn-ghost sm" id="mSync">Sync base</button>
393
399
  <span class="spacer"></span>
394
400
  <button class="btn-danger sm" id="mStop">Stop</button>
395
401
  <button class="btn-ghost sm" id="mCleanup">Cleanup</button>
@@ -597,12 +603,18 @@ function humanize(e){
597
603
  const kind = e.kind, payload = e.payload;
598
604
  if (kind === 'rate_limit_event') return null;
599
605
  if (kind === 'steer') return { k:'steer', t:'→ '+payload };
606
+ if (kind === 'ask') return { k:'ask', t:'? '+payload };
607
+ if (kind === 'sync') return { k:'sync', t:payload };
600
608
  if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
601
609
  if (kind === 'pr') return { k:'pr', t:payload };
602
610
  if (kind === 'stderr') return { k:'stderr', t:payload };
603
611
  try{
604
612
  const o = JSON.parse(payload);
605
- if (o.type === 'system') return { k:'session', t:'started · '+(o.model||o.subtype||'') };
613
+ if (o.type === 'system'){
614
+ if (o.subtype === 'init' || !o.subtype) return { k:'session', t:'started'+(o.model?' · '+o.model:'') };
615
+ if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
616
+ return null; // thinking_tokens 등 스트림 잡음
617
+ }
606
618
  if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
607
619
  if (o.type === 'assistant' && o.message){
608
620
  const parts = [];
@@ -620,7 +632,17 @@ function humanize(e){
620
632
  if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
621
633
  if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
622
634
  return { k:kind, t:payload.slice(0,140) };
623
- }catch{ return { k:kind, t:payload }; }
635
+ }catch{
636
+ // 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
637
+ // text 조각만 구제하고, 없으면 생략.
638
+ if (payload.trim().startsWith('{')){
639
+ const texts = [];
640
+ const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
641
+ while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
642
+ return texts.length ? { k:'said', t:texts.join(' · ') } : null;
643
+ }
644
+ return { k:kind, t:payload };
645
+ }
624
646
  }
625
647
  function humanLines(events){
626
648
  const out = [];
@@ -786,6 +808,33 @@ $('repos').addEventListener('click', async (e)=>{
786
808
  else toast('remove: '+(j.detail||res.status), 'error');
787
809
  });
788
810
 
811
+ /* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
812
+ let notifyOn = false;
813
+ try { notifyOn = localStorage.getItem('coxpit.notify') === '1' && Notification.permission === 'granted'; } catch {}
814
+ function paintBell(){ $('bell').textContent = notifyOn ? '🔔' : '🔕'; }
815
+ $('bell').addEventListener('click', async ()=>{
816
+ if (!('Notification' in window)){ toast('this browser has no notification support', 'error'); return; }
817
+ if (!notifyOn){
818
+ const perm = await Notification.requestPermission();
819
+ if (perm !== 'granted'){ toast('notification permission denied', 'error'); return; }
820
+ notifyOn = true;
821
+ } else notifyOn = false;
822
+ try { localStorage.setItem('coxpit.notify', notifyOn ? '1' : '0'); } catch {}
823
+ paintBell();
824
+ toast(notifyOn ? 'will notify when runs settle' : 'notifications off', 'ok');
825
+ });
826
+ paintBell();
827
+ function notifySettleUI(ev){
828
+ if (!notifyOn) return;
829
+ const t = tasks.get(ev.taskId ?? (runs.get(ev.runId)||{}).taskId);
830
+ try {
831
+ new Notification('coxpit · r'+ev.runId+' '+ev.status, {
832
+ body: (t ? t.title+' — ' : '') + (ev.filesChanged??0)+' file(s) changed',
833
+ tag: 'coxpit-r'+ev.runId,
834
+ });
835
+ } catch {}
836
+ }
837
+
789
838
  function connectWS(){
790
839
  const proto = location.protocol==='https:'?'wss':'ws';
791
840
  const ws = new WebSocket(proto+'://'+location.host+'/ws');
@@ -801,6 +850,7 @@ function connectWS(){
801
850
  render(); flash(ev.runId ?? ev.id); paintModal();
802
851
  if (openRunId===(ev.runId??ev.id) && ['done','failed','error','stopped'].includes(ev.status)) loadDiff();
803
852
  if (cmpTaskId!=null && ['done','failed','error','stopped','merged'].includes(ev.status)) paintCompare();
853
+ if (['done','failed','error','stopped'].includes(ev.status)) notifySettleUI(ev);
804
854
  } else if (ev.type==='event'){
805
855
  const r = runs.get(ev.runId); if(!r){ hydrate(); return; }
806
856
  r.events = r.events||[]; r.events.push({ kind:ev.kind, payload:ev.payload });
@@ -913,14 +963,34 @@ $('expOk').addEventListener('click', doExport);
913
963
  $('expDest').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){ e.preventDefault(); doExport(); } });
914
964
  $('expCancel').addEventListener('click', ()=>$('expOverlay').classList.remove('open'));
915
965
  $('expOverlay').addEventListener('click',(e)=>{ if(e.target===$('expOverlay')) $('expOverlay').classList.remove('open'); });
966
+ let steerMode = 'work';
967
+ document.querySelectorAll('#steerModeSeg .seg-opt').forEach(b=>{
968
+ b.addEventListener('click', ()=>{
969
+ steerMode = b.dataset.mode;
970
+ document.querySelectorAll('#steerModeSeg .seg-opt').forEach(x=>x.classList.toggle('on', x===b));
971
+ $('steerInput').placeholder = steerMode==='ask'
972
+ ? 'Ask the session — status, decisions, anything (no file changes)…'
973
+ : 'Next instruction — same session & worktree…';
974
+ });
975
+ });
916
976
  async function sendSteer(){
917
977
  if (openRunId==null) return;
918
978
  const msg = $('steerInput').value.trim(); if(!msg) return;
919
979
  const res = await fetch('/api/runs/'+openRunId+'/steer',{method:'POST',
920
- headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg})});
921
- if (res.ok){ $('steerInput').value=''; toast('steering — the agent resumes in its worktree', 'ok'); }
980
+ headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg, mode:steerMode})});
981
+ if (res.ok){
982
+ $('steerInput').value='';
983
+ toast(steerMode==='ask' ? 'asking — answer lands in the timeline' : 'working — same session & worktree', 'ok');
984
+ }
922
985
  else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
923
986
  }
987
+ $('mSync').addEventListener('click', async ()=>{
988
+ if (openRunId==null) return;
989
+ const res = await fetch('/api/runs/'+openRunId+'/sync',{method:'POST'});
990
+ const j = await res.json().catch(()=>({}));
991
+ if (res.ok){ toast('base merged into session worktree', 'ok'); loadDiff(); }
992
+ else toast('sync: '+(j.detail||res.status), 'error');
993
+ });
924
994
  $('steerSend').addEventListener('click', sendSteer);
925
995
  $('steerInput').addEventListener('keydown',(e)=>{ if(e.key==='Enter') sendSteer(); });
926
996
  $('mStop').addEventListener('click', async ()=>{
package/src/config.ts CHANGED
@@ -21,6 +21,8 @@ export const config = {
21
21
  dbPath: process.env.COXPIT_DB ?? './coxpit.db',
22
22
  // 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
23
23
  sshKey: process.env.COXPIT_SSH_KEY ?? '',
24
+ // run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
25
+ webhookUrl: process.env.COXPIT_WEBHOOK_URL ?? '',
24
26
  agent: {
25
27
  // 기본 드라이런(모의 에이전트). 실제 CLI 실행은 켤 때만(크레딧 소모).
26
28
  real: process.env.COXPIT_AGENT_REAL === '1',
@@ -155,8 +155,12 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
155
155
  const s = line.trim();
156
156
  if (!s) return;
157
157
  let kind = 'log';
158
+ let stored = s;
158
159
  try {
159
- const obj = JSON.parse(s) as { type?: string; result?: string; session_id?: string };
160
+ const obj = JSON.parse(s) as {
161
+ type?: string; subtype?: string; model?: string; result?: string; session_id?: string;
162
+ message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: Record<string, unknown> }> };
163
+ };
160
164
  if (obj.type) kind = obj.type;
161
165
  // steer(--resume) 용 세션 키 캡처
162
166
  if (obj.type === 'system' && typeof obj.session_id === 'string') {
@@ -164,8 +168,28 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
164
168
  }
165
169
  // result 이벤트의 사람이 읽는 요약만 뽑아 둔다(없으면 원본 라인).
166
170
  if (obj.type === 'result') lastResult = typeof obj.result === 'string' ? obj.result : s;
167
- } catch { /* 비-JSON 로그 라인 */ }
168
- void recordEvent(runId, kind, s.slice(0, 2000));
171
+ // 2000자 초과 이벤트는 자르면 JSON 깨져 잔해가 화면에 노출된다 —
172
+ // 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
173
+ if (s.length > 2000) {
174
+ if (obj.type === 'assistant' && obj.message) {
175
+ const content = (obj.message.content ?? [])
176
+ .filter((c) => c.type === 'text' || c.type === 'tool_use')
177
+ .map((c) => c.type === 'text'
178
+ ? { type: 'text', text: (c.text ?? '').slice(0, 600) }
179
+ : { type: 'tool_use', name: c.name, input: compactInput(c.input) });
180
+ stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
181
+ } else if (obj.type === 'user') {
182
+ stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
183
+ } else if (obj.type === 'system') {
184
+ stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
185
+ } else if (obj.type === 'result') {
186
+ stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
187
+ } else {
188
+ stored = s.slice(0, 2000);
189
+ }
190
+ }
191
+ } catch { stored = s.slice(0, 2000); /* 비-JSON 로그 라인 */ }
192
+ void recordEvent(runId, kind, stored.slice(0, 2000));
169
193
  });
170
194
  }
171
195
  if (child.stderr) {
@@ -186,19 +210,45 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
186
210
  const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
187
211
 
188
212
  const wasStopped = stoppedRuns.delete(runId);
189
- await setRun(runId, {
190
- status: wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed',
191
- endedAt: new Date(),
192
- filesChanged,
193
- exitSummary: wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`,
194
- });
213
+ const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
214
+ const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
215
+ await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
216
+ void notifySettle(runId, status, filesChanged, exitSummary);
217
+ }
218
+
219
+ /** tool_use input 을 표시용 핵심 필드만 남긴다(이벤트 압축용). */
220
+ function compactInput(input?: Record<string, unknown>): Record<string, string> {
221
+ const out: Record<string, string> = {};
222
+ if (!input) return out;
223
+ for (const k of ['file_path', 'command', 'path', 'pattern', 'url']) {
224
+ if (typeof input[k] === 'string') out[k] = (input[k] as string).slice(0, 200);
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /** run 정착 웹훅(선택) — COXPIT_WEBHOOK_URL 로 JSON POST. 실패는 무해. */
230
+ async function notifySettle(runId: number, status: string, filesChanged: number, exitSummary: string): Promise<void> {
231
+ if (!config.webhookUrl) return;
232
+ try {
233
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
234
+ const tr = rr[0] ? await db.select().from(tasks).where(eq(tasks.id, rr[0].taskId)).limit(1) : [];
235
+ await fetch(config.webhookUrl, {
236
+ method: 'POST',
237
+ headers: { 'content-type': 'application/json' },
238
+ body: JSON.stringify({
239
+ event: 'run.settled',
240
+ run: { id: runId, status, filesChanged, exitSummary: exitSummary.slice(0, 300), task: tr[0]?.title ?? '' },
241
+ }),
242
+ signal: AbortSignal.timeout(8000),
243
+ });
244
+ } catch { /* 웹훅 실패는 조용히 */ }
195
245
  }
196
246
 
197
247
  /**
198
248
  * 후속 지시(steer) — 정착한 run 의 세션을 --resume 으로 이어 같은 worktree 에서 계속.
199
249
  * fire-and-forget. 진행 중 run 은 거부(개입은 터미널로).
200
250
  */
201
- export async function steerRun(runId: number, message: string): Promise<{ ok: boolean; detail: string }> {
251
+ export async function steerRun(runId: number, message: string, mode: 'work' | 'ask' = 'work'): Promise<{ ok: boolean; detail: string }> {
202
252
  const ctx = await loadContext(runId);
203
253
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
204
254
  const run = rr[0];
@@ -213,11 +263,16 @@ export async function steerRun(runId: number, message: string): Promise<{ ok: bo
213
263
  if (!exists.stdout.includes('yes')) return { ok: false, detail: 'worktree missing on machine' };
214
264
 
215
265
  await setRun(runId, { status: 'running', endedAt: null });
216
- await recordEvent(runId, 'steer', message.slice(0, 2000));
266
+ await recordEvent(runId, mode === 'ask' ? 'ask' : 'steer', message.slice(0, 2000));
267
+
268
+ // Ask 모드 — 세션에 질문만: 파일 수정 없이 답변만 하도록 래핑
269
+ const finalMessage = mode === 'ask'
270
+ ? `Question about your work in this session (do NOT modify any files, do NOT run write commands — answer concisely):\n${message}`
271
+ : message;
217
272
 
218
273
  const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
219
274
  const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
220
- const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(message)}` +
275
+ const resume = `${config.agent.bin} -p --resume ${shq(run.sessionId)} ${shq(finalMessage)}` +
221
276
  ` --output-format stream-json --verbose --permission-mode ${config.agent.perm}`;
222
277
  const cmd = `cd ${shq(wt)} && ${pidPrefix}{ ${resume}; }`;
223
278
  void runAgentChild(runId, ctx.machine, wt, cmd);
@@ -285,6 +340,31 @@ export async function getRunDiff(runId: number): Promise<{ ok: boolean; diff: st
285
340
  return { ok: true, stat: stat.trim(), diff: diff.slice(0, 200_000) };
286
341
  }
287
342
 
343
+ /**
344
+ * base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
345
+ * 충돌 시 자동 abort — 그땐 steer 로 에이전트에게 머지를 맡기라고 안내.
346
+ */
347
+ export async function syncRun(runId: number): Promise<{ ok: boolean; detail: string; conflict?: boolean }> {
348
+ const ctx = await loadContext(runId);
349
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
350
+ const run = rr[0];
351
+ if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
352
+ if (liveChildren.has(runId)) return { ok: false, detail: 'still running — wait for it to settle' };
353
+ const wt = shq(run.worktreePath);
354
+ const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
355
+ // 미커밋 변경 먼저 커밋(머지 가능 상태로)
356
+ const c1 = await runShellOn(ctx.machine,
357
+ `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: checkpoint before base sync`)})`, 20000);
358
+ if (!c1.ok) return { ok: false, detail: 'checkpoint commit failed' };
359
+ const mg = await runShellOn(ctx.machine,
360
+ `cd ${wt} && git ${ident} -c commit.gpgsign=false merge --no-edit ${shq(ctx.baseBranch)} 2>&1 || (git merge --abort 2>/dev/null; echo COXPIT_SYNC_CONFLICT)`, 30000);
361
+ if (mg.stdout.includes('COXPIT_SYNC_CONFLICT')) {
362
+ return { ok: false, conflict: true, detail: `conflict with ${ctx.baseBranch} — steer the agent: "merge ${ctx.baseBranch} and resolve conflicts"` };
363
+ }
364
+ await recordEvent(runId, 'sync', `merged ${ctx.baseBranch} into session worktree`);
365
+ return { ok: true, detail: mg.stdout.trim().split('\n').slice(-1)[0] ?? 'synced' };
366
+ }
367
+
288
368
  /**
289
369
  * 승자 run 머지 — worktree 미커밋 변경을 자동 커밋 후 run 브랜치를
290
370
  * repo 기본 브랜치에 merge. 본 repo 가 기본 브랜치+클린일 때만, 충돌 시 abort.
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import { db } from './db';
12
12
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures } from './db/schema';
13
13
  import { BOOKMARKLET_JS } from './design';
14
14
  import { runShellOn, shq } from './exec';
15
- import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask } from './orchestrator';
15
+ import { launchRun, cleanupRun, stopRun, getRunDiff, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun } from './orchestrator';
16
16
  import { openTerm } from './term';
17
17
  import { addSink, removeSink, broadcast } from './hub';
18
18
  import { BOARD_HTML } from './board';
@@ -32,7 +32,7 @@ export async function buildServer(): Promise<FastifyInstance> {
32
32
  app.addHook('onRequest', authGate);
33
33
 
34
34
  // 무인증 헬스(외부 감시용)
35
- app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '2.9.0' }));
35
+ app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: '3.0.1' }));
36
36
 
37
37
  // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
38
38
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
@@ -402,16 +402,26 @@ export async function buildServer(): Promise<FastifyInstance> {
402
402
  // 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
403
403
  app.post('/api/runs/:id/steer', async (req, reply) => {
404
404
  const id = Number((req.params as { id: string }).id);
405
- const b = (req.body ?? {}) as { message?: string };
405
+ const b = (req.body ?? {}) as { message?: string; mode?: string };
406
406
  const message = (b.message ?? '').trim();
407
407
  if (!message) return reply.code(400).send({ error: 'message required' });
408
408
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
409
409
  if (!rr[0]) return reply.code(404).send({ error: 'not found' });
410
- const res = await steerRun(id, message);
410
+ const res = await steerRun(id, message, b.mode === 'ask' ? 'ask' : 'work');
411
411
  if (!res.ok) return reply.code(409).send(res);
412
412
  return reply.code(202).send(res);
413
413
  });
414
414
 
415
+ // base 동기화 — 오래 사는 세션 worktree 에 base 최신 머지.
416
+ app.post('/api/runs/:id/sync', async (req, reply) => {
417
+ const id = Number((req.params as { id: string }).id);
418
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
419
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
420
+ const res = await syncRun(id);
421
+ if (!res.ok) return reply.code(409).send(res);
422
+ return res;
423
+ });
424
+
415
425
  // Plan fan-out — 목표 하나 → 플래너가 태스크 분해 → 전부 자동 발사.
416
426
  // (real 플래너는 repo 를 읽고 계획하느라 1~3분 걸릴 수 있음 — 클라이언트는 대기)
417
427
  app.post('/api/plan', async (req, reply) => {
@@ -481,7 +491,7 @@ export async function buildServer(): Promise<FastifyInstance> {
481
491
  // 라이브 스트림 좌석 — 오케스트레이터가 run/event 를 여기로 broadcast.
482
492
  app.get('/ws', { websocket: true }, (socket) => {
483
493
  addSink(socket);
484
- socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '2.9.0' }));
494
+ socket.send(JSON.stringify({ type: 'hello', name: 'coxpit-fleet', version: '3.0.1' }));
485
495
  socket.on('close', () => removeSink(socket));
486
496
  });
487
497