coxpit 4.3.1 → 4.6.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 +13 -1
- package/package.json +1 -1
- package/src/board.ts +672 -12
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +136 -7
- package/src/providers.ts +14 -5
- package/src/remote.ts +148 -0
- package/src/server.ts +215 -7
package/src/db/index.ts
CHANGED
|
@@ -98,4 +98,5 @@ export async function ensureSchema(): Promise<void> {
|
|
|
98
98
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
99
99
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
|
|
100
100
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
|
|
101
|
+
try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
101
102
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -37,6 +37,7 @@ export const taskGroups = sqliteTable('task_groups', {
|
|
|
37
37
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
38
38
|
kind: text('kind').notNull().default('goal'), // 'goal' | 'swarm'
|
|
39
39
|
title: text('title').notNull(),
|
|
40
|
+
coordSessionId: text('coord_session_id').notNull().default(''), // L2 — 읽기전용 Ask 코디네이터의 재개 세션(--resume 키)
|
|
40
41
|
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
41
42
|
});
|
|
42
43
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -59,6 +59,11 @@ export function resolveAgentToken(token: string): number | null {
|
|
|
59
59
|
return agentTokens.get(token) ?? null;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** run 이 지금 살아 있는가(자식 프로세스 보유). aggregate 뷰의 live/steerable 판정용. */
|
|
63
|
+
export function isRunLive(runId: number): boolean {
|
|
64
|
+
return liveChildren.has(runId);
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
/** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
|
|
63
68
|
* 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
|
|
64
69
|
* 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
|
|
@@ -668,6 +673,25 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
|
|
|
668
673
|
return { ok: true, detail: 'workbench open', taskId: task.id, runId };
|
|
669
674
|
}
|
|
670
675
|
|
|
676
|
+
/**
|
|
677
|
+
* 그룹에 속한 태스크 1개를 만들고 run 1개를 발사한다(공용 helper).
|
|
678
|
+
* planFanout(plan 형제) 과 /api/groups/:id/spawn(+New attempt) 이 공유하는
|
|
679
|
+
* "태스크 생성(groupId 각인) → run 생성 → 브로드캐스트 → launchRun" 몸통.
|
|
680
|
+
*/
|
|
681
|
+
export async function launchGroupTask(
|
|
682
|
+
groupId: number, repoId: number, title: string, prompt: string, real: boolean,
|
|
683
|
+
): Promise<{ id: number; title: string; runId: number }> {
|
|
684
|
+
const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
|
|
685
|
+
const machineId = rp[0]!.machineId;
|
|
686
|
+
const tIns = await db.insert(tasks).values({ repoId, title: title.slice(0, 140), prompt, groupId }).returning();
|
|
687
|
+
const task = tIns[0]!;
|
|
688
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending' }).returning();
|
|
689
|
+
const run = rIns[0]!;
|
|
690
|
+
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
691
|
+
void launchRun(run.id, real);
|
|
692
|
+
return { id: task.id, title: task.title, runId: run.id };
|
|
693
|
+
}
|
|
694
|
+
|
|
671
695
|
/**
|
|
672
696
|
* Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
|
|
673
697
|
* 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
|
|
@@ -728,13 +752,7 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
|
|
|
728
752
|
|
|
729
753
|
const created: Array<{ id: number; title: string; runId: number }> = [];
|
|
730
754
|
for (const t of plan) {
|
|
731
|
-
|
|
732
|
-
const task = tIns[0]!;
|
|
733
|
-
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
|
|
734
|
-
const run = rIns[0]!;
|
|
735
|
-
broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
|
|
736
|
-
void launchRun(run.id, real);
|
|
737
|
-
created.push({ id: task.id, title: t.title, runId: run.id });
|
|
755
|
+
created.push(await launchGroupTask(groupId, repoId, t.title, t.prompt, real));
|
|
738
756
|
}
|
|
739
757
|
return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
|
|
740
758
|
}
|
|
@@ -795,6 +813,117 @@ export async function reviewTask(taskId: number, real: boolean): Promise<{ ok: b
|
|
|
795
813
|
}
|
|
796
814
|
}
|
|
797
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Ask 코디네이터 — 읽기 전용, 재개 가능한 그룹 스코프 Q&A.
|
|
818
|
+
* 그룹의 형제 run 들에서 {title,status,agent,filesChanged} + 정착 run 의 bounded diff 요약을
|
|
819
|
+
* 모아 컨텍스트로 주고, 질문에 답만 한다. worktree 를 열지도, run 을 발사하지도, 파일을 쓰지도,
|
|
820
|
+
* steer 하지도 않는다 — getRunDiff(읽기)와 텍스트 반환뿐. (reviewTask 를 대화형·재개형으로 변형)
|
|
821
|
+
*
|
|
822
|
+
* 세션: 첫 호출은 1회용(`bin -p <prompt> --output-format json`)으로 session_id 를 캡처해
|
|
823
|
+
* task_groups.coord_session_id 에 저장. 이후 호출은 provider.resumeCmd 로 진짜 대화를 잇는다.
|
|
824
|
+
* 드라이(real=false / COXPIT_AGENT_REAL off)는 결정적 mock + 합성 세션 id 반환(크레딧 0, e2e 안전).
|
|
825
|
+
*/
|
|
826
|
+
export async function askGroupCoordinator(
|
|
827
|
+
groupId: number, message: string, real: boolean,
|
|
828
|
+
): Promise<{ ok: boolean; detail: string; answer?: string }> {
|
|
829
|
+
const gr = await db.select().from(taskGroups).where(eq(taskGroups.id, groupId)).limit(1);
|
|
830
|
+
const group = gr[0];
|
|
831
|
+
if (!group) return { ok: false, detail: 'group not found' };
|
|
832
|
+
const msg = message.trim();
|
|
833
|
+
if (!msg) return { ok: false, detail: 'empty message' };
|
|
834
|
+
|
|
835
|
+
// 그룹의 형제 run 들(태스크 조인) — bounded 컨텍스트만.
|
|
836
|
+
const gts = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
|
|
837
|
+
const rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }> = [];
|
|
838
|
+
for (const t of gts) {
|
|
839
|
+
const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
|
|
840
|
+
for (const run of trs) rows.push({ run, task: t });
|
|
841
|
+
}
|
|
842
|
+
rows.sort((a, b) => a.run.id - b.run.id);
|
|
843
|
+
|
|
844
|
+
// repo/machine 은 그룹의 아무 태스크에서 상속(형제는 같은 repo 공유). read-only 는 repo 본체에서.
|
|
845
|
+
const anyTask = rows[0]?.task ?? gts[0];
|
|
846
|
+
let machine: MachineTarget | null = null;
|
|
847
|
+
let repoPath = '';
|
|
848
|
+
if (anyTask) {
|
|
849
|
+
const rp = await db.select().from(repos).where(eq(repos.id, anyTask.repoId)).limit(1);
|
|
850
|
+
const repo = rp[0];
|
|
851
|
+
if (repo) {
|
|
852
|
+
repoPath = repo.path;
|
|
853
|
+
const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
|
|
854
|
+
const m = mr[0];
|
|
855
|
+
if (m) machine = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// bounded 컨텍스트: run 요약 + 정착 run 의 diff 요약(각 ~1500자, 합 ~12k 상한).
|
|
860
|
+
const SETTLED = ['done', 'failed', 'stopped', 'merged'];
|
|
861
|
+
const sections: string[] = [];
|
|
862
|
+
let budget = 12000;
|
|
863
|
+
for (const { run, task } of rows) {
|
|
864
|
+
let sec = `### run r${run.id} — ${task.title.slice(0, 80)}\n`
|
|
865
|
+
+ `status: ${run.status} · agent: ${run.agent} · files changed: ${run.filesChanged}`;
|
|
866
|
+
if (SETTLED.includes(run.status) && budget > 0) {
|
|
867
|
+
const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
|
|
868
|
+
const raw = (d.ok ? (d.diff || d.stat || '(no changes)') : '(worktree gone — diff unavailable)');
|
|
869
|
+
const cap = Math.min(1500, Math.max(0, budget));
|
|
870
|
+
const clip = raw.slice(0, cap);
|
|
871
|
+
budget -= clip.length;
|
|
872
|
+
sec += `\nDiff summary:\n\`\`\`diff\n${clip}\n\`\`\``;
|
|
873
|
+
}
|
|
874
|
+
sections.push(sec);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const preamble =
|
|
878
|
+
`You are a READ-ONLY coordinator for a goal with these parallel attempts. `
|
|
879
|
+
+ `Answer the question about their state and diffs. Do NOT propose running commands, `
|
|
880
|
+
+ `do NOT modify files, do NOT suggest editing anything — you can only observe and explain.`;
|
|
881
|
+
const context = `Goal: ${group.title}\n\n${sections.join('\n\n') || '(no runs yet)'}`;
|
|
882
|
+
|
|
883
|
+
// 드라이: 결정적 mock 답변 + 합성 세션 id(첫 호출 시 저장, 이후 재사용). e2e 크레딧 0.
|
|
884
|
+
if (!real) {
|
|
885
|
+
const done = rows.filter((r) => SETTLED.includes(r.run.status)).length;
|
|
886
|
+
const running = rows.filter((r) => r.run.status === 'running').length;
|
|
887
|
+
const answer = `[dry coordinator] ${rows.length} attempt(s) · ${done} settled · ${running} running.\n`
|
|
888
|
+
+ `Q: ${msg.slice(0, 120)}\n`
|
|
889
|
+
+ `(rehearsal answer — read-only; run with Real agent for a substantive reply.)`;
|
|
890
|
+
if (!group.coordSessionId) {
|
|
891
|
+
const synth = 'dry-coord-' + randomBytes(6).toString('hex');
|
|
892
|
+
await db.update(taskGroups).set({ coordSessionId: synth }).where(eq(taskGroups.id, groupId));
|
|
893
|
+
}
|
|
894
|
+
return { ok: true, detail: 'rehearsal answer', answer };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (!machine || !repoPath) return { ok: false, detail: 'group has no repo to run the coordinator from' };
|
|
898
|
+
|
|
899
|
+
// 첫 호출 = 1회용(session_id 캡처), 이후 = resume(대화 이어가기). 둘 다 파일 미변경 read-only.
|
|
900
|
+
const provider = getProvider('claude-code');
|
|
901
|
+
let cmd: string;
|
|
902
|
+
const resuming = !!group.coordSessionId;
|
|
903
|
+
if (resuming) {
|
|
904
|
+
// resume 은 stream-json 을 내지만 여기선 마지막 result 만 필요 — json 으로 강제 재래핑 불가하므로
|
|
905
|
+
// 세션 id 는 이미 있으니 resumeCmd(대화)로 잇고, 최종 텍스트는 result 라인에서 추출한다.
|
|
906
|
+
cmd = `cd ${shq(repoPath)} && ${provider.resumeCmd(group.coordSessionId, `${preamble}\n\n${context}\n\nQuestion: ${msg}`)} --output-format json`;
|
|
907
|
+
} else {
|
|
908
|
+
const oneShot = `${preamble}\n\n${context}\n\nQuestion: ${msg}`;
|
|
909
|
+
cmd = `cd ${shq(repoPath)} && ${config.agent.bin} -p ${shq(oneShot)} --output-format json`;
|
|
910
|
+
}
|
|
911
|
+
const r = await runShellOn(machine, cmd, 300000);
|
|
912
|
+
if (!r.ok) return { ok: false, detail: 'coordinator failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
913
|
+
try {
|
|
914
|
+
const envelope = JSON.parse(r.stdout.trim()) as { result?: string; session_id?: string };
|
|
915
|
+
const answer = (envelope.result ?? '').trim();
|
|
916
|
+
if (!answer) throw new Error('empty answer');
|
|
917
|
+
// 첫 호출에서만 세션 각인(이후엔 유지). resume 응답도 같은 세션이라 덮어써도 무해.
|
|
918
|
+
if (typeof envelope.session_id === 'string' && envelope.session_id && envelope.session_id !== group.coordSessionId) {
|
|
919
|
+
await db.update(taskGroups).set({ coordSessionId: envelope.session_id }).where(eq(taskGroups.id, groupId));
|
|
920
|
+
}
|
|
921
|
+
return { ok: true, detail: resuming ? 'resumed' : 'answered', answer };
|
|
922
|
+
} catch (e) {
|
|
923
|
+
return { ok: false, detail: 'could not parse coordinator answer: ' + String(e).slice(0, 200) };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
798
927
|
export interface IntegrateResult {
|
|
799
928
|
runId: number;
|
|
800
929
|
status: 'merged' | 'conflict' | 'skipped';
|
package/src/providers.ts
CHANGED
|
@@ -76,9 +76,20 @@ const claudeProvider: Provider = {
|
|
|
76
76
|
if (obj.type) kind = obj.type;
|
|
77
77
|
if (obj.type === 'system' && typeof obj.session_id === 'string') ev.sessionId = obj.session_id;
|
|
78
78
|
if (obj.type === 'result') ev.resultText = typeof obj.result === 'string' ? obj.result : s;
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
79
|
+
// system 이벤트는 full fidelity 가 필요 없다 — 길이와 무관하게 항상 컴팩트 재직렬화로
|
|
80
|
+
// 통일하고 그 시점에 model 의 ANSI 이스케이프를 소독한다(예: 'claude-opus-4-8\x1b[1m').
|
|
81
|
+
// session_id 캡처는 위에서 이미 ev 에 담았으므로 stored 축약과 무관.
|
|
82
|
+
// ESC(\x1b) 를 포함해 SGR 시퀀스 전체를 제거 — spec 예시 정규식은 ESC 를 남겨
|
|
83
|
+
// 'm\x1b[1mx' → 'm\x1bx' 로 잔해가 남아 DoD("ESC 부재")를 못 지킨다. ESC 도 소비한다.
|
|
84
|
+
const stripAnsi = (x: string) => x.replace(/\x1b?\[[0-9;]*m/g, '');
|
|
85
|
+
if (obj.type === 'system') {
|
|
86
|
+
stored = JSON.stringify({
|
|
87
|
+
type: 'system', subtype: obj.subtype,
|
|
88
|
+
model: typeof obj.model === 'string' ? stripAnsi(obj.model) : obj.model,
|
|
89
|
+
});
|
|
90
|
+
} else if (s.length > 2000) {
|
|
91
|
+
// 2000자 초과 이벤트는 자르면 JSON 이 깨져 잔해가 화면에 노출된다 —
|
|
92
|
+
// 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
|
|
82
93
|
if (obj.type === 'assistant' && obj.message) {
|
|
83
94
|
const content = (obj.message.content ?? [])
|
|
84
95
|
.filter((c) => c.type === 'text' || c.type === 'tool_use')
|
|
@@ -88,8 +99,6 @@ const claudeProvider: Provider = {
|
|
|
88
99
|
stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
|
|
89
100
|
} else if (obj.type === 'user') {
|
|
90
101
|
stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
|
|
91
|
-
} else if (obj.type === 'system') {
|
|
92
|
-
stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
|
|
93
102
|
} else if (obj.type === 'result') {
|
|
94
103
|
stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
|
|
95
104
|
} else {
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Remote access detection — read the LOCAL machine's Tailscale state and whether
|
|
2
|
+
// Serve/Funnel already point at our port. This is the whole v4.5 backend surface.
|
|
3
|
+
//
|
|
4
|
+
// GUARDRAIL (non-negotiable): coxpit never hosts a relay and never issues a
|
|
5
|
+
// coxpit-branded public URL. It DETECTS the user's own Tailscale and DRIVES it
|
|
6
|
+
// (serve/funnel), or hands a copy-paste recipe. We never bundle tailscale or
|
|
7
|
+
// cloudflared — absent tools degrade to `missing` + a recipe, never to a coxpit
|
|
8
|
+
// tunnel. All truth is read live from the CLI; nothing is persisted in the DB.
|
|
9
|
+
|
|
10
|
+
import { runShellOn, shq, type MachineTarget } from './exec';
|
|
11
|
+
|
|
12
|
+
export interface RemoteState {
|
|
13
|
+
tailscale: 'missing' | 'stopped' | 'running';
|
|
14
|
+
dnsName?: string; // trailing dot stripped (e.g. host.tailnet.ts.net)
|
|
15
|
+
tailnetSuffix?: string; // MagicDNS suffix (e.g. tailnet.ts.net)
|
|
16
|
+
serve: boolean; // is serve active for OUR port?
|
|
17
|
+
funnel: boolean; // is funnel active for OUR port?
|
|
18
|
+
binPath?: string; // resolved tailscale bin
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Serve/Funnel are always driven against the local machine (the daemon host) —
|
|
22
|
+
// we cannot reach into a remote node's Tailscale from here.
|
|
23
|
+
const LOCAL: MachineTarget = { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
24
|
+
|
|
25
|
+
// mac app bundles the CLI here; Linux/most installs put `tailscale` on PATH.
|
|
26
|
+
const MAC_APP_BIN = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
27
|
+
|
|
28
|
+
/** Resolve the tailscale binary: PATH first, else the macOS app bundle. '' = none. */
|
|
29
|
+
async function resolveBin(): Promise<string> {
|
|
30
|
+
const onPath = await runShellOn(LOCAL, 'command -v tailscale 2>/dev/null || true', 6000);
|
|
31
|
+
const p = onPath.stdout.trim().split('\n').pop()?.trim() ?? '';
|
|
32
|
+
if (p) return p;
|
|
33
|
+
const app = await runShellOn(LOCAL, `test -x ${shq(MAC_APP_BIN)} && echo yes || true`, 6000);
|
|
34
|
+
if (app.stdout.includes('yes')) return MAC_APP_BIN;
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Does a serve/funnel status JSON (shape varies by version) target http://…:<port>? */
|
|
39
|
+
function jsonTargetsPort(raw: string, port: number): boolean {
|
|
40
|
+
// Rather than chase the (version-dependent) nested shape, look for any proxy
|
|
41
|
+
// target string that names our loopback port. `serve status --json` embeds
|
|
42
|
+
// upstreams as `http://127.0.0.1:<port>` / `http://localhost:<port>`.
|
|
43
|
+
try {
|
|
44
|
+
JSON.parse(raw); // ensure it IS json (caller falls back to text grep otherwise)
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error('not json');
|
|
47
|
+
}
|
|
48
|
+
return textTargetsPort(raw, port);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plain-text fallback: any `:<port>` upstream mention (defensive across versions). */
|
|
52
|
+
function textTargetsPort(raw: string, port: number): boolean {
|
|
53
|
+
const p = String(port);
|
|
54
|
+
return raw.includes('127.0.0.1:' + p)
|
|
55
|
+
|| raw.includes('localhost:' + p)
|
|
56
|
+
|| raw.includes('0.0.0.0:' + p)
|
|
57
|
+
|| raw.includes('[::1]:' + p);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Is serve/funnel active for our port? Try `<sub> status --json`, else `<sub> status`. */
|
|
61
|
+
async function subStateForPort(bin: string, sub: 'serve' | 'funnel', port: number): Promise<boolean> {
|
|
62
|
+
const j = await runShellOn(LOCAL, `${shq(bin)} ${sub} status --json 2>/dev/null || true`, 8000);
|
|
63
|
+
const jout = j.stdout.trim();
|
|
64
|
+
if (jout) {
|
|
65
|
+
try {
|
|
66
|
+
return jsonTargetsPort(jout, port);
|
|
67
|
+
} catch { /* not json — fall through to text grep */ }
|
|
68
|
+
}
|
|
69
|
+
const t = await runShellOn(LOCAL, `${shq(bin)} ${sub} status 2>/dev/null || true`, 8000);
|
|
70
|
+
return textTargetsPort(t.stdout, port);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Live Tailscale state for `port`, best-effort. Any failure downgrades to
|
|
75
|
+
* missing/stopped — this must never throw into the request handler.
|
|
76
|
+
*/
|
|
77
|
+
export async function remoteState(port: number): Promise<RemoteState> {
|
|
78
|
+
const off: RemoteState = { tailscale: 'missing', serve: false, funnel: false };
|
|
79
|
+
try {
|
|
80
|
+
const bin = await resolveBin();
|
|
81
|
+
if (!bin) return off;
|
|
82
|
+
|
|
83
|
+
const st = await runShellOn(LOCAL, `${shq(bin)} status --json 2>/dev/null || true`, 8000);
|
|
84
|
+
const out = st.stdout.trim();
|
|
85
|
+
if (!out) return { tailscale: 'stopped', serve: false, funnel: false, binPath: bin };
|
|
86
|
+
|
|
87
|
+
let self: { DNSName?: string } | undefined;
|
|
88
|
+
let magic = '';
|
|
89
|
+
let backendState = '';
|
|
90
|
+
try {
|
|
91
|
+
const j = JSON.parse(out) as {
|
|
92
|
+
Self?: { DNSName?: string };
|
|
93
|
+
MagicDNSSuffix?: string;
|
|
94
|
+
BackendState?: string;
|
|
95
|
+
};
|
|
96
|
+
self = j.Self;
|
|
97
|
+
magic = String(j.MagicDNSSuffix ?? '');
|
|
98
|
+
backendState = String(j.BackendState ?? '');
|
|
99
|
+
} catch {
|
|
100
|
+
// Non-JSON (e.g. "Logged out." / "stopped") — treat as not running.
|
|
101
|
+
return { tailscale: 'stopped', serve: false, funnel: false, binPath: bin };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Logged out / stopped backends have no usable name.
|
|
105
|
+
if (/stopped|NoState|NeedsLogin|Logged out/i.test(backendState) || !self?.DNSName) {
|
|
106
|
+
return { tailscale: 'stopped', serve: false, funnel: false, binPath: bin };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dnsName = String(self.DNSName).replace(/\.$/, ''); // strip trailing dot
|
|
110
|
+
const tailnetSuffix = magic.replace(/\.$/, '') || undefined;
|
|
111
|
+
|
|
112
|
+
const [serve, funnel] = await Promise.all([
|
|
113
|
+
subStateForPort(bin, 'serve', port),
|
|
114
|
+
subStateForPort(bin, 'funnel', port),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
return { tailscale: 'running', dnsName, tailnetSuffix, serve, funnel, binPath: bin };
|
|
118
|
+
} catch {
|
|
119
|
+
return off;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Turn Serve on/off for our port (tailnet-only, HTTPS — safe by default). */
|
|
124
|
+
export async function setServe(port: number, on: boolean): Promise<RemoteState> {
|
|
125
|
+
const bin = await resolveBin();
|
|
126
|
+
if (bin) {
|
|
127
|
+
const cmd = on
|
|
128
|
+
? `${shq(bin)} serve --bg ${String(port)}`
|
|
129
|
+
: `${shq(bin)} serve reset`;
|
|
130
|
+
await runShellOn(LOCAL, cmd, 20000);
|
|
131
|
+
}
|
|
132
|
+
return remoteState(port);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Turn Funnel on/off for our port (PUBLIC internet). The NO_AUTH guard lives in
|
|
137
|
+
* the route (Funnel with no basic auth = open shells); this just drives the CLI.
|
|
138
|
+
*/
|
|
139
|
+
export async function setFunnel(port: number, on: boolean): Promise<RemoteState> {
|
|
140
|
+
const bin = await resolveBin();
|
|
141
|
+
if (bin) {
|
|
142
|
+
const cmd = on
|
|
143
|
+
? `${shq(bin)} funnel --bg ${String(port)}`
|
|
144
|
+
: `${shq(bin)} funnel reset`;
|
|
145
|
+
await runShellOn(LOCAL, cmd, 20000);
|
|
146
|
+
}
|
|
147
|
+
return remoteState(port);
|
|
148
|
+
}
|