coxpit 5.18.0 → 5.21.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 CHANGED
@@ -72,6 +72,23 @@ Your keys and login never touch coxpit's config or database.
72
72
 
73
73
  > **New here?** The **[full guide](docs/GUIDE.md)** ([한국어](docs/GUIDE.ko.md)) walks through starting a new project, your first fleet, comparing and merging, doc mode, the terminal, remote access, and every feature — task by task, with GIFs.
74
74
 
75
+ ## Dispatch from the terminal
76
+
77
+ The daemon isn't only a web board — you can throw work at a project straight from a shell. Handy when your main workspace session is a terminal and you just want to fan agents at a repo without leaving it. You name the target project each time (no sticky default — the target stays visible in the command you typed):
78
+
79
+ ```console
80
+ $ coxpit ls # projects + active runs
81
+ triforge 2 running
82
+ keeping idle
83
+ $ coxpit fan triforge "add retry to the uploader" -n 2
84
+ ▶ triforge · 2 runs (dry) · r58 r59
85
+ $ coxpit ps # what's running
86
+ $ coxpit steer r58 "log the retries too"
87
+ $ coxpit add /path/to/repo # register a repo as a project
88
+ ```
89
+
90
+ Runs land in the project like any board-launched run (isolated worktree + branch, reviewable in the board/cockpit Review). Default is a **dry rehearsal** — add `--real` to spend credits (`-n` sets parallelism 1–8, `--agent`/`--model` override). The CLI finds the daemon via its lock file; if auth is on, set `COXPIT_KEY` (or `COXPIT_AUTH_PASS`) to your access key.
91
+
75
92
  ## Configuration
76
93
 
77
94
  | env | default | what |
package/bin/coxpit.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { readFileSync, existsSync, rmSync } from 'node:fs';
10
+ import http from 'node:http';
10
11
 
11
12
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
12
13
  const entry = join(root, 'src', 'index.ts');
@@ -28,6 +29,18 @@ Usage:
28
29
  coxpit --version, -v print version
29
30
  coxpit --help, -h show this help
30
31
 
32
+ Dispatch orchestration to a project (from your workspace terminal):
33
+ coxpit ls list projects + active runs
34
+ coxpit fan <project> "<goal>" [opts] launch agent runs on <project>
35
+ -n N number of parallel runs (1-8, default 1)
36
+ --real run the real agent (spends credits; default is dry rehearsal)
37
+ --agent X claude-code | codex --model M model override
38
+ coxpit ps list active runs
39
+ coxpit steer <run> "<message>" send follow-up to a running agent
40
+ coxpit add <path> register a git repo as a project
41
+ The target is always named per command (no sticky default). Talks to the local
42
+ daemon via its lock file; set COXPIT_KEY (or COXPIT_AUTH_PASS) if auth is on.
43
+
31
44
  Access-key auth:
32
45
  On first boot with no key, open the board to set an access key (a one-time
33
46
  setup token is printed to the log — needed unless you visit http://127.0.0.1
@@ -65,13 +78,129 @@ if (args[0] === 'reset-key') {
65
78
  process.exit(0);
66
79
  }
67
80
 
68
- // --import bare 'tsx' 사용자 cwd 기준으로 해석돼 npx 실행에서 깨진다 —
69
- // 패키지 루트 기준으로 절대경로 해석해 넘긴다.
70
- const require_ = createRequire(join(root, 'package.json'));
71
- const tsxEntry = pathToFileURL(require_.resolve('tsx')).href;
81
+ // ── Orchestration from the terminal dispatch to the local daemon ──────────
82
+ // The session is your workspace command post; you name the target project per
83
+ // command (no sticky default — the target is always visible in what you typed).
84
+ // Reuses the same API the board uses; loopback is still key-gated, so we auth
85
+ // with COXPIT_KEY / COXPIT_AUTH_PASS when the daemon requires it.
86
+ const SUBCMDS = new Set(['ls', 'projects', 'fan', 'run', 'ps', 'steer', 'add']);
87
+ if (SUBCMDS.has(args[0])) {
88
+ const dbEnv = process.env.COXPIT_DB;
89
+ const dir = dbEnv ? dirname(resolve(dbEnv)) : join(homedir(), '.coxpit');
90
+ const lockPath = join(dir, 'daemon.lock.json');
91
+
92
+ const die = (msg) => { console.error('coxpit: ' + msg); process.exit(1); };
93
+ let port;
94
+ try {
95
+ const lk = JSON.parse(readFileSync(lockPath, 'utf8'));
96
+ if (!Number.isInteger(lk?.port)) throw new Error('no port');
97
+ port = lk.port;
98
+ } catch {
99
+ die('no running daemon (lock not found at ' + lockPath + ') — start it with "coxpit".');
100
+ }
101
+ const key = process.env.COXPIT_KEY || process.env.COXPIT_AUTH_PASS || '';
102
+ const authHeaders = key ? { authorization: 'Basic ' + Buffer.from('coxpit:' + key).toString('base64') } : {};
103
+
104
+ const api = (method, path, body) => new Promise((res, rej) => {
105
+ const data = body != null ? JSON.stringify(body) : null;
106
+ const req = http.request(
107
+ { host: '127.0.0.1', port, path, method, headers: { 'content-type': 'application/json', ...authHeaders, ...(data ? { 'content-length': Buffer.byteLength(data) } : {}) } },
108
+ (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => { let j = null; try { j = b ? JSON.parse(b) : null; } catch { /* non-json */ } res({ status: r.statusCode, json: j, raw: b }); }); },
109
+ );
110
+ req.on('error', rej); if (data) req.write(data); req.end();
111
+ });
112
+ const guard = (r) => {
113
+ if (r.status === 401) die('daemon requires an access key — set COXPIT_KEY (or COXPIT_AUTH_PASS) to your key.');
114
+ if (r.status >= 400) die((r.json && r.json.error) || ('HTTP ' + r.status));
115
+ return r;
116
+ };
117
+ const num = (flag, def) => { const i = args.indexOf(flag); return i >= 0 && args[i + 1] != null ? Number(args[i + 1]) : def; };
118
+ const str = (flag, def) => { const i = args.indexOf(flag); return i >= 0 && args[i + 1] != null ? args[i + 1] : def; };
119
+ const has = (flag) => args.includes(flag);
120
+ // positional args = everything that isn't a flag or a flag's value
121
+ const positionals = () => {
122
+ const flagsWithVal = new Set(['-n', '--count', '--agent', '--model']);
123
+ const out = []; for (let i = 1; i < args.length; i++) { const a = args[i]; if (a.startsWith('-')) { if (flagsWithVal.has(a)) i++; continue; } out.push(a); }
124
+ return out;
125
+ };
126
+ const realRepos = async () => (guard(await api('GET', '/api/fleet?view=all')).json.repos || []).filter((r) => r.kind !== 'sessions');
127
+ const matchRepo = (repos, name) => {
128
+ const n = String(name || '').toLowerCase();
129
+ let hit = repos.filter((r) => r.name.toLowerCase() === n);
130
+ if (!hit.length) hit = repos.filter((r) => r.name.toLowerCase().startsWith(n));
131
+ if (!hit.length) hit = repos.filter((r) => r.name.toLowerCase().includes(n));
132
+ return hit;
133
+ };
134
+
135
+ (async () => {
136
+ const cmd = args[0];
137
+ if (cmd === 'ls' || cmd === 'projects') {
138
+ const fleet = guard(await api('GET', '/api/fleet?view=all')).json;
139
+ const repos = (fleet.repos || []).filter((r) => r.kind !== 'sessions');
140
+ const runs = fleet.runs || [];
141
+ if (!repos.length) { console.log('no projects registered — "coxpit add <path>" or register one in the board.'); process.exit(0); }
142
+ const active = (repoId) => runs.filter((r) => { const t = (fleet.tasks || []).find((x) => x.id === r.taskId); return t && t.repoId === repoId && (r.status === 'running' || r.status === 'pending'); }).length;
143
+ const w = Math.max(...repos.map((r) => r.name.length), 7);
144
+ for (const r of repos) { const a = active(r.id); console.log(' ' + r.name.padEnd(w) + ' ' + (a ? a + ' running' : 'idle')); }
145
+ process.exit(0);
146
+ }
147
+ if (cmd === 'ps') {
148
+ const fleet = guard(await api('GET', '/api/fleet?view=all')).json;
149
+ const repoName = Object.fromEntries((fleet.repos || []).map((r) => [r.id, r.name]));
150
+ const taskById = Object.fromEntries((fleet.tasks || []).map((t) => [t.id, t]));
151
+ const live = (fleet.runs || []).filter((r) => r.status === 'running' || r.status === 'pending');
152
+ if (!live.length) { console.log('no active runs.'); process.exit(0); }
153
+ for (const r of live) { const t = taskById[r.taskId]; const rn = t ? repoName[t.repoId] : '?'; console.log(' r' + r.id + ' ' + r.status.padEnd(8) + ' ' + (rn || '?') + ' ' + (t ? t.title : '').slice(0, 50)); }
154
+ process.exit(0);
155
+ }
156
+ if (cmd === 'add') {
157
+ const p = positionals()[0]; if (!p) die('usage: coxpit add <path>');
158
+ const abs = resolve(p);
159
+ const r = guard(await api('POST', '/api/repos', { machineSlug: 'local', path: abs }));
160
+ console.log('registered ' + (r.json.repo ? r.json.repo.name : abs));
161
+ process.exit(0);
162
+ }
163
+ if (cmd === 'steer') {
164
+ const pos = positionals(); const rid = String(pos[0] || '').replace(/^r/, ''); const msg = pos.slice(1).join(' ');
165
+ if (!rid || !msg) die('usage: coxpit steer <run> "<message>"');
166
+ guard(await api('POST', '/api/runs/' + Number(rid) + '/steer', { message: msg }));
167
+ console.log('steered r' + rid);
168
+ process.exit(0);
169
+ }
170
+ if (cmd === 'fan' || cmd === 'run') {
171
+ const pos = positionals();
172
+ const project = pos[0]; const goal = pos.slice(1).join(' ');
173
+ if (!project || !goal) die('usage: coxpit fan <project> "<goal>" [-n N] [--real] [--agent claude-code|codex] [--model M]');
174
+ const repos = await realRepos();
175
+ const hit = matchRepo(repos, project);
176
+ if (!hit.length) die('no project matches "' + project + '" — try "coxpit ls".');
177
+ if (hit.length > 1) die('"' + project + '" is ambiguous: ' + hit.map((r) => r.name).join(', '));
178
+ const repo = hit[0];
179
+ const count = Math.max(1, Math.min(8, num('-n', num('--count', 1)) || 1));
180
+ const real = has('--real');
181
+ const agent = str('--agent', undefined);
182
+ const model = str('--model', undefined);
183
+ const task = guard(await api('POST', '/api/tasks', { repoId: repo.id, title: goal.slice(0, 140), prompt: goal })).json.task;
184
+ const body = { count, real }; if (agent) body.agent = agent; if (model) body.model = model;
185
+ const runs = guard(await api('POST', '/api/tasks/' + task.id + '/run', body)).json.runs || [];
186
+ console.log('▶ ' + repo.name + ' · ' + count + (count === 1 ? ' run' : ' runs') + (real ? ' (real)' : ' (dry)') + ' · ' + runs.map((r) => 'r' + r.id).join(' '));
187
+ console.log(' watch: coxpit ps · compare/merge in the board/cockpit Review');
188
+ process.exit(0);
189
+ }
190
+ })().catch((e) => die(String(e && e.message || e)));
191
+ }
192
+
193
+ // 서브커맨드는 위 async IIFE 가 처리하고 스스로 exit 한다 — 데몬 기동으로 fall through 하면 안 됨
194
+ // (async 라 동기 흐름이 여기까지 내려오므로 명시 가드).
195
+ if (!SUBCMDS.has(args[0])) {
196
+ // --import 의 bare 'tsx' 는 사용자 cwd 기준으로 해석돼 npx 실행에서 깨진다 —
197
+ // 패키지 루트 기준으로 절대경로 해석해 넘긴다.
198
+ const require_ = createRequire(join(root, 'package.json'));
199
+ const tsxEntry = pathToFileURL(require_.resolve('tsx')).href;
72
200
 
73
- const child = spawn(process.execPath, ['--import', tsxEntry, entry, ...process.argv.slice(2)], {
74
- stdio: 'inherit',
75
- env: process.env,
76
- });
77
- child.on('exit', (code, sig) => process.exit(code ?? (sig ? 1 : 0)));
201
+ const child = spawn(process.execPath, ['--import', tsxEntry, entry, ...process.argv.slice(2)], {
202
+ stdio: 'inherit',
203
+ env: process.env,
204
+ });
205
+ child.on('exit', (code, sig) => process.exit(code ?? (sig ? 1 : 0)));
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.18.0",
3
+ "version": "5.21.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": "SEE LICENSE IN LICENSE.md",
package/src/cockpit.ts CHANGED
@@ -61,7 +61,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
61
61
  .lbl{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:6px 8px 10px;display:flex;justify-content:space-between}
62
62
  .tnode{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;color:var(--muted);white-space:nowrap;cursor:default}
63
63
  .tnode .car{color:var(--faint);width:9px;display:inline-block;text-align:center;cursor:pointer}
64
- .tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1}
64
+ .tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
65
65
  .tnode .meta{color:var(--faint);font-size:11px}
66
66
  .tnode.repo{color:var(--ink)}
67
67
  .tnode.goal{padding-left:20px} .tnode.goal .gi{color:var(--brand)}
@@ -144,7 +144,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
144
144
  .view-bar button:hover{border-color:var(--brand)}
145
145
  .view-bar .prim{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
146
146
  .view-body{flex:1;min-height:0;overflow:auto;background:var(--bg);position:relative}
147
- .view-body iframe{width:100%;height:100%;border:0;background:#fff}
147
+ .view-body:has(iframe){overflow:hidden} /* iframe 이 자체 스크롤 — 바깥 view-body 는 스크롤 금지(이중 스크롤바 방지) */
148
+ .view-body iframe{display:block;width:100%;height:100%;border:0;background:#fff}
148
149
  .view-body img{max-width:100%;display:block;margin:0 auto;padding:12px}
149
150
  .view-body pre.vtext{margin:0;padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-word}
150
151
  .view-body textarea.vedit{width:100%;height:100%;box-sizing:border-box;border:0;outline:none;resize:none;padding:12px 14px;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--ink);background:var(--bg)}
@@ -189,7 +190,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
189
190
  .pick-h .x{margin-left:auto;background:none;border:none;color:var(--faint);font-size:16px;cursor:pointer}
190
191
  .pick-h .x:hover{color:var(--ink)}
191
192
  .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}
193
+ .fp-search{padding:8px 12px;border-bottom:1px solid var(--line)}
194
+ .fp-search input{width:100%;box-sizing:border-box;font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--panel);border:1px solid var(--line-hi);border-radius:7px;padding:7px 10px;outline:none}
195
+ .fp-search input:focus{border-color:var(--brand)}
192
196
  .pick-list{flex:1;overflow:auto;padding:6px}
197
+ .pick-row .rel{margin-left:auto;font-size:10px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;max-width:60%}
193
198
  .pick-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;color:var(--muted)}
194
199
  .pick-row:hover{background:var(--surface2);color:var(--ink)}
195
200
  .pick-row .ic{width:14px;text-align:center;color:var(--faint)}
@@ -215,7 +220,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
215
220
  .lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
216
221
  .tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
217
222
  .tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
218
- .tnode.session .p{color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis}
223
+ /* 이름 우선: .n(flex:1) 이 공간을 갖고, 경로는 끝만 짧게(고정 폭) — hover 시 title 로 전체 표시 */
224
+ .tnode.session .p{flex:0 1 auto;max-width:64px;color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
225
+ .tnode.session:hover .p{color:var(--muted)}
226
+ .tnode.session .del{margin-left:auto;color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;line-height:1;padding:0 3px;opacity:0;flex:none}
227
+ .tnode.session:hover .del{opacity:1} .tnode.session .del:hover{color:var(--failed)}
228
+ body.touch .tnode.session .del{opacity:.65}
219
229
 
220
230
  .toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
221
231
  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}
@@ -432,10 +442,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
432
442
  <div class="pick">
433
443
  <div class="pick-h"><span class="t">파일 보기</span><button class="x" id="fpClose" title="닫기">×</button></div>
434
444
  <div class="pick-path" id="fpPath">…</div>
445
+ <div class="fp-search"><input id="fpSearch" placeholder="이 폴더 아래에서 이름으로 검색 (2자 이상)" autocomplete="off" spellcheck="false" /></div>
435
446
  <div class="pick-list" id="fpList"></div>
436
447
  <div class="pick-f">
437
448
  <button class="home" id="fpHome" title="홈으로">⌂ home</button>
438
- <span style="flex:1;font-size:11px;color:var(--faint)">폴더=이동 · 파일=뷰어로 열기</span>
449
+ <span id="fpHint" style="flex:1;font-size:11px;color:var(--faint)">폴더=이동 · 파일=뷰어로 열기</span>
439
450
  </div>
440
451
  </div>
441
452
  </div>
@@ -543,8 +554,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
543
554
  if (sessRuns.length){
544
555
  sessRuns.forEach(function(s){
545
556
  var r=s.run; var open = tabs[r.id] ? ' open' : '';
546
- html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
547
- + '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span></div>';
557
+ var sPath=(r.worktreePath||'');
558
+ var sTail=sPath.replace(/^.*\\/([^/]+)$/,'$1'); // 마지막 폴더명만(끝 조금)
559
+ html += '<div class="tnode session'+open+'" data-run="'+r.id+'" title="'+esc(sPath)+'"><span class="st '+esc(r.status)+'"></span>'
560
+ + '<span class="n" title="'+esc(s.title||'session')+'">'+esc(s.title||'session')+'</span>'
561
+ + '<span class="p" title="'+esc(sPath)+'">'+esc(sTail)+'</span>'
562
+ + '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
548
563
  });
549
564
  } else {
550
565
  html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
@@ -586,6 +601,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
586
601
  }
587
602
  $('tree').addEventListener('click', function(e){
588
603
  if (e.target.closest('[data-newsession]')){ openSession(); return; }
604
+ var del = e.target.closest('[data-delsession]');
605
+ if (del){ e.stopPropagation(); deleteSession(+del.dataset.delsession); return; }
589
606
  var run = e.target.closest('[data-run]');
590
607
  if (run){ openRunPane(+run.dataset.run); if (isMobile()) setDrawer(false); return; }
591
608
  var fn = e.target.closest('[data-fold]');
@@ -644,6 +661,26 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
644
661
  try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
645
662
  // URL 링크 클릭 가능(웹=새 탭, 데스크톱 앱=시스템 브라우저 — setWindowOpenHandler 가 external 로).
646
663
  try{ term.loadAddon(new window.WebLinksAddon.WebLinksAddon(function(ev, uri){ window.open(uri, '_blank', 'noopener'); })); }catch(e){}
664
+ // 파일 경로 링크: 에이전트가 찍는 경로(src/x.ts:12 · /Users/…/README.md 등)를 클릭하면 뷰어 페인으로.
665
+ try{ term.registerLinkProvider({ provideLinks: function(y, cb){
666
+ var ln; try{ ln=term.buffer.active.getLine(y-1); }catch(e){ cb(undefined); return; }
667
+ if(!ln){ cb(undefined); return; }
668
+ var s=ln.translateToString(true);
669
+ var re=/(?:~\\/|\\.{0,2}\\/)?[\\w.\\-\\/]*\\.[A-Za-z0-9]{1,8}(?::\\d+(?::\\d+)?)?/g;
670
+ var links=[], m;
671
+ while((m=re.exec(s))){
672
+ var raw=m[0]; if(!raw || raw.indexOf('://')>=0 || raw.slice(0,2)==='//') continue; // URL 은 WebLinksAddon 담당
673
+ var before = m.index>0 ? s.charAt(m.index-1) : ' ';
674
+ if(before===':' || before==='/' || /[A-Za-z0-9]/.test(before)) continue; // URL 조각·토큰 중간 배제
675
+ var pathPart=raw.replace(/:\\d+(?::\\d+)?$/,'');
676
+ var ext=(pathPart.split('.').pop()||'').toLowerCase();
677
+ if(!(VIEW_EXT[ext] || pathPart.indexOf('/')>=0)) continue; // 오탐 축소: 알려진 확장자거나 경로형
678
+ var sx=m.index+1, ex=m.index+raw.length;
679
+ links.push({ text:raw, range:{ start:{x:sx,y:y}, end:{x:ex,y:y} },
680
+ activate:function(ev, txt){ openPathFromTerm(runId, txt); } });
681
+ }
682
+ cb(links.length?links:undefined);
683
+ }}); }catch(e){}
647
684
  // 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
648
685
  // ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
649
686
  var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
@@ -675,8 +712,21 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
675
712
  return t;
676
713
  }
677
714
  function openViewer(path, name){ var t=ensureViewer(path, name); openTab(t.runId); }
715
+ // 뷰어 대상 확장자(터미널 경로 링크 오탐 축소용)
716
+ var VIEW_EXT = (function(){ var o={}; ('md markdown mdx html htm pdf png jpg jpeg gif webp svg bmp ico avif txt env json jsonc yaml yml toml ini conf cfg log csv tsv xml sql sh bash zsh js cjs mjs ts tsx jsx css scss less py rb php go rs java c h cpp hpp swift kt lua pl r dart vue svelte').split(' ').forEach(function(e){o[e]=1;}); return o; })();
717
+ var fsHome = '';
718
+ try{ fetch('/api/fs/list').then(function(r){return r.json();}).then(function(d){ fsHome=d.home||''; }).catch(function(){}); }catch(e){}
719
+ // 터미널에서 클릭한 경로 → 절대경로로 해석 후 뷰어 페인. 상대경로는 그 run 의 작업폴더(cwd) 기준.
720
+ function openPathFromTerm(runId, raw){
721
+ var p=(raw||'').replace(/:\\d+(?::\\d+)?$/,''); // :line:col 제거
722
+ var abs;
723
+ if(p.charAt(0)==='/') abs=p;
724
+ else if(p.slice(0,2)==='~/') abs=(fsHome||'').replace(/\\/$/,'')+p.slice(1);
725
+ else { var r=runById[runId]; var cwd=r&&r.worktreePath; if(!cwd){ toast('작업 폴더를 몰라 경로를 열 수 없습니다'); return; } abs=cwd.replace(/\\/$/,'')+'/'+p; }
726
+ openViewer(abs);
727
+ }
678
728
  function fmtSize(n){ return n<1024?(n+' B'):n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'); }
679
- var MD_FRAME_CSS = 'body{margin:0;padding:18px 22px;background:#0b0d12;color:#dee4ec;font:14px/1.7 -apple-system,BlinkMacSystemFont,\\'Apple SD Gothic Neo\\',\\'Noto Sans KR\\',sans-serif;max-width:800px}'
729
+ var MD_FRAME_CSS = 'body{margin:0 auto;padding:40px 28px 80px;background:#0b0d12;color:#dee4ec;font:14px/1.7 -apple-system,BlinkMacSystemFont,\\'Apple SD Gothic Neo\\',\\'Noto Sans KR\\',sans-serif;max-width:740px}'
680
730
  + 'a{color:#4ec9b0}h1,h2,h3{color:#fff;line-height:1.3}h1{border-bottom:1px solid #2c3444;padding-bottom:.3em}h2{border-bottom:1px solid #232a36;padding-bottom:.25em}'
681
731
  + 'code{background:#1c212c;padding:.15em .4em;border-radius:4px;font-family:ui-monospace,Menlo,monospace;font-size:.9em}'
682
732
  + 'pre{background:#12161d;padding:12px 14px;border-radius:8px;overflow:auto}pre code{background:none;padding:0}'
@@ -1055,6 +1105,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1055
1105
  // ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
1056
1106
  var pickPathCur = '';
1057
1107
  function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
1108
+ async function deleteSession(runId){
1109
+ if(!confirm('이 세션을 삭제할까요?\\n터미널만 종료됩니다 · 폴더와 파일은 그대로 보존됩니다.')) return;
1110
+ try{
1111
+ var res=await fetch('/api/runs/'+runId,{method:'DELETE'});
1112
+ var j=await res.json().catch(function(){return{};});
1113
+ if(res.ok){ if(tabs[runId]) closeTab(runId); toast('세션 삭제됨 (폴더 보존)'); await hydrate(); }
1114
+ else toast('삭제 실패: '+(j.error||res.status));
1115
+ }catch(e){ toast('삭제 실패: '+e); }
1116
+ }
1058
1117
  function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
1059
1118
  function closePicker(){ $('pickModal').classList.remove('on'); }
1060
1119
  async function browseTo(p){
@@ -1095,13 +1154,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1095
1154
 
1096
1155
  // ── 파일 피커(뷰어로 열기) — 폴더=이동, 파일=뷰어 ──
1097
1156
  var fpDirCur = '';
1098
- function openFilePicker(){ $('fpickModal').classList.add('on'); fpTo(fpDirCur||''); }
1157
+ function openFilePicker(){ $('fpickModal').classList.add('on'); $('fpSearch').value=''; fpTo(fpDirCur||''); setTimeout(function(){ try{ $('fpSearch').focus(); }catch(e){} },30); }
1099
1158
  function closeFilePicker(){ $('fpickModal').classList.remove('on'); }
1100
1159
  function fpIcon(kind){ return kind==='md'?'M':kind==='html'?'H':kind==='pdf'?'P':kind==='image'?'I':kind==='binary'?'·':'T'; }
1101
1160
  async function fpTo(p){
1102
1161
  try{
1103
1162
  var d = await (await fetch('/api/fs/list'+(p?('?path='+encodeURIComponent(p)):''))).json();
1104
- fpDirCur = d.path; $('fpPath').textContent = d.path;
1163
+ fpDirCur = d.path; $('fpPath').textContent = d.path; if($('fpHint')) $('fpHint').textContent='폴더=이동 · 파일=뷰어로 열기';
1105
1164
  var html='';
1106
1165
  if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-dir="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
1107
1166
  (d.entries||[]).forEach(function(e){
@@ -1113,12 +1172,32 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1113
1172
  $('fpList').innerHTML = html;
1114
1173
  }catch(e){ $('fpList').innerHTML = '<div class="pick-row" style="color:var(--failed)">읽을 수 없습니다</div>'; }
1115
1174
  }
1175
+ var fpFindT=null, fpFindSeq=0;
1176
+ async function fpFind(q){
1177
+ var seq=++fpFindSeq;
1178
+ try{
1179
+ var d = await (await fetch('/api/fs/find?path='+encodeURIComponent(fpDirCur||'')+'&q='+encodeURIComponent(q))).json();
1180
+ if(seq!==fpFindSeq) return; // 뒤늦게 도착한 오래된 응답 무시
1181
+ if($('fpHint')) $('fpHint').textContent = d.error ? d.error : (d.results.length+'개'+(d.truncated?'+':'')+' · '+ (fpDirCur||'') +' 아래');
1182
+ if(d.error){ $('fpList').innerHTML='<div class="pick-row" style="cursor:default;color:var(--faint)">'+esc(d.error)+'</div>'; return; }
1183
+ if(!d.results.length){ $('fpList').innerHTML='<div class="pick-row" style="cursor:default;color:var(--faint)">일치하는 파일 없음</div>'; return; }
1184
+ $('fpList').innerHTML = d.results.map(function(e){
1185
+ return '<div class="pick-row" data-file="'+esc(e.path)+'"><span class="ic">'+fpIcon(e.kind)+'</span><span>'+esc(e.name)+'</span><span class="rel">'+esc(e.rel)+'</span></div>';
1186
+ }).join('');
1187
+ }catch(e){ if(seq===fpFindSeq) $('fpList').innerHTML='<div class="pick-row" style="color:var(--failed)">검색 실패</div>'; }
1188
+ }
1189
+ $('fpSearch').addEventListener('input', function(){
1190
+ var q=this.value.trim();
1191
+ if(fpFindT){ clearTimeout(fpFindT); fpFindT=null; }
1192
+ if(q.length<2){ fpTo(fpDirCur); return; } // 비우면 현재 폴더 목록으로 복귀
1193
+ fpFindT=setTimeout(function(){ fpFindT=null; fpFind(q); }, 220);
1194
+ });
1116
1195
  $('fpList').addEventListener('click', function(e){
1117
- var dir=e.target.closest('[data-dir]'); if(dir){ fpTo(dir.dataset.dir); return; }
1196
+ var dir=e.target.closest('[data-dir]'); if(dir){ $('fpSearch').value=''; fpTo(dir.dataset.dir); return; }
1118
1197
  var file=e.target.closest('[data-file]'); if(file){ closeFilePicker(); openViewer(file.dataset.file); if(isMobile()) setDrawer(false); }
1119
1198
  });
1120
1199
  $('fpClose').addEventListener('click', closeFilePicker);
1121
- $('fpHome').addEventListener('click', function(){ fpTo(''); });
1200
+ $('fpHome').addEventListener('click', function(){ $('fpSearch').value=''; fpTo(''); });
1122
1201
  $('fpickModal').addEventListener('click', function(e){ if(e.target===this) closeFilePicker(); });
1123
1202
  $('fileBtn').addEventListener('click', openFilePicker);
1124
1203
  $('sessionCta').addEventListener('click', openSession);
package/src/files.ts CHANGED
@@ -87,9 +87,50 @@ export async function listDir(input?: string) {
87
87
  return { path, parent: pdirname(path), home: HOME, entries, error };
88
88
  }
89
89
 
90
+ // Recursive filename search under a folder (bounded). For the picker's search box.
91
+ const FIND_SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', 'venv', '.venv', '__pycache__', '.turbo', 'coverage', '.gradle', 'target']);
92
+ export async function findFiles(input: string | undefined, q: string, limit = 300) {
93
+ const root = await jail(input);
94
+ const needle = (q || '').trim().toLowerCase();
95
+ if (needle.length < 2) return { root, q: needle, results: [] as any[], error: 'query too short (min 2 chars)' };
96
+ const out: Array<{ path: string; rel: string; name: string; size: number; kind: FileKind }> = [];
97
+ async function walk(dir: string, depth: number): Promise<void> {
98
+ if (out.length >= limit || depth > 6) return;
99
+ let items;
100
+ try { items = await readdir(dir, { withFileTypes: true }); } catch { return; }
101
+ for (const it of items) {
102
+ if (out.length >= limit) return;
103
+ if (it.name.startsWith('.') && !it.name.startsWith('.env')) continue;
104
+ const full = pjoin(dir, it.name);
105
+ if (it.isDirectory()) { if (!FIND_SKIP.has(it.name)) await walk(full, depth + 1); continue; }
106
+ if (it.name.toLowerCase().includes(needle)) {
107
+ let size = 0; try { size = (await stat(full)).size; } catch { /* skip */ }
108
+ out.push({ path: full, rel: full.slice(root.length + 1), name: it.name, size, kind: classify(it.name) });
109
+ }
110
+ }
111
+ }
112
+ await walk(root, 0);
113
+ const truncated = out.length >= limit;
114
+ out.sort((a, b) => a.rel.length - b.rel.length || a.rel.localeCompare(b.rel));
115
+ return { root, q: needle, results: out, truncated };
116
+ }
117
+
118
+ // Node fs errors are ugly ("ENOENT: no such file or directory, stat '/…'"). Map to a
119
+ // short, human message (the viewer shows this verbatim). The path is echoed in the bar.
120
+ function friendly(e: any): Error {
121
+ const code = e && e.code;
122
+ if (code === 'ENOENT') return new Error('파일을 찾을 수 없습니다');
123
+ if (code === 'EISDIR') return new Error('폴더입니다');
124
+ if (code === 'EACCES' || code === 'EPERM') return new Error('열 권한이 없습니다');
125
+ if (e && e.message === 'outside home') return new Error('홈 폴더 밖이라 열 수 없습니다');
126
+ return new Error(String((e && e.message) || e));
127
+ }
128
+
90
129
  export async function readForView(input?: string) {
91
- const path = await jail(input);
92
- const st = await stat(path);
130
+ let path: string;
131
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
132
+ let st;
133
+ try { st = await stat(path); } catch (e) { throw friendly(e); }
93
134
  if (st.isDirectory()) throw new Error('is a directory');
94
135
  const name = pbasename(path);
95
136
  let kind = classify(path);
@@ -112,14 +153,17 @@ export async function readForView(input?: string) {
112
153
 
113
154
  // Raw bytes with correct content-type — this is what makes PDFs/images/html render.
114
155
  export async function readRaw(input?: string) {
115
- const path = await jail(input);
116
- const st = await stat(path);
117
- if (st.isDirectory()) throw new Error('is a directory');
156
+ let path: string;
157
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
158
+ let st;
159
+ try { st = await stat(path); } catch (e) { throw friendly(e); }
160
+ if (st.isDirectory()) throw new Error('폴더입니다');
118
161
  return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
119
162
  }
120
163
 
121
164
  export async function writeText(input: string, content: string) {
122
- const path = await jail(input);
165
+ let path: string;
166
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
123
167
  const st = await stat(path).catch(() => null);
124
168
  if (st && st.isDirectory()) throw new Error('is a directory');
125
169
  if (st && st.size > MAX_WRITE) throw new Error('file too large to edit here');
@@ -1158,6 +1158,28 @@ export async function openSessionAt(machineSlug: string, path: string, title: st
1158
1158
  return { ok: true, detail: 'session open', taskId: task.id, runId };
1159
1159
  }
1160
1160
 
1161
+ /**
1162
+ * 세션 삭제 — tmux 종료 + run/task 레코드 제거(폴더는 보존). sessions 버킷 run 에만 허용.
1163
+ * (일반 task run 은 cleanup/reclaim 을 쓴다 — 삭제로 이력이 사라지면 안 되므로.)
1164
+ */
1165
+ export async function deleteSession(runId: number): Promise<{ ok: boolean; detail: string }> {
1166
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
1167
+ const run = rr[0];
1168
+ if (!run) return { ok: false, detail: 'not found' };
1169
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
1170
+ const task = tr[0];
1171
+ const rp = task ? (await db.select().from(repos).where(eq(repos.id, task.repoId)).limit(1))[0] : undefined;
1172
+ if (!rp || rp.kind !== 'sessions') return { ok: false, detail: 'not a session (use cleanup/reclaim for task runs)' };
1173
+ await cleanupRun(runId).catch(() => { /* tmux kill + 포인터 비움; 실패해도 레코드는 지운다 */ });
1174
+ await db.delete(agentEvents).where(eq(agentEvents.runId, runId));
1175
+ await db.delete(agentRuns).where(eq(agentRuns.id, runId));
1176
+ // 세션 task 는 run 과 1:1 — 남은 run 이 없으면 task 도 제거해 트리에서 사라지게.
1177
+ const siblings = await db.select().from(agentRuns).where(eq(agentRuns.taskId, run.taskId));
1178
+ if (task && siblings.length === 0) await db.delete(tasks).where(eq(tasks.id, task.id));
1179
+ broadcast({ type: 'run', runId, deleted: true }); // 모든 콘솔이 재하이드레이트 → 행 제거
1180
+ return { ok: true, detail: 'session deleted (folder preserved)' };
1181
+ }
1182
+
1161
1183
  /**
1162
1184
  * 그룹에 속한 태스크 1개를 만들고 run 1개를 발사한다(공용 helper).
1163
1185
  * planFanout(plan 형제) 과 /api/groups/:id/spawn(+New attempt) 이 공유하는
package/src/remote.ts CHANGED
@@ -48,6 +48,21 @@ function jsonTargetsPort(raw: string, port: number): boolean {
48
48
  return textTargetsPort(raw, port);
49
49
  }
50
50
 
51
+ /**
52
+ * Funnel is active only if `AllowFunnel` has a truthy entry AND our port is targeted.
53
+ * serve and funnel read the SAME ServeConfig, so `funnel status` prints the Serve
54
+ * config verbatim when Funnel is off — port-matching alone false-positives Funnel
55
+ * (issue #7). `AllowFunnel` is the only field that differs; it is absent when off.
56
+ */
57
+ function funnelActiveForPort(raw: string, port: number): boolean {
58
+ let cfg: { AllowFunnel?: Record<string, unknown> };
59
+ try { cfg = JSON.parse(raw) as { AllowFunnel?: Record<string, unknown> }; } catch { throw new Error('not json'); }
60
+ const allow = cfg?.AllowFunnel;
61
+ if (!allow || typeof allow !== 'object') return false;
62
+ if (!Object.values(allow).some(Boolean)) return false;
63
+ return textTargetsPort(raw, port); // still confirm it is *our* port
64
+ }
65
+
51
66
  /** Plain-text fallback: any `:<port>` upstream mention (defensive across versions). */
52
67
  function textTargetsPort(raw: string, port: number): boolean {
53
68
  const p = String(port);
@@ -63,10 +78,12 @@ async function subStateForPort(bin: string, sub: 'serve' | 'funnel', port: numbe
63
78
  const jout = j.stdout.trim();
64
79
  if (jout) {
65
80
  try {
66
- return jsonTargetsPort(jout, port);
81
+ return sub === 'funnel' ? funnelActiveForPort(jout, port) : jsonTargetsPort(jout, port);
67
82
  } catch { /* not json — fall through to text grep */ }
68
83
  }
69
84
  const t = await runShellOn(LOCAL, `${shq(bin)} ${sub} status 2>/dev/null || true`, 8000);
85
+ // `funnel status` prints "(tailnet only)" when Funnel is off — a cheap, version-tolerant guard.
86
+ if (sub === 'funnel' && /\(tailnet only\)/i.test(t.stdout)) return false;
70
87
  return textTargetsPort(t.stdout, port);
71
88
  }
72
89
 
package/src/server.ts CHANGED
@@ -19,14 +19,14 @@ import { db } from './db';
19
19
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } 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, openSessionAt, getScrollback, getSessionChat } 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, deleteSession, getScrollback, getSessionChat } from './orchestrator';
23
23
  import { openTerm } from './term';
24
24
  import { addSink, removeSink, broadcast } from './hub';
25
25
  import { getProvider, listProviders } from './providers';
26
26
  import { remoteState, setServe, setFunnel } from './remote';
27
27
  import { BOARD_HTML } from './board';
28
28
  import { COCKPIT_HTML } from './cockpit';
29
- import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText } from './files';
29
+ import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles } from './files';
30
30
 
31
31
  const require_ = createRequire(import.meta.url);
32
32
 
@@ -752,6 +752,11 @@ export async function buildServer(): Promise<FastifyInstance> {
752
752
  try { return await fsListDir(q.path); }
753
753
  catch { return { path: q.path ?? '', parent: '', home: homedir(), entries: [], error: 'outside home or unreadable' }; }
754
754
  });
755
+ app.get('/api/fs/find', async (req) => {
756
+ const q = (req.query ?? {}) as { path?: string; q?: string };
757
+ try { return await fsFindFiles(q.path, q.q ?? ''); }
758
+ catch { return { root: q.path ?? '', q: q.q ?? '', results: [], error: 'outside home or unreadable' }; }
759
+ });
755
760
  app.get('/api/fs/read', async (req, reply) => {
756
761
  const q = (req.query ?? {}) as { path?: string };
757
762
  if (!q.path) return reply.code(400).send({ error: 'path required' });
@@ -998,6 +1003,15 @@ export async function buildServer(): Promise<FastifyInstance> {
998
1003
  return res;
999
1004
  });
1000
1005
 
1006
+ // 세션 삭제(tmux 종료 + 레코드 제거, 폴더 보존). sessions 버킷 run 에만 허용.
1007
+ app.delete('/api/runs/:id', async (req, reply) => {
1008
+ const id = Number((req.params as { id: string }).id);
1009
+ if (!Number.isInteger(id)) return reply.code(400).send({ error: 'bad id' });
1010
+ const res = await deleteSession(id);
1011
+ if (!res.ok) return reply.code(res.detail === 'not found' ? 404 : 400).send({ error: res.detail });
1012
+ return res;
1013
+ });
1014
+
1001
1015
  // v5.1 Part C foundation — resolve the land target + base drift for a run.
1002
1016
  // ?fetch=1 refreshes the remote first (network) so ahead/behind are current.
1003
1017
  app.get('/api/runs/:id/land-target', async (req, reply) => {