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 +17 -0
- package/bin/coxpit.js +138 -9
- package/package.json +1 -1
- package/src/cockpit.ts +9 -4
- package/src/files.ts +22 -6
- package/src/remote.ts +18 -1
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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|