@cordfuse/llmux 0.11.0 → 0.12.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/dist/index.js +3265 -75
- package/package.json +17 -4
- package/src/cli.ts +100 -0
- package/src/daemon/agents.ts +193 -0
- package/src/daemon/auth-store.ts +85 -0
- package/src/daemon/config.ts +77 -0
- package/src/daemon/handlers.ts +414 -0
- package/src/daemon/net.ts +113 -0
- package/src/daemon/state.ts +78 -0
- package/src/daemon/tmux.ts +117 -0
- package/src/daemon/token.ts +13 -0
- package/src/daemon/web/server.ts +2277 -0
- package/src/index.ts +386 -37
- /package/src/{client.ts → client/client.ts} +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import qrcodeTerminal from 'qrcode-terminal';
|
|
5
|
+
import { DEFAULT_AGENTS, isAgentInstalled, type AgentDefinition } from './agents.ts';
|
|
6
|
+
import * as state from './state.ts';
|
|
7
|
+
import * as tmux from './tmux.ts';
|
|
8
|
+
import * as authStore from './auth-store.ts';
|
|
9
|
+
import { startServer, printBanner } from './web/server.ts';
|
|
10
|
+
import { getAddresses } from './net.ts';
|
|
11
|
+
import type { ParsedArgs } from '../cli.ts';
|
|
12
|
+
|
|
13
|
+
// ---------- helpers ----------
|
|
14
|
+
|
|
15
|
+
function expandAgentList(spec: string): AgentDefinition[] {
|
|
16
|
+
if (spec === 'all') return Object.values(DEFAULT_AGENTS).filter(isAgentInstalled);
|
|
17
|
+
const keys = spec.split(',').map((k) => k.trim()).filter(Boolean);
|
|
18
|
+
const out: AgentDefinition[] = [];
|
|
19
|
+
for (const k of keys) {
|
|
20
|
+
const def = DEFAULT_AGENTS[k];
|
|
21
|
+
if (!def) throw new Error(`unknown agent "${k}". Known: ${Object.keys(DEFAULT_AGENTS).join(', ')}`);
|
|
22
|
+
if (!isAgentInstalled(def)) throw new Error(`agent "${k}" is not installed (looked for: ${def.cmd})`);
|
|
23
|
+
out.push(def);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildCommand(agent: AgentDefinition): string {
|
|
29
|
+
return agent.flags ? `${agent.cmd} ${agent.flags}` : agent.cmd;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveCwd(input: string | undefined): string {
|
|
33
|
+
if (!input) return process.cwd();
|
|
34
|
+
const out = resolve(input);
|
|
35
|
+
if (!existsSync(out)) throw new Error(`cwd does not exist: ${out}`);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ResolvedTarget {
|
|
40
|
+
session: state.SessionState;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve `<target>` to a single session; supports session-name OR agent-type-with-N=1. */
|
|
44
|
+
export function resolveTarget(target: string): ResolvedTarget {
|
|
45
|
+
const direct = state.get(target);
|
|
46
|
+
if (direct) return { session: direct };
|
|
47
|
+
|
|
48
|
+
const byAgent = state.list().filter((s) => s.agent === target);
|
|
49
|
+
if (byAgent.length === 0) {
|
|
50
|
+
throw new Error(`no session matches "${target}" (not a session name; no agent of that type running)`);
|
|
51
|
+
}
|
|
52
|
+
if (byAgent.length > 1) {
|
|
53
|
+
const names = byAgent.map((s) => s.name).join(', ');
|
|
54
|
+
throw new Error(`"${target}" is ambiguous — ${byAgent.length} ${target} sessions: ${names}`);
|
|
55
|
+
}
|
|
56
|
+
return { session: byAgent[0]! };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- handlers ----------
|
|
60
|
+
|
|
61
|
+
export function handleSpawn(args: ParsedArgs): void {
|
|
62
|
+
tmux.requireTmux();
|
|
63
|
+
const spec = args.positional[0];
|
|
64
|
+
if (!spec) throw new Error('spawn requires an agent (or `all`)');
|
|
65
|
+
const name = args.flags.name as string | undefined;
|
|
66
|
+
const prefix = args.flags.prefix as string | undefined;
|
|
67
|
+
const cwd = resolveCwd(args.flags.cwd as string | undefined);
|
|
68
|
+
if (name && prefix) throw new Error('--name and --prefix are mutually exclusive');
|
|
69
|
+
|
|
70
|
+
const agents = expandAgentList(spec);
|
|
71
|
+
|
|
72
|
+
if (name && agents.length > 1) {
|
|
73
|
+
throw new Error('--name is only valid with a single agent');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parent = process.env.LLMUX_SESSION ?? null;
|
|
77
|
+
const created: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const agent of agents) {
|
|
80
|
+
const sessionName = name ?? (prefix ? `${prefix}${agent.key}` : agent.key);
|
|
81
|
+
if (state.get(sessionName) || tmux.hasSession(sessionName)) {
|
|
82
|
+
throw new Error(`session "${sessionName}" already exists`);
|
|
83
|
+
}
|
|
84
|
+
tmux.newSession({
|
|
85
|
+
name: sessionName,
|
|
86
|
+
command: buildCommand(agent),
|
|
87
|
+
cwd,
|
|
88
|
+
env: { LLMUX_SESSION: sessionName, LLMUX_AGENT: agent.key },
|
|
89
|
+
});
|
|
90
|
+
state.record({
|
|
91
|
+
name: sessionName,
|
|
92
|
+
agent: agent.key,
|
|
93
|
+
cwd,
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
parent,
|
|
96
|
+
restart: 'on-failure',
|
|
97
|
+
});
|
|
98
|
+
created.push(sessionName);
|
|
99
|
+
console.log(`spawned ${sessionName} (agent: ${agent.key}, cwd: ${cwd})`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (created.length === 0) {
|
|
103
|
+
console.log('no sessions spawned');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function handleStatus(args: ParsedArgs): void {
|
|
108
|
+
// Reconcile state against tmux: anything tracked but missing in tmux is marked exited.
|
|
109
|
+
const tracked = state.list();
|
|
110
|
+
const live = new Set(tmux.listSessions().map((s) => s.name));
|
|
111
|
+
|
|
112
|
+
if (args.flags.json) {
|
|
113
|
+
const out = tracked.map((s) => ({
|
|
114
|
+
...s,
|
|
115
|
+
state: live.has(s.name) ? 'running' : 'exited',
|
|
116
|
+
}));
|
|
117
|
+
console.log(JSON.stringify(out, null, 2));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (tracked.length === 0) {
|
|
122
|
+
console.log('no llmuxd sessions');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rows = tracked.map((s) => [
|
|
127
|
+
s.name,
|
|
128
|
+
s.agent,
|
|
129
|
+
live.has(s.name) ? 'running' : 'exited',
|
|
130
|
+
s.parent ?? '-',
|
|
131
|
+
s.cwd,
|
|
132
|
+
]);
|
|
133
|
+
const headers = ['NAME', 'AGENT', 'STATE', 'PARENT', 'CWD'];
|
|
134
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
|
|
135
|
+
const fmt = (cols: string[]): string => cols.map((c, i) => c.padEnd(widths[i]!)).join(' ');
|
|
136
|
+
console.log(fmt(headers));
|
|
137
|
+
for (const r of rows) console.log(fmt(r));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function handleSend(args: ParsedArgs): void {
|
|
141
|
+
tmux.requireTmux();
|
|
142
|
+
const [target, ...promptParts] = args.positional;
|
|
143
|
+
if (!target || promptParts.length === 0) {
|
|
144
|
+
throw new Error('send requires <session> and "<prompt>"');
|
|
145
|
+
}
|
|
146
|
+
const prompt = promptParts.join(' ');
|
|
147
|
+
const { session } = resolveTarget(target);
|
|
148
|
+
if (!tmux.hasSession(session.name)) {
|
|
149
|
+
throw new Error(`session "${session.name}" is in state but not live in tmux (exited?). Try \`llmuxd respawn ${session.name}\`.`);
|
|
150
|
+
}
|
|
151
|
+
tmux.sendKeys(session.name, prompt, { enter: true });
|
|
152
|
+
console.log(`sent ${prompt.length} bytes → ${session.name}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function handleBroadcast(args: ParsedArgs): void {
|
|
156
|
+
tmux.requireTmux();
|
|
157
|
+
const [agentKey, ...promptParts] = args.positional;
|
|
158
|
+
if (!agentKey || promptParts.length === 0) {
|
|
159
|
+
throw new Error('broadcast requires <agent> and "<prompt>"');
|
|
160
|
+
}
|
|
161
|
+
if (!DEFAULT_AGENTS[agentKey]) {
|
|
162
|
+
throw new Error(`unknown agent "${agentKey}". Known: ${Object.keys(DEFAULT_AGENTS).join(', ')}`);
|
|
163
|
+
}
|
|
164
|
+
const prompt = promptParts.join(' ');
|
|
165
|
+
const sessions = state.list().filter((s) => s.agent === agentKey);
|
|
166
|
+
if (sessions.length === 0) {
|
|
167
|
+
console.log(`no ${agentKey} sessions running`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let n = 0;
|
|
171
|
+
for (const s of sessions) {
|
|
172
|
+
if (!tmux.hasSession(s.name)) continue;
|
|
173
|
+
tmux.sendKeys(s.name, prompt, { enter: true });
|
|
174
|
+
console.log(`sent → ${s.name}`);
|
|
175
|
+
n++;
|
|
176
|
+
}
|
|
177
|
+
console.log(`broadcast to ${n}/${sessions.length} ${agentKey} sessions`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function handleChat(args: ParsedArgs): void {
|
|
181
|
+
tmux.requireTmux();
|
|
182
|
+
const target = args.positional[0];
|
|
183
|
+
if (!target) throw new Error('chat requires <session>');
|
|
184
|
+
if (args.flags.browser) {
|
|
185
|
+
throw new Error('--browser requires `llmuxd serve` (Phase 4). Use `llmuxd chat` without --browser for now.');
|
|
186
|
+
}
|
|
187
|
+
const { session } = resolveTarget(target);
|
|
188
|
+
if (!tmux.hasSession(session.name)) {
|
|
189
|
+
throw new Error(`session "${session.name}" is not live in tmux`);
|
|
190
|
+
}
|
|
191
|
+
tmux.attachOrSwitch(session.name);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function handleServe(args: ParsedArgs): Promise<void> {
|
|
195
|
+
tmux.requireTmux();
|
|
196
|
+
const portRaw = (args.flags.port as string | undefined) ?? process.env.LLMUXD_PORT ?? '3000';
|
|
197
|
+
const port = Number(portRaw);
|
|
198
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
199
|
+
throw new Error(`invalid port: ${portRaw}`);
|
|
200
|
+
}
|
|
201
|
+
const host = process.env.LLMUXD_HOST ?? '0.0.0.0';
|
|
202
|
+
const handle = startServer({ port, host });
|
|
203
|
+
printBanner(handle.port);
|
|
204
|
+
|
|
205
|
+
const shutdown = async (sig: string) => {
|
|
206
|
+
console.log(`\n${sig} received — shutting down`);
|
|
207
|
+
await handle.stop();
|
|
208
|
+
process.exit(0);
|
|
209
|
+
};
|
|
210
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
211
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
212
|
+
|
|
213
|
+
// Idle forever — the http server and ws server keep the event loop alive.
|
|
214
|
+
await new Promise<void>(() => {});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function endpointPort(): number {
|
|
218
|
+
// Default daemon port — matches the `serve` command default in commands.ts.
|
|
219
|
+
// QR builders run from a separate `token create` invocation that doesn't
|
|
220
|
+
// know which port the daemon is bound to, so we resolve from the running
|
|
221
|
+
// daemon if reachable, else fall back to 3030 (the documented default).
|
|
222
|
+
return Number(process.env.LLMUX_PORT) || 3030;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function selectorOf(label: string): string {
|
|
226
|
+
return label.toLowerCase().replace(/\s+/g, '-');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resolveQrEndpoint(selector: string, port: number): { label: string; url: string } {
|
|
230
|
+
const addrs = getAddresses(port);
|
|
231
|
+
const wanted = selector.toLowerCase().trim();
|
|
232
|
+
const matches = addrs.filter((a) => selectorOf(a.label) === wanted);
|
|
233
|
+
if (matches.length === 0) {
|
|
234
|
+
const available = Array.from(new Set(addrs.map((a) => selectorOf(a.label)))).join(', ');
|
|
235
|
+
throw new Error(`unknown --qr-endpoint "${selector}". Available: ${available}`);
|
|
236
|
+
}
|
|
237
|
+
if (matches.length > 1) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`--qr-endpoint "${selector}" is ambiguous (${matches.length} matches). Use \`llmuxd token create --qr\` without an endpoint to pick interactively.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return matches[0]!;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function pickEndpointInteractively(port: number): Promise<{ label: string; url: string }> {
|
|
246
|
+
const addrs = getAddresses(port);
|
|
247
|
+
if (addrs.length === 0) throw new Error('no reachable endpoints found');
|
|
248
|
+
console.log('');
|
|
249
|
+
console.log('Pick an endpoint for the QR code:');
|
|
250
|
+
for (let i = 0; i < addrs.length; i++) {
|
|
251
|
+
console.log(` ${i + 1}) ${addrs[i]!.label.padEnd(18)} ${addrs[i]!.url}`);
|
|
252
|
+
}
|
|
253
|
+
if (!process.stdin.isTTY) {
|
|
254
|
+
throw new Error('--qr without --qr-endpoint requires an interactive terminal');
|
|
255
|
+
}
|
|
256
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
257
|
+
const answer = await new Promise<string>((resolve) => rl.question(' > ', (a) => resolve(a)));
|
|
258
|
+
rl.close();
|
|
259
|
+
const idx = Number(answer.trim()) - 1;
|
|
260
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= addrs.length) {
|
|
261
|
+
throw new Error(`invalid selection "${answer}"`);
|
|
262
|
+
}
|
|
263
|
+
return addrs[idx]!;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function printQr(url: string, token: string, label: string): void {
|
|
267
|
+
const deepLink = `${url.replace(/\/$/, '')}/?token=${encodeURIComponent(token)}`;
|
|
268
|
+
console.log('');
|
|
269
|
+
console.log(`QR for ${label}:`);
|
|
270
|
+
console.log('');
|
|
271
|
+
qrcodeTerminal.generate(deepLink, { small: true });
|
|
272
|
+
console.log(` ${deepLink}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function handleTokenCreate(args: ParsedArgs): Promise<void> {
|
|
276
|
+
const name = args.flags.name as string | undefined;
|
|
277
|
+
const expiry = args.flags.expiry as string | undefined;
|
|
278
|
+
const qrFlag = Boolean(args.flags.qr);
|
|
279
|
+
const qrEndpoint = args.flags['qr-endpoint'] as string | undefined;
|
|
280
|
+
if (expiry && isNaN(new Date(expiry).getTime())) {
|
|
281
|
+
throw new Error(`--expiry must be an ISO-8601 timestamp (got "${expiry}")`);
|
|
282
|
+
}
|
|
283
|
+
const wantsQr = qrFlag || qrEndpoint !== undefined;
|
|
284
|
+
|
|
285
|
+
// Resolve the QR endpoint BEFORE creating the token so a bad selector
|
|
286
|
+
// doesn't leave an orphan token in auth.json.
|
|
287
|
+
let endpoint: { label: string; url: string } | undefined;
|
|
288
|
+
if (wantsQr) {
|
|
289
|
+
const port = endpointPort();
|
|
290
|
+
endpoint = qrEndpoint
|
|
291
|
+
? resolveQrEndpoint(qrEndpoint, port)
|
|
292
|
+
: await pickEndpointInteractively(port);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const wasEnabled = authStore.authEnabled();
|
|
296
|
+
const rec = authStore.createAuthToken({
|
|
297
|
+
...(name !== undefined ? { name } : {}),
|
|
298
|
+
...(expiry !== undefined ? { expiresAt: expiry } : {}),
|
|
299
|
+
});
|
|
300
|
+
console.log(`token created (id: ${rec.id})${rec.name ? ` "${rec.name}"` : ''}`);
|
|
301
|
+
console.log('');
|
|
302
|
+
console.log(` ${rec.token}`);
|
|
303
|
+
console.log('');
|
|
304
|
+
console.log('Save this token now — it is shown once. Use in the LLMUX_TOKEN env var, the');
|
|
305
|
+
console.log('`Authorization: Bearer <token>` header, or paste it into the web gate page.');
|
|
306
|
+
if (!wasEnabled) {
|
|
307
|
+
console.log('');
|
|
308
|
+
console.log('Auth is now enabled. All non-localhost requests require this (or another) token.');
|
|
309
|
+
}
|
|
310
|
+
if (endpoint) {
|
|
311
|
+
printQr(endpoint.url, rec.token, endpoint.label);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function handleTokenShow(args: ParsedArgs): void {
|
|
316
|
+
const tokens = authStore.listAuthTokens();
|
|
317
|
+
if (args.flags.json) {
|
|
318
|
+
const out = tokens.map((t) => ({ id: t.id, name: t.name, createdAt: t.createdAt, expiresAt: t.expiresAt }));
|
|
319
|
+
console.log(JSON.stringify(out, null, 2));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (tokens.length === 0) {
|
|
323
|
+
console.log('no tokens — auth is disabled. Create one with `llmuxd token create`.');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const headers = ['ID', 'NAME', 'CREATED', 'EXPIRES'];
|
|
327
|
+
const rows = tokens.map((t) => [t.id, t.name ?? '-', t.createdAt, t.expiresAt ?? '-']);
|
|
328
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
|
|
329
|
+
console.log(headers.map((h, i) => h.padEnd(widths[i]!)).join(' '));
|
|
330
|
+
for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i]!)).join(' '));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function handleTokenRevoke(args: ParsedArgs): void {
|
|
334
|
+
// Accept the id at positional[0] (new flat dispatcher) OR positional[1] (legacy
|
|
335
|
+
// `llmuxd token revoke <id>` form where positional[0] was "revoke").
|
|
336
|
+
const idPrefix = args.positional[0] === 'revoke' ? args.positional[1] : args.positional[0];
|
|
337
|
+
if (!idPrefix) throw new Error('token revoke requires an <id> (the 8-char prefix shown by `token show`)');
|
|
338
|
+
const ok = authStore.revokeAuthToken(idPrefix);
|
|
339
|
+
if (!ok) throw new Error(`no token with id "${idPrefix}"`);
|
|
340
|
+
console.log(`revoked ${idPrefix}`);
|
|
341
|
+
if (!authStore.authEnabled()) {
|
|
342
|
+
console.log('No tokens remain — auth is now disabled.');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function handleRespawn(args: ParsedArgs): void {
|
|
347
|
+
tmux.requireTmux();
|
|
348
|
+
const target = args.positional[0];
|
|
349
|
+
if (!target) throw new Error('respawn requires <session>');
|
|
350
|
+
|
|
351
|
+
const session = state.get(target);
|
|
352
|
+
if (!session) throw new Error(`no tracked session "${target}"`);
|
|
353
|
+
|
|
354
|
+
const agent = DEFAULT_AGENTS[session.agent];
|
|
355
|
+
if (!agent) throw new Error(`unknown agent "${session.agent}" — cannot respawn`);
|
|
356
|
+
if (!isAgentInstalled(agent)) {
|
|
357
|
+
throw new Error(`agent "${session.agent}" is not installed (looked for: ${agent.cmd})`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// If the session is still running, kill it first so respawn = restart
|
|
361
|
+
// with the persisted config (parity with the web API's respawnSession).
|
|
362
|
+
if (tmux.hasSession(target)) {
|
|
363
|
+
tmux.killSession(target);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
tmux.newSession({
|
|
367
|
+
name: session.name,
|
|
368
|
+
command: buildCommand(agent),
|
|
369
|
+
cwd: session.cwd,
|
|
370
|
+
env: { LLMUX_SESSION: session.name, LLMUX_AGENT: session.agent },
|
|
371
|
+
});
|
|
372
|
+
state.record({ ...session, createdAt: new Date().toISOString() });
|
|
373
|
+
console.log(`respawned ${target} (agent: ${session.agent}, cwd: ${session.cwd})`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function handleKill(args: ParsedArgs): void {
|
|
377
|
+
tmux.requireTmux();
|
|
378
|
+
const target = args.positional[0];
|
|
379
|
+
if (!target) throw new Error('kill requires <session> or `all`');
|
|
380
|
+
const cascade = Boolean(args.flags.cascade);
|
|
381
|
+
|
|
382
|
+
if (target === 'all') {
|
|
383
|
+
const all = state.list();
|
|
384
|
+
for (const s of all) {
|
|
385
|
+
tmux.killSession(s.name);
|
|
386
|
+
state.forget(s.name);
|
|
387
|
+
console.log(`killed ${s.name}`);
|
|
388
|
+
}
|
|
389
|
+
if (all.length === 0) console.log('no sessions to kill');
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const session = state.get(target);
|
|
394
|
+
if (!session) throw new Error(`no tracked session "${target}"`);
|
|
395
|
+
|
|
396
|
+
if (cascade) {
|
|
397
|
+
const queue: string[] = [target];
|
|
398
|
+
const killed = new Set<string>();
|
|
399
|
+
while (queue.length) {
|
|
400
|
+
const name = queue.shift()!;
|
|
401
|
+
if (killed.has(name)) continue;
|
|
402
|
+
for (const child of state.children(name)) queue.push(child.name);
|
|
403
|
+
tmux.killSession(name);
|
|
404
|
+
state.forget(name);
|
|
405
|
+
killed.add(name);
|
|
406
|
+
console.log(`killed ${name}`);
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
tmux.killSession(target);
|
|
412
|
+
state.forget(target);
|
|
413
|
+
console.log(`killed ${target}`);
|
|
414
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export interface ReachableAddress {
|
|
5
|
+
label: string;
|
|
6
|
+
url: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TAILSCALE_CGNAT = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
|
|
10
|
+
|
|
11
|
+
interface TailscaleServeConfig {
|
|
12
|
+
TCP?: Record<string, { HTTPS?: boolean; HTTP?: boolean }>;
|
|
13
|
+
Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ServeMatch {
|
|
17
|
+
hostname: string;
|
|
18
|
+
hasHttp: boolean;
|
|
19
|
+
hasHttps: boolean;
|
|
20
|
+
httpPort?: string;
|
|
21
|
+
httpsPort?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the tailnet hostname + which schemes have a `tailscale serve` config
|
|
26
|
+
* that proxies to our local port. Empty when tailscale isn't installed or no
|
|
27
|
+
* serve config matches.
|
|
28
|
+
*/
|
|
29
|
+
function detectTailscaleServe(port: number): ServeMatch | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const raw = execSync('tailscale serve status --json', {
|
|
32
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
33
|
+
timeout: 1500,
|
|
34
|
+
})
|
|
35
|
+
.toString()
|
|
36
|
+
.trim();
|
|
37
|
+
if (!raw) return undefined;
|
|
38
|
+
const config: TailscaleServeConfig = JSON.parse(raw);
|
|
39
|
+
const targets = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
|
40
|
+
let hostname: string | undefined;
|
|
41
|
+
let hasHttp = false;
|
|
42
|
+
let hasHttps = false;
|
|
43
|
+
let httpPort: string | undefined;
|
|
44
|
+
let httpsPort: string | undefined;
|
|
45
|
+
for (const [hostPort, web] of Object.entries(config.Web ?? {})) {
|
|
46
|
+
for (const handler of Object.values(web.Handlers ?? {})) {
|
|
47
|
+
if (!handler.Proxy || !targets.includes(handler.Proxy)) continue;
|
|
48
|
+
const [host, p] = hostPort.split(':');
|
|
49
|
+
if (!host || !p) continue;
|
|
50
|
+
hostname = host;
|
|
51
|
+
const tcp = (config.TCP ?? {})[p];
|
|
52
|
+
if (tcp?.HTTPS) {
|
|
53
|
+
hasHttps = true;
|
|
54
|
+
httpsPort = p;
|
|
55
|
+
} else if (tcp?.HTTP) {
|
|
56
|
+
hasHttp = true;
|
|
57
|
+
httpPort = p;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!hostname) return undefined;
|
|
62
|
+
return { hostname, hasHttp, hasHttps, ...(httpPort ? { httpPort } : {}), ...(httpsPort ? { httpsPort } : {}) };
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findTailscaleIp(): string | undefined {
|
|
69
|
+
for (const ifaces of Object.values(networkInterfaces())) {
|
|
70
|
+
for (const iface of ifaces ?? []) {
|
|
71
|
+
if (iface.family !== 'IPv4' || iface.internal) continue;
|
|
72
|
+
if (TAILSCALE_CGNAT.test(iface.address)) return iface.address;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build the address list shown on `llmuxd serve` startup. */
|
|
79
|
+
export function getAddresses(port: number): ReachableAddress[] {
|
|
80
|
+
const out: ReachableAddress[] = [];
|
|
81
|
+
const serve = detectTailscaleServe(port);
|
|
82
|
+
const tailscaleIp = findTailscaleIp();
|
|
83
|
+
|
|
84
|
+
// Tailscale HTTPS — only exists if `tailscale serve --https` is configured.
|
|
85
|
+
if (serve?.hasHttps) {
|
|
86
|
+
const portSuffix = serve.httpsPort && serve.httpsPort !== '443' ? `:${serve.httpsPort}` : '';
|
|
87
|
+
out.push({ label: 'Tailscale HTTPS', url: `https://${serve.hostname}${portSuffix}` });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Tailscale HTTP — one entry whenever tailscale is up. Prefer the friendlier
|
|
91
|
+
// hostname-via-serve form when `tailscale serve --http` is configured; fall
|
|
92
|
+
// back to the direct IP+port form. Same conceptual slot either way.
|
|
93
|
+
if (serve?.hasHttp) {
|
|
94
|
+
const portSuffix = serve.httpPort && serve.httpPort !== '80' ? `:${serve.httpPort}` : '';
|
|
95
|
+
out.push({ label: 'Tailscale HTTP', url: `http://${serve.hostname}${portSuffix}` });
|
|
96
|
+
} else if (tailscaleIp) {
|
|
97
|
+
out.push({ label: 'Tailscale HTTP', url: `http://${tailscaleIp}:${port}` });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Local
|
|
101
|
+
out.push({ label: 'Local', url: `http://localhost:${port}` });
|
|
102
|
+
|
|
103
|
+
// LAN — every non-internal, non-tailnet IPv4 interface
|
|
104
|
+
for (const ifaces of Object.values(networkInterfaces())) {
|
|
105
|
+
for (const iface of ifaces ?? []) {
|
|
106
|
+
if (iface.family !== 'IPv4' || iface.internal) continue;
|
|
107
|
+
if (TAILSCALE_CGNAT.test(iface.address)) continue;
|
|
108
|
+
out.push({ label: 'LAN', url: `http://${iface.address}:${port}` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface SessionState {
|
|
6
|
+
name: string;
|
|
7
|
+
agent: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
/** Override of the agent definition's default flags. undefined = use default. */
|
|
10
|
+
flags?: string;
|
|
11
|
+
/** Per-session environment variable overrides (merged over agent envDefaults). */
|
|
12
|
+
env?: Record<string, string>;
|
|
13
|
+
/** ID of the agent's prior conversation this session resumed from (if any). */
|
|
14
|
+
resumeFrom?: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
parent: string | null;
|
|
17
|
+
restart: 'always' | 'on-failure' | 'never';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface State {
|
|
21
|
+
version: 1;
|
|
22
|
+
sessions: Record<string, SessionState>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EMPTY: State = { version: 1, sessions: {} };
|
|
26
|
+
|
|
27
|
+
export function stateDir(): string {
|
|
28
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
29
|
+
return xdg ? join(xdg, 'llmuxd') : join(homedir(), '.local', 'state', 'llmuxd');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function statePath(): string {
|
|
33
|
+
return join(stateDir(), 'sessions.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function load(): State {
|
|
37
|
+
const path = statePath();
|
|
38
|
+
if (!existsSync(path)) return structuredClone(EMPTY);
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<State>;
|
|
41
|
+
if (parsed.version !== 1 || typeof parsed.sessions !== 'object' || parsed.sessions === null) {
|
|
42
|
+
return structuredClone(EMPTY);
|
|
43
|
+
}
|
|
44
|
+
return { version: 1, sessions: parsed.sessions as Record<string, SessionState> };
|
|
45
|
+
} catch {
|
|
46
|
+
return structuredClone(EMPTY);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function save(state: State): void {
|
|
51
|
+
const path = statePath();
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function record(session: SessionState): void {
|
|
57
|
+
const state = load();
|
|
58
|
+
state.sessions[session.name] = session;
|
|
59
|
+
save(state);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function forget(name: string): void {
|
|
63
|
+
const state = load();
|
|
64
|
+
delete state.sessions[name];
|
|
65
|
+
save(state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function get(name: string): SessionState | undefined {
|
|
69
|
+
return load().sessions[name];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function list(): SessionState[] {
|
|
73
|
+
return Object.values(load().sessions);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function children(parent: string): SessionState[] {
|
|
77
|
+
return list().filter((s) => s.parent === parent);
|
|
78
|
+
}
|