coxpit 5.20.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.20.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)}
@@ -220,7 +220,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
220
220
  .lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
221
221
  .tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
222
222
  .tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
223
- .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)}
224
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}
225
227
  .tnode.session:hover .del{opacity:1} .tnode.session .del:hover{color:var(--failed)}
226
228
  body.touch .tnode.session .del{opacity:.65}
@@ -552,8 +554,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
552
554
  if (sessRuns.length){
553
555
  sessRuns.forEach(function(s){
554
556
  var r=s.run; var open = tabs[r.id] ? ' open' : '';
555
- html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
556
- + '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span>'
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>'
557
562
  + '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
558
563
  });
559
564
  } else {
package/src/files.ts CHANGED
@@ -115,9 +115,22 @@ export async function findFiles(input: string | undefined, q: string, limit = 30
115
115
  return { root, q: needle, results: out, truncated };
116
116
  }
117
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
+
118
129
  export async function readForView(input?: string) {
119
- const path = await jail(input);
120
- 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); }
121
134
  if (st.isDirectory()) throw new Error('is a directory');
122
135
  const name = pbasename(path);
123
136
  let kind = classify(path);
@@ -140,14 +153,17 @@ export async function readForView(input?: string) {
140
153
 
141
154
  // Raw bytes with correct content-type — this is what makes PDFs/images/html render.
142
155
  export async function readRaw(input?: string) {
143
- const path = await jail(input);
144
- const st = await stat(path);
145
- 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('폴더입니다');
146
161
  return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
147
162
  }
148
163
 
149
164
  export async function writeText(input: string, content: string) {
150
- const path = await jail(input);
165
+ let path: string;
166
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
151
167
  const st = await stat(path).catch(() => null);
152
168
  if (st && st.isDirectory()) throw new Error('is a directory');
153
169
  if (st && st.size > MAX_WRITE) throw new Error('file too large to edit here');
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