coxpit 5.10.0 → 5.11.0

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