@cordfuse/llmux 0.11.0 → 0.12.1

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 CHANGED
@@ -1,14 +1,2910 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync } from "fs";
4
+ import { readFileSync as readFileSync5 } from "fs";
5
+ import { fileURLToPath as fileURLToPath2 } from "url";
6
+ import { dirname as dirname4, resolve as resolve3 } from "path";
7
+
8
+ // src/cli.ts
9
+ function parseArgs(argv, specs) {
10
+ const aliasMap = /* @__PURE__ */ new Map();
11
+ for (const [name, spec] of Object.entries(specs)) {
12
+ if (spec.alias) aliasMap.set(spec.alias, name);
13
+ }
14
+ const resolveName = (raw) => aliasMap.get(raw) ?? raw;
15
+ const positional = [];
16
+ const flags = {};
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const token = argv[i];
19
+ if (token === "--") {
20
+ positional.push(...argv.slice(i + 1));
21
+ break;
22
+ }
23
+ if (token.startsWith("--")) {
24
+ const body = token.slice(2);
25
+ const eq = body.indexOf("=");
26
+ const rawName = eq >= 0 ? body.slice(0, eq) : body;
27
+ const name = resolveName(rawName);
28
+ const spec = specs[name];
29
+ if (!spec) {
30
+ throw new Error(`unknown flag --${rawName}`);
31
+ }
32
+ if (spec.kind === "boolean") {
33
+ flags[name] = eq >= 0 ? body.slice(eq + 1) !== "false" : true;
34
+ } else {
35
+ if (eq >= 0) {
36
+ flags[name] = body.slice(eq + 1);
37
+ } else {
38
+ const next = argv[i + 1];
39
+ if (next === void 0 || next.startsWith("-")) {
40
+ throw new Error(`--${rawName} requires a value`);
41
+ }
42
+ flags[name] = next;
43
+ i++;
44
+ }
45
+ }
46
+ continue;
47
+ }
48
+ if (token.startsWith("-") && token.length > 1) {
49
+ const body = token.slice(1);
50
+ const name = resolveName(body);
51
+ const spec = specs[name];
52
+ if (!spec) {
53
+ throw new Error(`unknown flag -${body}`);
54
+ }
55
+ if (spec.kind === "boolean") {
56
+ flags[name] = true;
57
+ } else {
58
+ const next = argv[i + 1];
59
+ if (next === void 0 || next.startsWith("-")) {
60
+ throw new Error(`-${body} requires a value`);
61
+ }
62
+ flags[name] = next;
63
+ i++;
64
+ }
65
+ continue;
66
+ }
67
+ positional.push(token);
68
+ }
69
+ return { positional, flags };
70
+ }
71
+
72
+ // src/daemon/handlers.ts
73
+ import { existsSync as existsSync5 } from "fs";
74
+ import { resolve as resolve2 } from "path";
75
+ import { createInterface } from "readline";
76
+ import qrcodeTerminal from "qrcode-terminal";
77
+
78
+ // src/daemon/agents.ts
79
+ import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from "fs";
80
+ import { homedir } from "os";
81
+ import { join, delimiter } from "path";
82
+ function encodeClaudeCwd(cwd) {
83
+ return cwd.replace(/\//g, "-");
84
+ }
85
+ function extractClaudeUserText(msg) {
86
+ if (typeof msg !== "object" || msg === null) return void 0;
87
+ const content = msg.content;
88
+ if (typeof content === "string") return content;
89
+ if (Array.isArray(content)) {
90
+ for (const block of content) {
91
+ if (typeof block === "object" && block !== null) {
92
+ const b = block;
93
+ if (b.type === "text" && typeof b.text === "string") return b.text;
94
+ }
95
+ }
96
+ }
97
+ return void 0;
98
+ }
99
+ function looksLikeRealUserMessage(text) {
100
+ if (!text) return false;
101
+ if (text.startsWith("<local-command")) return false;
102
+ if (text.startsWith("<command-name>")) return false;
103
+ if (text.startsWith("<command-message>")) return false;
104
+ return true;
105
+ }
106
+ var claudeHistory = {
107
+ listConversations(cwd) {
108
+ const dir = join(homedir(), ".claude", "projects", encodeClaudeCwd(cwd));
109
+ if (!existsSync(dir)) return [];
110
+ let entries;
111
+ try {
112
+ entries = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
113
+ } catch {
114
+ return [];
115
+ }
116
+ const out = [];
117
+ for (const fname of entries) {
118
+ const id = fname.slice(0, -".jsonl".length);
119
+ const fpath = join(dir, fname);
120
+ try {
121
+ const raw = readFileSync(fpath, "utf8");
122
+ const lines = raw.split("\n").filter((l) => l.length > 0);
123
+ let title;
124
+ let firstTs;
125
+ let lastTs;
126
+ for (const line of lines) {
127
+ let evt;
128
+ try {
129
+ evt = JSON.parse(line);
130
+ } catch {
131
+ continue;
132
+ }
133
+ if (evt.timestamp) {
134
+ if (!firstTs) firstTs = evt.timestamp;
135
+ lastTs = evt.timestamp;
136
+ }
137
+ if (!title && evt.type === "user") {
138
+ const text = extractClaudeUserText(evt.message);
139
+ if (text && looksLikeRealUserMessage(text)) {
140
+ title = text.split("\n")[0].slice(0, 100).trim();
141
+ }
142
+ }
143
+ }
144
+ const stat = statSync(fpath);
145
+ out.push({
146
+ id,
147
+ title: title ?? "(no opener)",
148
+ startedAt: firstTs ?? new Date(stat.ctimeMs).toISOString(),
149
+ lastMessageAt: lastTs ?? new Date(stat.mtimeMs).toISOString(),
150
+ messageCount: lines.length
151
+ });
152
+ } catch {
153
+ }
154
+ }
155
+ return out.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt));
156
+ },
157
+ resumeFlag(id) {
158
+ return `--resume ${id}`;
159
+ }
160
+ };
161
+ var which = (cmd) => {
162
+ const pathDirs = (process.env.PATH ?? "").split(delimiter);
163
+ for (const dir of pathDirs) {
164
+ if (!dir) continue;
165
+ try {
166
+ accessSync(join(dir, cmd), constants.X_OK);
167
+ return true;
168
+ } catch {
169
+ }
170
+ }
171
+ return false;
172
+ };
173
+ var copilotInstalled = () => {
174
+ return existsSync(join(homedir(), ".local/share/gh/copilot"));
175
+ };
176
+ var DEFAULT_AGENTS = {
177
+ claude: { key: "claude", displayName: "Claude Code", cmd: "claude", flags: "--dangerously-skip-permissions", readyPrompt: "^>", installHint: "curl -fsSL https://claude.ai/install.sh | bash", docsUrl: "https://docs.claude.com/en/docs/claude-code/overview", history: claudeHistory },
178
+ codex: { key: "codex", displayName: "Codex CLI", cmd: "codex", flags: "--dangerously-bypass-approvals-and-sandbox", readyPrompt: "^>", installHint: "npm install -g @openai/codex", docsUrl: "https://github.com/openai/codex" },
179
+ agy: { key: "agy", displayName: "Antigravity CLI", cmd: "agy", flags: "--dangerously-skip-permissions", readyPrompt: "^agy>", installHint: "curl -fsSL https://antigravity.google/cli/install.sh | bash", docsUrl: "https://antigravity.google/docs/cli-install" },
180
+ gemini: { key: "gemini", displayName: "Gemini CLI", cmd: "gemini", flags: "--yolo", readyPrompt: "^>", installHint: "npm install -g @google/gemini-cli", docsUrl: "https://github.com/google-gemini/gemini-cli" },
181
+ qwen: { key: "qwen", displayName: "Qwen Code", cmd: "qwen", flags: "--yolo", readyPrompt: "^>", installHint: "npm install -g @qwen-code/qwen-code", docsUrl: "https://github.com/QwenLM/qwen-code" },
182
+ // OpenCode's --dangerously-skip-permissions only applies to `opencode run`
183
+ // (one-shot). The TUI default mode rejects it and exits — danger mode in
184
+ // the TUI is controlled via OPENCODE_YOLO=1 instead.
185
+ // No model flag set — OpenCode honors the operator's own config at
186
+ // ~/.config/opencode/opencode.json (provider + default model). Operator
187
+ // overrides per-spawn via the flags field if they want a specific model
188
+ // (e.g. `-m openrouter/anthropic/claude-sonnet-4.6` or
189
+ // `-m ollama/qwen2.5-coder:14b`).
190
+ opencode: { key: "opencode", displayName: "OpenCode", cmd: "opencode", readyPrompt: "^>", installHint: "curl -fsSL https://opencode.ai/install | bash", docsUrl: "https://opencode.ai", envDefaults: { OPENCODE_YOLO: "1" } },
191
+ amp: { key: "amp", displayName: "Sourcegraph Amp", cmd: "amp", flags: "--dangerously-allow-all", readyPrompt: "^>", installHint: "npm install -g @sourcegraph/amp", docsUrl: "https://ampcode.com/manual" },
192
+ grok: { key: "grok", displayName: "Grok Build CLI", cmd: "grok", flags: "--always-approve", readyPrompt: "^grok>", installHint: "curl -fsSL https://x.ai/cli/install.sh | bash", docsUrl: "https://x.ai/cli" },
193
+ aider: { key: "aider", displayName: "Aider", cmd: "aider", flags: "--yes-always --model claude-opus-4-6", readyPrompt: "^> $", installHint: "python -m pip install aider-chat", docsUrl: "https://aider.chat" },
194
+ continue: { key: "continue", displayName: "Continue CLI", cmd: "cn", flags: "--auto", readyPrompt: "^>", installHint: "npm install -g @continuedev/cli", docsUrl: "https://docs.continue.dev/guides/cli" },
195
+ kiro: { key: "kiro", displayName: "Kiro CLI", cmd: "kiro-cli", flags: "--trust-all-tools", readyPrompt: "^>", installHint: "brew install kiro # or see docs for Linux/Windows", docsUrl: "https://kiro.dev/docs/cli/installation/" },
196
+ cursor: { key: "cursor", displayName: "Cursor CLI", cmd: "cursor-agent", readyPrompt: "^>", installHint: "curl https://cursor.com/install -fsSL | bash", docsUrl: "https://cursor.com/docs/cli/installation" },
197
+ plandex: { key: "plandex", displayName: "Plandex", cmd: "plandex", readyPrompt: "^>", installHint: "curl -fsSL https://plandex.ai/install.sh | bash", docsUrl: "https://docs.plandex.ai" },
198
+ // goose has no launch flag — auto-approve is controlled via GOOSE_MODE=auto.
199
+ goose: { key: "goose", displayName: "Goose", cmd: "goose", readyPrompt: "Goose\u276F", installHint: "curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash", docsUrl: "https://block.github.io/goose", envDefaults: { GOOSE_MODE: "auto" } },
200
+ copilot: { key: "copilot", displayName: "GitHub Copilot CLI", cmd: "gh copilot", readyPrompt: "\u25CF", detectInstalled: copilotInstalled, installHint: 'gh copilot suggest "hi" # gh prerequisite; first run downloads', docsUrl: "https://docs.github.com/en/copilot/how-tos/use-copilot-in-the-cli" }
201
+ };
202
+ function isAgentInstalled(agent) {
203
+ if (agent.detectInstalled) return agent.detectInstalled();
204
+ const head = agent.cmd.split(/\s+/)[0];
205
+ return which(head);
206
+ }
207
+
208
+ // src/daemon/state.ts
209
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
210
+ import { homedir as homedir2 } from "os";
211
+ import { dirname, join as join2 } from "path";
212
+ var EMPTY = { version: 1, sessions: {} };
213
+ function stateDir() {
214
+ const xdg = process.env.XDG_STATE_HOME;
215
+ return xdg ? join2(xdg, "llmuxd") : join2(homedir2(), ".local", "state", "llmuxd");
216
+ }
217
+ function statePath() {
218
+ return join2(stateDir(), "sessions.json");
219
+ }
220
+ function load() {
221
+ const path = statePath();
222
+ if (!existsSync2(path)) return structuredClone(EMPTY);
223
+ try {
224
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
225
+ if (parsed.version !== 1 || typeof parsed.sessions !== "object" || parsed.sessions === null) {
226
+ return structuredClone(EMPTY);
227
+ }
228
+ return { version: 1, sessions: parsed.sessions };
229
+ } catch {
230
+ return structuredClone(EMPTY);
231
+ }
232
+ }
233
+ function save(state) {
234
+ const path = statePath();
235
+ mkdirSync(dirname(path), { recursive: true });
236
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
237
+ }
238
+ function record(session) {
239
+ const state = load();
240
+ state.sessions[session.name] = session;
241
+ save(state);
242
+ }
243
+ function forget(name) {
244
+ const state = load();
245
+ delete state.sessions[name];
246
+ save(state);
247
+ }
248
+ function get(name) {
249
+ return load().sessions[name];
250
+ }
251
+ function list() {
252
+ return Object.values(load().sessions);
253
+ }
254
+ function children(parent) {
255
+ return list().filter((s) => s.parent === parent);
256
+ }
257
+
258
+ // src/daemon/tmux.ts
259
+ import { spawnSync } from "child_process";
260
+ var TMUX_FORMAT = "#{session_name} #{session_windows} #{session_attached} #{session_created}";
261
+ function requireTmux() {
262
+ const r = spawnSync("tmux", ["-V"], { stdio: "pipe" });
263
+ if (r.status !== 0) {
264
+ throw new Error("tmux is required but was not found on PATH");
265
+ }
266
+ }
267
+ function listSessions() {
268
+ const r = spawnSync("tmux", ["list-sessions", "-F", TMUX_FORMAT], { stdio: "pipe" });
269
+ if (r.status !== 0) return [];
270
+ return r.stdout.toString().trim().split("\n").filter(Boolean).map((line) => {
271
+ const [name, windows, attached, created] = line.split(" ");
272
+ return {
273
+ name: name ?? "",
274
+ windows: Number(windows ?? "0"),
275
+ attached: attached === "1",
276
+ created: new Date(Number(created ?? "0") * 1e3)
277
+ };
278
+ });
279
+ }
280
+ function hasSession(name) {
281
+ const r = spawnSync("tmux", ["has-session", "-t", `=${name}`], { stdio: "pipe" });
282
+ return r.status === 0;
283
+ }
284
+ function newSession(opts) {
285
+ if (hasSession(opts.name)) {
286
+ throw new Error(`tmux session "${opts.name}" already exists`);
287
+ }
288
+ const args = ["new-session", "-d", "-s", opts.name];
289
+ if (opts.cwd) args.push("-c", opts.cwd);
290
+ args.push(opts.command);
291
+ const env = opts.env ? { ...process.env, ...opts.env } : process.env;
292
+ const r = spawnSync("tmux", args, { stdio: "pipe", env });
293
+ if (r.status !== 0) {
294
+ throw new Error(`tmux new-session failed: ${r.stderr.toString().trim() || `exit ${r.status}`}`);
295
+ }
296
+ }
297
+ function sendKeys(name, text, opts = {}) {
298
+ if (!hasSession(name)) {
299
+ throw new Error(`tmux session "${name}" not found`);
300
+ }
301
+ const literal = spawnSync("tmux", ["send-keys", "-t", name, "-l", text], { stdio: "pipe" });
302
+ if (literal.status !== 0) {
303
+ throw new Error(`tmux send-keys failed: ${literal.stderr.toString().trim() || `exit ${literal.status}`}`);
304
+ }
305
+ if (opts.enter) {
306
+ const enter = spawnSync("tmux", ["send-keys", "-t", name, "Enter"], { stdio: "pipe" });
307
+ if (enter.status !== 0) {
308
+ throw new Error(`tmux send-keys Enter failed: ${enter.stderr.toString().trim() || `exit ${enter.status}`}`);
309
+ }
310
+ }
311
+ }
312
+ function killSession(name) {
313
+ if (!hasSession(name)) return;
314
+ const r = spawnSync("tmux", ["kill-session", "-t", name], { stdio: "pipe" });
315
+ if (r.status !== 0) {
316
+ throw new Error(`tmux kill-session failed: ${r.stderr.toString().trim() || `exit ${r.status}`}`);
317
+ }
318
+ }
319
+ function renameSession(oldName, newName) {
320
+ if (!hasSession(oldName)) return;
321
+ const r = spawnSync("tmux", ["rename-session", "-t", oldName, newName], { stdio: "pipe" });
322
+ if (r.status !== 0) {
323
+ throw new Error(`tmux rename-session failed: ${r.stderr.toString().trim() || `exit ${r.status}`}`);
324
+ }
325
+ }
326
+ function attachOrSwitch(name) {
327
+ if (!hasSession(name)) {
328
+ throw new Error(`tmux session "${name}" not found`);
329
+ }
330
+ const inTmux = Boolean(process.env.TMUX);
331
+ const verb = inTmux ? "switch-client" : "attach-session";
332
+ const r = spawnSync("tmux", [verb, "-t", name], { stdio: "inherit" });
333
+ if (r.status !== 0) {
334
+ throw new Error(`tmux ${verb} exited with status ${r.status}`);
335
+ }
336
+ }
337
+
338
+ // src/daemon/auth-store.ts
339
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
340
+ import { dirname as dirname2, join as join3 } from "path";
341
+
342
+ // src/daemon/token.ts
343
+ import { randomBytes } from "crypto";
344
+ var TOKEN_PREFIX = "sas_";
345
+ function generateToken() {
346
+ return TOKEN_PREFIX + randomBytes(32).toString("base64url");
347
+ }
348
+ function tokenId(token) {
349
+ return token.startsWith(TOKEN_PREFIX) ? token.slice(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 8) : token.slice(0, 8);
350
+ }
351
+
352
+ // src/daemon/auth-store.ts
353
+ var EMPTY2 = { version: 1, tokens: [] };
354
+ function authPath() {
355
+ return join3(stateDir(), "auth.json");
356
+ }
357
+ function load2() {
358
+ const p = authPath();
359
+ if (!existsSync3(p)) return structuredClone(EMPTY2);
360
+ try {
361
+ const parsed = JSON.parse(readFileSync3(p, "utf8"));
362
+ if (parsed.version === 1 && Array.isArray(parsed.tokens)) {
363
+ return { version: 1, tokens: parsed.tokens };
364
+ }
365
+ } catch {
366
+ }
367
+ return structuredClone(EMPTY2);
368
+ }
369
+ function save2(file) {
370
+ const p = authPath();
371
+ mkdirSync2(dirname2(p), { recursive: true });
372
+ writeFileSync2(p, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
373
+ }
374
+ function listAuthTokens() {
375
+ return load2().tokens;
376
+ }
377
+ function createAuthToken(opts = {}) {
378
+ const token = generateToken();
379
+ const rec = {
380
+ id: tokenId(token),
381
+ token,
382
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
383
+ ...opts.name !== void 0 ? { name: opts.name } : {},
384
+ ...opts.expiresAt !== void 0 ? { expiresAt: opts.expiresAt } : {}
385
+ };
386
+ const file = load2();
387
+ file.tokens.push(rec);
388
+ save2(file);
389
+ return rec;
390
+ }
391
+ function revokeAuthToken(idPrefix) {
392
+ const file = load2();
393
+ const before = file.tokens.length;
394
+ file.tokens = file.tokens.filter((t) => t.id !== idPrefix);
395
+ if (file.tokens.length === before) return false;
396
+ save2(file);
397
+ return true;
398
+ }
399
+ function validateAuthToken(candidate) {
400
+ if (!candidate) return false;
401
+ const now = Date.now();
402
+ return load2().tokens.some((t) => {
403
+ if (t.token !== candidate) return false;
404
+ if (t.expiresAt && new Date(t.expiresAt).getTime() < now) return false;
405
+ return true;
406
+ });
407
+ }
408
+ function authEnabled() {
409
+ return load2().tokens.length > 0;
410
+ }
411
+
412
+ // src/daemon/web/server.ts
413
+ import { createServer } from "http";
414
+ import { readFileSync as readFileSync4, existsSync as existsSync4 } from "fs";
5
415
  import { fileURLToPath } from "url";
6
- import { dirname, resolve } from "path";
416
+ import { dirname as dirname3, resolve } from "path";
417
+ import { WebSocketServer } from "ws";
418
+ import * as pty from "node-pty";
419
+
420
+ // src/daemon/net.ts
421
+ import { networkInterfaces } from "os";
422
+ import { execSync } from "child_process";
423
+ var TAILSCALE_CGNAT = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
424
+ function detectTailscaleServe(port) {
425
+ try {
426
+ const raw = execSync("tailscale serve status --json", {
427
+ stdio: ["ignore", "pipe", "ignore"],
428
+ timeout: 1500
429
+ }).toString().trim();
430
+ if (!raw) return void 0;
431
+ const config = JSON.parse(raw);
432
+ const targets = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
433
+ let hostname;
434
+ let hasHttp = false;
435
+ let hasHttps = false;
436
+ let httpPort;
437
+ let httpsPort;
438
+ for (const [hostPort, web] of Object.entries(config.Web ?? {})) {
439
+ for (const handler of Object.values(web.Handlers ?? {})) {
440
+ if (!handler.Proxy || !targets.includes(handler.Proxy)) continue;
441
+ const [host, p] = hostPort.split(":");
442
+ if (!host || !p) continue;
443
+ hostname = host;
444
+ const tcp = (config.TCP ?? {})[p];
445
+ if (tcp?.HTTPS) {
446
+ hasHttps = true;
447
+ httpsPort = p;
448
+ } else if (tcp?.HTTP) {
449
+ hasHttp = true;
450
+ httpPort = p;
451
+ }
452
+ }
453
+ }
454
+ if (!hostname) return void 0;
455
+ return { hostname, hasHttp, hasHttps, ...httpPort ? { httpPort } : {}, ...httpsPort ? { httpsPort } : {} };
456
+ } catch {
457
+ return void 0;
458
+ }
459
+ }
460
+ function findTailscaleIp() {
461
+ for (const ifaces of Object.values(networkInterfaces())) {
462
+ for (const iface of ifaces ?? []) {
463
+ if (iface.family !== "IPv4" || iface.internal) continue;
464
+ if (TAILSCALE_CGNAT.test(iface.address)) return iface.address;
465
+ }
466
+ }
467
+ return void 0;
468
+ }
469
+ function getAddresses(port) {
470
+ const out = [];
471
+ const serve = detectTailscaleServe(port);
472
+ const tailscaleIp = findTailscaleIp();
473
+ if (serve?.hasHttps) {
474
+ const portSuffix = serve.httpsPort && serve.httpsPort !== "443" ? `:${serve.httpsPort}` : "";
475
+ out.push({ label: "Tailscale HTTPS", url: `https://${serve.hostname}${portSuffix}` });
476
+ }
477
+ if (serve?.hasHttp) {
478
+ const portSuffix = serve.httpPort && serve.httpPort !== "80" ? `:${serve.httpPort}` : "";
479
+ out.push({ label: "Tailscale HTTP", url: `http://${serve.hostname}${portSuffix}` });
480
+ } else if (tailscaleIp) {
481
+ out.push({ label: "Tailscale HTTP", url: `http://${tailscaleIp}:${port}` });
482
+ }
483
+ out.push({ label: "Local", url: `http://localhost:${port}` });
484
+ for (const ifaces of Object.values(networkInterfaces())) {
485
+ for (const iface of ifaces ?? []) {
486
+ if (iface.family !== "IPv4" || iface.internal) continue;
487
+ if (TAILSCALE_CGNAT.test(iface.address)) continue;
488
+ out.push({ label: "LAN", url: `http://${iface.address}:${port}` });
489
+ }
490
+ }
491
+ return out;
492
+ }
493
+
494
+ // src/daemon/web/server.ts
495
+ function readDaemonVersion() {
496
+ try {
497
+ const here = dirname3(fileURLToPath(import.meta.url));
498
+ for (const candidate of [
499
+ resolve(here, "../../package.json"),
500
+ resolve(here, "../package.json"),
501
+ resolve(here, "./package.json")
502
+ ]) {
503
+ try {
504
+ const pkg = JSON.parse(readFileSync4(candidate, "utf8"));
505
+ if (pkg.name === "@cordfuse/llmux" && typeof pkg.version === "string") return pkg.version;
506
+ } catch {
507
+ }
508
+ }
509
+ } catch {
510
+ }
511
+ return "unknown";
512
+ }
513
+ var DAEMON_VERSION = readDaemonVersion();
514
+ var XTERM_CSS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css";
515
+ var XTERM_JS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js";
516
+ var XTERM_FIT_JS = "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js";
517
+ function escapeHtml(s) {
518
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
519
+ }
520
+ function shortenCwd(cwd) {
521
+ const home = process.env.HOME;
522
+ if (!home) return cwd;
523
+ if (cwd === home) return "~";
524
+ if (cwd.startsWith(home + "/")) return "~" + cwd.slice(home.length);
525
+ return cwd;
526
+ }
527
+ function expandTilde(p) {
528
+ const home = process.env.HOME;
529
+ if (!home) return p;
530
+ if (p === "~") return home;
531
+ if (p.startsWith("~/")) return home + p.slice(1);
532
+ return p;
533
+ }
534
+ function listSessionViews() {
535
+ const tracked = list();
536
+ const live = new Set(listSessions().map((s) => s.name));
537
+ return tracked.map((s) => viewOf(s, live.has(s.name))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
538
+ }
539
+ var FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0b0c10"/><rect x="5" y="5" width="9" height="9" fill="#7cc4ff"/><rect x="18" y="5" width="9" height="9" fill="#7cc4ff"/><rect x="5" y="18" width="9" height="9" fill="#7cc4ff"/><rect x="18" y="18" width="9" height="9" fill="#7cc4ff"/></svg>`;
540
+ var FAVICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}`;
541
+ function pickerPage() {
542
+ const sessions = listSessionViews();
543
+ return `<!doctype html><html lang="en"><head>
544
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
545
+ <title>LLMUX: Sessions</title>
546
+ <link rel="icon" href="${FAVICON_DATA_URL}">
547
+ <link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
548
+ <style>
549
+ :root{color-scheme:dark}
550
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px;overflow-x:hidden}
551
+ body{padding:18px 16px 80px;max-width:980px;margin:0 auto;box-sizing:border-box}
552
+ header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
553
+ h1{font-size:18px;margin:0}
554
+ h1 .brand{color:#7cc4ff;letter-spacing:.08em;font-weight:600}
555
+ #meta{color:#7a7f87;font-size:11px;display:flex;gap:10px;align-items:center}
556
+ #refresh-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#7ee787;transition:background .25s;box-shadow:0 0 6px #7ee78766}
557
+ #refresh-dot.stale{background:#9aa0a6;box-shadow:none}
558
+ #refresh-dot.error{background:#f85149;box-shadow:0 0 6px #f8514966}
559
+ table{border-collapse:collapse;width:100%}
560
+ thead{display:table-header-group}
561
+ th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #1f2329;vertical-align:middle}
562
+ th{font-weight:500;color:#9aa0a6;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
563
+ a.session-link{color:#7cc4ff;text-decoration:none}
564
+ a.session-link:hover{text-decoration:underline}
565
+ .name{font-weight:600}
566
+ .started{color:#7a7f87;font-size:11px;margin-top:2px;display:block}
567
+ .state-running{color:#7ee787}
568
+ .state-exited{color:#7a7f87}
569
+ .cwd{color:#c9d1d9;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
570
+ .cwd code{unicode-bidi:embed;direction:ltr}
571
+ .cwd-col{max-width:0}
572
+ .actions{text-align:right;white-space:nowrap}
573
+ .actions button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:5px 9px;font:12px ui-monospace,monospace;cursor:pointer;margin-left:4px;display:inline-flex;align-items:center;gap:4px;transition:background 150ms ease,border-color 150ms ease,transform 100ms ease}
574
+ .actions button:hover{background:#252b34;border-color:#3a414b}
575
+ .actions button:active{transform:scale(.94)}
576
+ .actions button.respawn{color:#7cc4ff;border-color:#2d4a66}
577
+ .actions button.edit{color:#d29922;border-color:#574122}
578
+ .actions button.kill{color:#f85149;border-color:#4a2329}
579
+ .actions button.resume-btn{color:#a371f7;border-color:#3c2a59}
580
+ .actions button .icon{font-size:13px;line-height:1;display:inline-block;vertical-align:middle}
581
+ .actions button:disabled{opacity:.5;cursor:wait}
582
+ .empty{color:#7a7f87;padding:18px;text-align:center;border:1px dashed #1f2329;border-radius:8px}
583
+ .empty code{color:#c9d1d9;background:#11141a;padding:2px 6px;border-radius:4px}
584
+ tbody tr{transition:background 150ms ease}
585
+ tbody tr:hover{background:#0e1116}
586
+ #new-btn{background:#1c2128;color:#7cc4ff;border:1px solid #2d4a66;border-radius:6px;padding:6px 10px;font:12px ui-monospace,monospace;cursor:pointer}
587
+ #new-btn:hover{background:#252b34}
588
+ #new-form{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:14px;margin-bottom:14px;max-height:0;opacity:0;overflow:hidden;padding-top:0;padding-bottom:0;border-width:0;margin-bottom:0;transition:max-height 220ms ease,opacity 180ms ease,padding-top 220ms ease,padding-bottom 220ms ease,border-width 220ms ease,margin-bottom 220ms ease}
589
+ #new-form.open{max-height:900px;opacity:1;padding:14px;border-width:1px;margin-bottom:14px}
590
+ #new-form .form-title{margin:0 0 12px;font-size:13px;color:#c9d1d9;font-weight:600}
591
+ #new-form select:disabled{opacity:.6;cursor:not-allowed}
592
+ #new-form .field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
593
+ #new-form label{font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em}
594
+ #new-form select,#new-form input,#new-form textarea{background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 10px;font:13px ui-monospace,monospace;outline:none;width:100%;box-sizing:border-box;resize:vertical}
595
+ #new-form select:focus,#new-form input:focus,#new-form textarea:focus{border-color:#2d4a66}
596
+ #new-form .actions{display:flex;gap:8px;justify-content:flex-end}
597
+ #new-form button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:12px ui-monospace,monospace;cursor:pointer}
598
+ #new-form button.primary{color:#7cc4ff;border-color:#2d4a66}
599
+ #new-form button:hover{background:#252b34}
600
+ #new-form button:disabled{opacity:.5;cursor:wait}
601
+ #new-form .hint{font-size:11px;color:#7a7f87;margin-top:-4px;margin-bottom:10px}
602
+ footer{position:fixed;bottom:0;left:0;right:0;background:#0b0c10;border-top:1px solid #1f2329;padding:10px 16px;font-size:11px;color:#7a7f87;display:flex;justify-content:space-between;gap:10px}
603
+ footer .warn{color:#d29922}
604
+ footer .ok{color:#7ee787}
605
+ #toast{position:fixed;bottom:50px;left:50%;transform:translateX(-50%);background:#11141a;border:1px solid #1f2329;color:#e6e8eb;padding:8px 14px;border-radius:6px;font-size:12px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:30}
606
+ #toast.show{opacity:1}
607
+ #toast.error{border-color:#4a2329;color:#f85149}
608
+ #confirm-modal{position:fixed;inset:0;background:rgba(11,12,16,.85);display:flex;align-items:center;justify-content:center;z-index:60;padding:20px;opacity:0;visibility:hidden;transition:opacity 160ms ease,visibility 0s 160ms}
609
+ #confirm-modal.open{opacity:1;visibility:visible;transition:opacity 160ms ease}
610
+ #confirm-modal .panel{transform:translateY(8px) scale(.97);transition:transform 200ms ease}
611
+ #confirm-modal.open .panel{transform:translateY(0) scale(1)}
612
+ #confirm-modal .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:20px;max-width:360px;width:100%}
613
+ #confirm-modal h3{margin:0 0 8px;font-size:15px;color:#e6e8eb}
614
+ #confirm-modal p{margin:0 0 16px;font-size:13px;color:#c9d1d9;line-height:1.5}
615
+ #confirm-modal p code{color:#7cc4ff;background:#0b0c10;padding:2px 5px;border-radius:3px}
616
+ #confirm-modal .actions{display:flex;gap:8px;justify-content:flex-end}
617
+ #confirm-modal button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer}
618
+ #confirm-modal button.danger{color:#f85149;border-color:#4a2329}
619
+ #confirm-modal button.danger:hover{background:#2a1c1f}
620
+ #confirm-modal button:disabled{opacity:.5;cursor:wait}
621
+ .help-btn{background:#1c2128;color:#7cc4ff;border:1px solid #2d4a66;border-radius:50%;width:18px;height:18px;font:11px ui-monospace,monospace;cursor:pointer;padding:0;margin-left:4px;display:inline-flex;align-items:center;justify-content:center;vertical-align:middle}
622
+ .help-btn:hover{background:#252b34}
623
+ #agents-modal{position:fixed;inset:0;background:rgba(11,12,16,.85);display:flex;align-items:center;justify-content:center;z-index:40;padding:20px;opacity:0;visibility:hidden;transition:opacity 160ms ease,visibility 0s 160ms}
624
+ #agents-modal.open{opacity:1;visibility:visible;transition:opacity 160ms ease}
625
+ #agents-modal .panel{transform:translateY(8px) scale(.97);transition:transform 200ms ease}
626
+ #agents-modal.open .panel{transform:translateY(0) scale(1)}
627
+ #agents-modal .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:18px;max-width:520px;width:100%;max-height:80vh;display:flex;flex-direction:column}
628
+ #agents-modal h3{margin:0 0 4px;font-size:15px;color:#e6e8eb}
629
+ #agents-modal .sub{margin:0 0 14px;font-size:11px;color:#7a7f87}
630
+ #agents-list{flex:1 1 auto;overflow-y:auto;margin-bottom:12px;min-height:0}
631
+ #agents-list .agent{padding:10px 0;border-bottom:1px solid #1f2329}
632
+ #agents-list .agent:last-child{border-bottom:none}
633
+ #agents-list .agent-head{display:flex;align-items:center;gap:8px;margin-bottom:4px}
634
+ #agents-list .agent-name{font-weight:600;color:#e6e8eb;font-size:13px}
635
+ #agents-list .agent-status{font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid}
636
+ #agents-list .agent-status.ok{color:#7ee787;border-color:#235828;background:#0d1f10}
637
+ #agents-list .agent-status.miss{color:#7a7f87;border-color:#262c34;background:#0e1116}
638
+ #agents-list .agent-install{font:11px ui-monospace,monospace;color:#c9d1d9;background:#0b0c10;border:1px solid #1f2329;border-radius:4px;padding:6px 8px;margin-top:4px;word-break:break-all}
639
+ #agents-list .agent-docs{font-size:11px;color:#7cc4ff;text-decoration:none;margin-top:4px;display:inline-block}
640
+ #agents-list .agent-docs:hover{text-decoration:underline}
641
+ #agents-modal .actions{display:flex;justify-content:flex-end}
642
+ #agents-modal button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer}
643
+ #agents-modal button:hover{background:#252b34}
644
+ #convs-modal{position:fixed;inset:0;background:rgba(11,12,16,.85);display:flex;align-items:center;justify-content:center;z-index:40;padding:20px;opacity:0;visibility:hidden;transition:opacity 160ms ease,visibility 0s 160ms}
645
+ #convs-modal.open{opacity:1;visibility:visible;transition:opacity 160ms ease}
646
+ #convs-modal .panel{transform:translateY(8px) scale(.97);transition:transform 200ms ease}
647
+ #convs-modal.open .panel{transform:translateY(0) scale(1)}
648
+ #convs-list .conv{transition:background 120ms ease}
649
+ #convs-modal .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:18px;max-width:560px;width:100%;max-height:80vh;display:flex;flex-direction:column}
650
+ #convs-modal h3{margin:0 0 4px;font-size:15px;color:#e6e8eb}
651
+ #convs-modal .sub{margin:0 0 14px;font-size:11px;color:#7a7f87}
652
+ #convs-list{flex:1 1 auto;overflow-y:auto;margin-bottom:12px;min-height:0}
653
+ #convs-list .conv{padding:10px 0;border-bottom:1px solid #1f2329;cursor:pointer;display:block;width:100%;text-align:left;background:transparent;border-left:none;border-right:none;border-top:none;color:inherit;font-family:inherit;font-size:inherit}
654
+ #convs-list .conv:last-child{border-bottom:none}
655
+ #convs-list .conv:hover{background:#1a1d23}
656
+ #convs-list .conv-title{font-size:13px;color:#e6e8eb;font-weight:500;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
657
+ #convs-list .conv-meta{font-size:11px;color:#7a7f87;margin-top:2px;display:flex;gap:8px}
658
+ #convs-list .conv-meta .when{color:#9aa0a6}
659
+ #convs-list .conv-meta .count{color:#7a7f87}
660
+ #convs-list .conv-current{color:#a371f7;font-weight:600}
661
+ #convs-modal .actions{display:flex;justify-content:flex-end}
662
+ #convs-modal button.close-btn{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer}
663
+ #convs-modal button.close-btn:hover{background:#252b34}
664
+ /* Mobile: hide cwd column, show under name */
665
+ @media (max-width: 600px){
666
+ body{padding:14px 8px 72px}
667
+ th.cwd-col,td.cwd-col{display:none}
668
+ .name-block .cwd{display:block;margin-top:3px;max-width:100%}
669
+ th,td{padding:8px 4px;font-size:13px}
670
+ .name-block{max-width:42vw}
671
+ td.actions{white-space:nowrap;text-align:right;padding-right:0}
672
+ /* Buttons collapse to icon-only \u2014 long-press surfaces title= for label. */
673
+ .actions button .label{display:none}
674
+ .actions button{padding:5px 6px;min-width:28px;justify-content:center;margin-left:2px}
675
+ }
676
+ @media (min-width: 601px){
677
+ .name-block .cwd{display:none}
678
+ }
679
+ </style></head>
680
+ <body>
681
+ <header>
682
+ <h1><span class="brand">LLMUX</span>: Sessions</h1>
683
+ <div id="meta">
684
+ <button id="new-btn" type="button">+ new session</button>
685
+ <span id="refresh-dot" title="updates every 3s"></span>
686
+ <span id="refresh-label">live</span>
687
+ <span>\xB7</span>
688
+ <span>v${escapeHtml(DAEMON_VERSION)}</span>
689
+ </div>
690
+ </header>
691
+ <div id="new-form" aria-hidden="true">
692
+ <h3 id="new-title" class="form-title">new session</h3>
693
+ <form id="new-session-form">
694
+ <div class="field">
695
+ <label for="new-agent">agent <button type="button" id="agent-help-btn" class="help-btn" title="Show all supported agents">?</button></label>
696
+ <select id="new-agent" required></select>
697
+ </div>
698
+ <div class="field">
699
+ <label for="new-name">name</label>
700
+ <input id="new-name" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="(defaults to agent key)" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]*">
701
+ </div>
702
+ <div class="field">
703
+ <label for="new-cwd">cwd</label>
704
+ <input id="new-cwd" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="(defaults to $HOME on the daemon host)">
705
+ </div>
706
+ <div id="new-cwd-hint" class="hint" hidden>cwd changes apply immediately \u2014 if the session is running, it'll be killed and respawned in the new directory</div>
707
+ <div class="field">
708
+ <label for="new-flags">flags</label>
709
+ <input id="new-flags" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
710
+ </div>
711
+ <div id="new-flags-hint" class="hint" hidden></div>
712
+ <div class="field">
713
+ <label for="new-env">env vars</label>
714
+ <textarea id="new-env" rows="3" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="KEY=VALUE one per line"></textarea>
715
+ </div>
716
+ <div id="new-env-hint" class="hint" hidden></div>
717
+ <div class="actions">
718
+ <button type="button" id="new-cancel">cancel</button>
719
+ <button type="submit" class="primary" id="new-submit">spawn</button>
720
+ </div>
721
+ </form>
722
+ </div>
723
+ <div id="list-container">${renderSessionTable(sessions)}</div>
724
+ <div id="toast"></div>
725
+ <div id="confirm-modal" aria-hidden="true">
726
+ <div class="panel">
727
+ <h3 id="confirm-title">Kill session?</h3>
728
+ <p id="confirm-body"></p>
729
+ <div class="actions">
730
+ <button type="button" id="confirm-cancel">cancel</button>
731
+ <button type="button" class="danger" id="confirm-ok">kill</button>
732
+ </div>
733
+ </div>
734
+ </div>
735
+ <div id="agents-modal" aria-hidden="true">
736
+ <div class="panel">
737
+ <h3>Supported agents</h3>
738
+ <p class="sub">Only installed agents appear in the spawn dropdown. Install the others on the daemon host to enable them.</p>
739
+ <div id="agents-list">loading\u2026</div>
740
+ <div class="actions">
741
+ <button type="button" id="agents-close">close</button>
742
+ </div>
743
+ </div>
744
+ </div>
745
+ <div id="convs-modal" aria-hidden="true">
746
+ <div class="panel">
747
+ <h3 id="convs-title">Past conversations</h3>
748
+ <p class="sub" id="convs-sub">Pick one to resume. The current session will be killed and respawned with the agent's resume flag.</p>
749
+ <div id="convs-list">loading\u2026</div>
750
+ <div class="actions">
751
+ <button type="button" id="convs-close">cancel</button>
752
+ </div>
753
+ </div>
754
+ </div>
755
+ <footer>
756
+ <span>llmux v${escapeHtml(DAEMON_VERSION)}</span>
757
+ ${authEnabled() ? `<span class="ok">\u2713 auth required \u2014 ${listAuthTokens().length} active token${listAuthTokens().length === 1 ? "" : "s"}</span>` : `<span class="warn">\u26A0 no auth \u2014 anyone on the network can attach</span>`}
758
+ </footer>
759
+ <script>
760
+ (function(){
761
+ const container = document.getElementById('list-container');
762
+ const dot = document.getElementById('refresh-dot');
763
+ const label = document.getElementById('refresh-label');
764
+ const toast = document.getElementById('toast');
765
+ let pollTimer = null;
766
+ let lastFetch = 0;
767
+
768
+ function showToast(msg, isError){
769
+ toast.textContent = msg;
770
+ toast.classList.toggle('error', !!isError);
771
+ toast.classList.add('show');
772
+ setTimeout(function(){ toast.classList.remove('show'); }, 2200);
773
+ }
774
+
775
+ function escapeHtml(s){
776
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
777
+ }
778
+
779
+ function relativeTime(iso){
780
+ const ms = Date.now() - new Date(iso).getTime();
781
+ if (isNaN(ms) || ms < 0) return '';
782
+ if (ms < 60000) return 'just now';
783
+ const m = Math.floor(ms/60000);
784
+ if (m < 60) return m + 'm ago';
785
+ const h = Math.floor(m/60);
786
+ if (h < 24) return h + 'h ago';
787
+ const d = Math.floor(h/24);
788
+ return d + 'd ago';
789
+ }
790
+ function rowHtml(s){
791
+ const cls = 'state-' + s.status;
792
+ const linkOpen = s.status === 'running' ? '<a class="session-link" href="/session/' + encodeURIComponent(s.name) + '">' : '<a class="session-link" href="/session/' + encodeURIComponent(s.name) + '" title="session is not running \u2014 click to respawn">';
793
+ const respawnText = s.status === 'running' ? 'restart' : 'respawn';
794
+ const respawnTitle = s.status === 'running' ? 'kill + relaunch with the persisted config (use after edit)' : 'launch the agent again with the persisted config';
795
+ const respawnBtn = '<button class="respawn" data-action="respawn" data-name="' + escapeHtml(s.name) + '" title="' + respawnTitle + '" aria-label="' + respawnText + '"><span class="icon">\u21BB</span><span class="label">' + respawnText + '</span></button>';
796
+ const editBtn = '<button class="edit" data-action="edit" data-name="' + escapeHtml(s.name) + '" data-cwd="' + escapeHtml(s.cwd) + '" data-agent="' + escapeHtml(s.agent) + '" data-flags="' + escapeHtml(s.flags || '') + '" data-env="' + escapeHtml(JSON.stringify(s.env || {})) + '" title="edit name, cwd, flags, or env" aria-label="edit"><span class="icon">\u270E</span><span class="label">edit</span></button>';
797
+ const resumeBtn = (s.hasHistory && s.conversationCount > 0)
798
+ ? '<button class="resume-btn" data-action="resume" data-name="' + escapeHtml(s.name) + '" title="resume a past conversation for this agent + cwd" aria-label="resume"><span class="icon">\u2630</span><span class="label">' + s.conversationCount + '</span></button>'
799
+ : '';
800
+ const when = relativeTime(s.createdAt);
801
+ const cwdShort = s.cwdDisplay || s.cwd;
802
+ return '<tr data-name="' + escapeHtml(s.name) + '">' +
803
+ '<td class="name-block"><span class="name">' + linkOpen + escapeHtml(s.name) + '</a></span>' + (when ? '<span class="started">started ' + when + '</span>' : '') + '<span class="cwd" title="' + escapeHtml(s.cwd) + '"><code>' + escapeHtml(cwdShort) + '</code></span></td>' +
804
+ '<td>' + escapeHtml(s.agent) + '</td>' +
805
+ '<td class="' + cls + '">' + s.status + '</td>' +
806
+ '<td class="cwd cwd-col" title="' + escapeHtml(s.cwd) + '"><code>' + escapeHtml(cwdShort) + '</code></td>' +
807
+ '<td class="actions">' + resumeBtn + respawnBtn + editBtn + '<button class="kill" data-action="kill" data-name="' + escapeHtml(s.name) + '" data-status="' + s.status + '" title="' + (s.status === 'running' ? 'kill the tmux session + remove the record' : 'remove the record') + '" aria-label="' + (s.status === 'running' ? 'kill' : 'remove') + '"><span class="icon">\u2715</span><span class="label">' + (s.status === 'running' ? 'kill' : 'remove') + '</span></button></td>' +
808
+ '</tr>';
809
+ }
810
+
811
+ function render(sessions){
812
+ if (!sessions || sessions.length === 0){
813
+ container.innerHTML = '<div class="empty">no sessions yet \u2014 spawn one from the CLI:<br><br><code>llmux session start claude --name <em>name</em></code></div>';
814
+ return;
815
+ }
816
+ const rows = sessions.map(rowHtml).join('');
817
+ container.innerHTML = '<table><thead><tr><th>name</th><th>agent</th><th>state</th><th class="cwd-col">cwd</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
818
+ }
819
+
820
+ async function poll(){
821
+ if (document.hidden) return;
822
+ try {
823
+ const r = await fetch('/api/sessions', { cache: 'no-store' });
824
+ if (!r.ok) throw new Error('http ' + r.status);
825
+ const data = await r.json();
826
+ render(data);
827
+ dot.classList.remove('stale','error');
828
+ label.textContent = 'live';
829
+ lastFetch = Date.now();
830
+ } catch(e){
831
+ dot.classList.add('error');
832
+ dot.classList.remove('stale');
833
+ label.textContent = 'offline';
834
+ }
835
+ }
836
+
837
+ function staleCheck(){
838
+ if (lastFetch && Date.now() - lastFetch > 8000 && !dot.classList.contains('error')){
839
+ dot.classList.add('stale');
840
+ label.textContent = 'stale';
841
+ }
842
+ }
843
+
844
+ async function action(name, kind, btn){
845
+ btn.disabled = true;
846
+ const original = btn.textContent;
847
+ btn.textContent = kind === 'respawn' ? '\u2026' : '\u2026';
848
+ try {
849
+ const r = await fetch('/api/sessions/' + encodeURIComponent(name) + '/' + kind, { method: 'POST' });
850
+ const body = await r.json().catch(function(){ return {}; });
851
+ if (!r.ok || body.ok === false) throw new Error(body.error || 'request failed');
852
+ showToast(kind === 'respawn' ? 'respawned ' + name : (body.status === 'running' ? 'killed ' + name : 'removed ' + name));
853
+ poll();
854
+ } catch(e){
855
+ showToast(kind + ' failed: ' + (e.message || e), true);
856
+ btn.disabled = false;
857
+ btn.textContent = original;
858
+ }
859
+ }
860
+
861
+ // ---- Agents help modal ----
862
+ const agentsModal = document.getElementById('agents-modal');
863
+ const agentsList = document.getElementById('agents-list');
864
+ const agentsClose = document.getElementById('agents-close');
865
+ const agentHelpBtn = document.getElementById('agent-help-btn');
866
+ let agentsAllLoaded = false;
867
+
868
+ async function loadAgentsAll(){
869
+ if (agentsAllLoaded) return;
870
+ try {
871
+ const r = await fetch('/api/agents/all', { cache: 'no-store' });
872
+ if (!r.ok) throw new Error('http ' + r.status);
873
+ const list = await r.json();
874
+ agentsList.innerHTML = list.map(function(a){
875
+ const status = a.installed
876
+ ? '<span class="agent-status ok">installed</span>'
877
+ : '<span class="agent-status miss">not installed</span>';
878
+ const install = a.installHint
879
+ ? '<div class="agent-install">' + escapeHtml(a.installHint) + '</div>'
880
+ : '';
881
+ const docs = a.docsUrl
882
+ ? '<a class="agent-docs" href="' + escapeHtml(a.docsUrl) + '" target="_blank" rel="noopener">docs \u2197</a>'
883
+ : '';
884
+ return '<div class="agent">' +
885
+ '<div class="agent-head"><span class="agent-name">' + escapeHtml(a.displayName) + '</span>' + status + '</div>' +
886
+ install + docs +
887
+ '</div>';
888
+ }).join('');
889
+ agentsAllLoaded = true;
890
+ } catch(e){
891
+ agentsList.innerHTML = '<div class="agent">failed to load agents: ' + escapeHtml(e.message || String(e)) + '</div>';
892
+ }
893
+ }
894
+
895
+ agentHelpBtn.addEventListener('click', async function(e){
896
+ e.preventDefault();
897
+ e.stopPropagation();
898
+ agentsModal.classList.add('open');
899
+ agentsModal.setAttribute('aria-hidden', 'false');
900
+ await loadAgentsAll();
901
+ });
902
+ agentsClose.addEventListener('click', function(){
903
+ agentsModal.classList.remove('open');
904
+ agentsModal.setAttribute('aria-hidden', 'true');
905
+ });
906
+ agentsModal.addEventListener('click', function(e){
907
+ if (e.target === agentsModal){
908
+ agentsModal.classList.remove('open');
909
+ agentsModal.setAttribute('aria-hidden', 'true');
910
+ }
911
+ });
912
+
913
+ // ---- Conversations modal ----
914
+ const convsModal = document.getElementById('convs-modal');
915
+ const convsTitle = document.getElementById('convs-title');
916
+ const convsList = document.getElementById('convs-list');
917
+ const convsClose = document.getElementById('convs-close');
918
+ let convsForSession = null;
919
+ let convsCurrentResumeFrom = null;
920
+
921
+ function relTime(iso){
922
+ const ms = Date.now() - new Date(iso).getTime();
923
+ if (isNaN(ms) || ms < 0) return iso;
924
+ if (ms < 60000) return 'just now';
925
+ const m = Math.floor(ms/60000);
926
+ if (m < 60) return m + 'm ago';
927
+ const h = Math.floor(m/60);
928
+ if (h < 24) return h + 'h ago';
929
+ const d = Math.floor(h/24);
930
+ return d + 'd ago';
931
+ }
932
+
933
+ async function openConvsModal(sessionName){
934
+ convsForSession = sessionName;
935
+ convsTitle.textContent = 'Past conversations \xB7 ' + sessionName;
936
+ convsList.innerHTML = 'loading\u2026';
937
+ convsModal.classList.add('open');
938
+ convsModal.setAttribute('aria-hidden', 'false');
939
+ // Track this row's current resumeFrom so we can flag the active conversation
940
+ convsCurrentResumeFrom = null;
941
+ try {
942
+ const sres = await fetch('/api/sessions', { cache: 'no-store' });
943
+ if (sres.ok){
944
+ const list = await sres.json();
945
+ const row = list.find(function(s){ return s.name === sessionName; });
946
+ if (row) convsCurrentResumeFrom = row.resumeFrom || null;
947
+ }
948
+ } catch(_){}
949
+ try {
950
+ const r = await fetch('/api/sessions/' + encodeURIComponent(sessionName) + '/conversations', { cache: 'no-store' });
951
+ if (!r.ok) throw new Error('http ' + r.status);
952
+ const list = await r.json();
953
+ if (!Array.isArray(list) || list.length === 0){
954
+ convsList.innerHTML = '<div class="conv">no past conversations for this agent + cwd</div>';
955
+ return;
956
+ }
957
+ convsList.innerHTML = list.map(function(c){
958
+ const isCurrent = c.id === convsCurrentResumeFrom;
959
+ const titleCls = isCurrent ? 'conv-title conv-current' : 'conv-title';
960
+ return '<button class="conv" data-conv-id="' + escapeHtml(c.id) + '" data-conv-title="' + escapeHtml(c.title) + '">' +
961
+ '<span class="' + titleCls + '">' + (isCurrent ? '\u21BB ' : '') + escapeHtml(c.title) + '</span>' +
962
+ '<span class="conv-meta"><span class="when">' + escapeHtml(relTime(c.lastMessageAt)) + '</span><span class="count">' + c.messageCount + ' msgs</span></span>' +
963
+ '</button>';
964
+ }).join('');
965
+ } catch(e){
966
+ convsList.innerHTML = '<div class="conv">failed to load conversations: ' + escapeHtml(e.message || String(e)) + '</div>';
967
+ }
968
+ }
969
+
970
+ function closeConvsModal(){
971
+ convsModal.classList.remove('open');
972
+ convsModal.setAttribute('aria-hidden', 'true');
973
+ convsForSession = null;
974
+ }
975
+ convsClose.addEventListener('click', closeConvsModal);
976
+ convsModal.addEventListener('click', function(e){
977
+ if (e.target === convsModal) closeConvsModal();
978
+ });
979
+
980
+ convsList.addEventListener('click', async function(e){
981
+ const btn = e.target.closest('button[data-conv-id]');
982
+ if (!btn || !convsForSession) return;
983
+ const convId = btn.dataset.convId;
984
+ const convTitle = btn.dataset.convTitle || '(conversation)';
985
+ const sessionName = convsForSession;
986
+ // Dismiss the conversations modal immediately so the confirm dialog
987
+ // doesn't stack underneath it (was a real bug \u2014 same z-index meant the
988
+ // confirm rendered behind the picker and tapping looked like nothing
989
+ // happened).
990
+ closeConvsModal();
991
+ const ok = await askConfirm({
992
+ title: 'Resume conversation?',
993
+ body: 'Kill <code>' + escapeHtmlSafe(sessionName) + '</code> and relaunch the agent with <code>--resume ' + escapeHtmlSafe(convId.slice(0, 8)) + '\u2026</code>. The current in-process state is lost; conversation history (on the agent\\'s side) is intact.<br><br><em>' + escapeHtmlSafe(convTitle) + '</em>',
994
+ okLabel: 'resume',
995
+ destructive: true,
996
+ });
997
+ if (!ok) return;
998
+ try {
999
+ const r = await fetch('/api/sessions/' + encodeURIComponent(sessionName) + '/resume', {
1000
+ method: 'POST',
1001
+ headers: { 'content-type': 'application/json' },
1002
+ body: JSON.stringify({ conversationId: convId }),
1003
+ });
1004
+ const data = await r.json().catch(function(){ return {}; });
1005
+ if (!r.ok || data.ok === false) throw new Error(data.error || 'resume failed');
1006
+ showToast('resumed ' + sessionName);
1007
+ poll();
1008
+ } catch(err){
1009
+ showToast('resume failed: ' + (err.message || err), true);
1010
+ }
1011
+ });
1012
+
1013
+ // ---- Confirm modal ----
1014
+ const confirmModal = document.getElementById('confirm-modal');
1015
+ const confirmTitle = document.getElementById('confirm-title');
1016
+ const confirmBody = document.getElementById('confirm-body');
1017
+ const confirmCancel = document.getElementById('confirm-cancel');
1018
+ const confirmOk = document.getElementById('confirm-ok');
1019
+ let confirmResolve = null;
1020
+
1021
+ function askConfirm(opts){
1022
+ confirmTitle.textContent = opts.title;
1023
+ confirmBody.innerHTML = opts.body;
1024
+ confirmOk.textContent = opts.okLabel || 'confirm';
1025
+ confirmOk.className = opts.destructive ? 'danger' : '';
1026
+ confirmModal.classList.add('open');
1027
+ confirmModal.setAttribute('aria-hidden', 'false');
1028
+ return new Promise(function(resolve){ confirmResolve = resolve; });
1029
+ }
1030
+ function closeConfirm(answer){
1031
+ confirmModal.classList.remove('open');
1032
+ confirmModal.setAttribute('aria-hidden', 'true');
1033
+ const r = confirmResolve;
1034
+ confirmResolve = null;
1035
+ if (r) r(answer);
1036
+ }
1037
+ confirmCancel.addEventListener('click', function(){ closeConfirm(false); });
1038
+ confirmOk.addEventListener('click', function(){ closeConfirm(true); });
1039
+ // Tapping the dim background = cancel
1040
+ confirmModal.addEventListener('click', function(e){
1041
+ if (e.target === confirmModal) closeConfirm(false);
1042
+ });
1043
+
1044
+ function escapeHtmlSafe(s){
1045
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1046
+ }
1047
+
1048
+ container.addEventListener('click', async function(e){
1049
+ const btn = e.target.closest('button[data-action]');
1050
+ if (!btn) return;
1051
+ e.preventDefault();
1052
+ const name = btn.dataset.name;
1053
+ const kind = btn.dataset.action;
1054
+ if (kind === 'edit'){
1055
+ let env = {};
1056
+ try { env = JSON.parse(btn.dataset.env || '{}'); } catch(_){}
1057
+ openEditForm({ name: name, agent: btn.dataset.agent, cwd: btn.dataset.cwd, flags: btn.dataset.flags, env: env });
1058
+ return;
1059
+ }
1060
+ if (kind === 'resume'){
1061
+ openConvsModal(name);
1062
+ return;
1063
+ }
1064
+ if (kind === 'kill'){
1065
+ const running = btn.dataset.status === 'running';
1066
+ const ok = await askConfirm({
1067
+ title: running ? 'Kill session?' : 'Remove session record?',
1068
+ body: running
1069
+ ? 'Terminate the tmux session <code>' + escapeHtmlSafe(name) + '</code> and remove its state record. The agent process inside will be killed. This cannot be undone.'
1070
+ : 'Remove the state record for <code>' + escapeHtmlSafe(name) + '</code>. The tmux session is already exited; this just cleans up the row.',
1071
+ okLabel: running ? 'kill' : 'remove',
1072
+ destructive: true,
1073
+ });
1074
+ if (!ok) return;
1075
+ }
1076
+ action(name, kind, btn);
1077
+ });
1078
+
1079
+ // ---- New / Edit session form ----
1080
+ const newBtn = document.getElementById('new-btn');
1081
+ const newForm = document.getElementById('new-form');
1082
+ const newTitle = document.getElementById('new-title');
1083
+ const newSessionForm = document.getElementById('new-session-form');
1084
+ const newAgent = document.getElementById('new-agent');
1085
+ const newName = document.getElementById('new-name');
1086
+ const newCwd = document.getElementById('new-cwd');
1087
+ const newFlags = document.getElementById('new-flags');
1088
+ const newEnv = document.getElementById('new-env');
1089
+ const newCwdHint = document.getElementById('new-cwd-hint');
1090
+ const newFlagsHint = document.getElementById('new-flags-hint');
1091
+ const newEnvHint = document.getElementById('new-env-hint');
1092
+ const newCancel = document.getElementById('new-cancel');
1093
+ const newSubmit = document.getElementById('new-submit');
1094
+ let agentsLoaded = false;
1095
+ let agentList = [];
1096
+ // mode: null (closed) | 'new' | { edit: <original-name> }
1097
+ let formMode = null;
1098
+
1099
+ function agentDefaultFlags(key){
1100
+ const a = agentList.find(function(x){ return x.key === key; });
1101
+ return (a && a.flags) || '';
1102
+ }
1103
+ function agentDefaultEnv(key){
1104
+ const a = agentList.find(function(x){ return x.key === key; });
1105
+ return (a && a.envDefaults) || {};
1106
+ }
1107
+ function envToText(envObj){
1108
+ if (!envObj) return '';
1109
+ return Object.keys(envObj).sort().map(function(k){ return k + '=' + envObj[k]; }).join('\\n');
1110
+ }
1111
+
1112
+ async function loadAgents(){
1113
+ if (agentsLoaded) return;
1114
+ try {
1115
+ const r = await fetch('/api/agents', { cache: 'no-store' });
1116
+ if (!r.ok) throw new Error('http ' + r.status);
1117
+ const list = await r.json();
1118
+ if (!Array.isArray(list) || list.length === 0){
1119
+ newAgent.innerHTML = '<option value="" disabled selected>no installed agents</option>';
1120
+ agentList = [];
1121
+ } else {
1122
+ agentList = list;
1123
+ newAgent.innerHTML = list.map(function(a){
1124
+ const label = a.displayName || a.key;
1125
+ return '<option value="' + escapeHtml(a.key) + '">' + escapeHtml(label) + '</option>';
1126
+ }).join('');
1127
+ }
1128
+ agentsLoaded = true;
1129
+ } catch(e){
1130
+ showToast('couldn\\'t load agents: ' + (e.message || e), true);
1131
+ }
1132
+ }
1133
+
1134
+ function closeForm(){
1135
+ newForm.classList.remove('open');
1136
+ newForm.setAttribute('aria-hidden', 'true');
1137
+ formMode = null;
1138
+ newAgent.disabled = false;
1139
+ }
1140
+
1141
+ function syncFlagsHint(agentKey){
1142
+ const def = agentDefaultFlags(agentKey);
1143
+ newFlagsHint.textContent = def
1144
+ ? 'agent default: ' + def + '. Clear the input to spawn with no flags. Takes effect on next respawn.'
1145
+ : 'this agent has no default flags. Takes effect on next respawn.';
1146
+ }
1147
+ function syncEnvHint(agentKey){
1148
+ const def = agentDefaultEnv(agentKey);
1149
+ const keys = Object.keys(def);
1150
+ newEnvHint.textContent = keys.length > 0
1151
+ ? 'agent defaults: ' + keys.join(', ') + '. KEY=VALUE one per line. Stored on the daemon host (auth-gated) \u2014 keep secrets out if you prefer to inject from a shell profile.'
1152
+ : 'no defaults for this agent. KEY=VALUE one per line. Stored on the daemon host (auth-gated) \u2014 keep secrets out if you prefer to inject from a shell profile.';
1153
+ }
1154
+
1155
+ async function openNewForm(){
1156
+ formMode = 'new';
1157
+ newTitle.textContent = 'new session';
1158
+ newSubmit.textContent = 'spawn';
1159
+ newName.value = '';
1160
+ newCwd.value = '';
1161
+ newAgent.disabled = false;
1162
+ newCwdHint.hidden = true;
1163
+ newFlagsHint.hidden = false;
1164
+ newEnvHint.hidden = false;
1165
+ newForm.classList.add('open');
1166
+ newForm.setAttribute('aria-hidden', 'false');
1167
+ await loadAgents();
1168
+ // Pre-fill flags + env with the selected agent's defaults so the operator
1169
+ // can edit/clear from there. Empty = spawn with no flags / no env override.
1170
+ newFlags.value = agentDefaultFlags(newAgent.value);
1171
+ newEnv.value = envToText(agentDefaultEnv(newAgent.value));
1172
+ syncFlagsHint(newAgent.value);
1173
+ syncEnvHint(newAgent.value);
1174
+ newAgent.focus();
1175
+ }
1176
+
1177
+ async function openEditForm(row){
1178
+ formMode = { edit: row.name };
1179
+ newTitle.textContent = 'edit "' + row.name + '"';
1180
+ newSubmit.textContent = 'save';
1181
+ newName.value = row.name;
1182
+ newCwd.value = row.cwd || '';
1183
+ newCwdHint.hidden = false;
1184
+ newFlagsHint.hidden = false;
1185
+ newEnvHint.hidden = false;
1186
+ newForm.classList.add('open');
1187
+ newForm.setAttribute('aria-hidden', 'false');
1188
+ await loadAgents();
1189
+ // Agent of an existing session can't be changed without kill+respawn;
1190
+ // surface it as read-only so the user sees what they have.
1191
+ if (row.agent) newAgent.value = row.agent;
1192
+ newAgent.disabled = true;
1193
+ // Pre-fill with the persisted override if present, else the agent default.
1194
+ newFlags.value = row.flags !== undefined && row.flags !== ''
1195
+ ? row.flags
1196
+ : agentDefaultFlags(newAgent.value);
1197
+ newEnv.value = row.env && Object.keys(row.env).length > 0
1198
+ ? envToText(row.env)
1199
+ : envToText(agentDefaultEnv(newAgent.value));
1200
+ syncFlagsHint(newAgent.value);
1201
+ syncEnvHint(newAgent.value);
1202
+ newName.focus();
1203
+ newName.select();
1204
+ }
1205
+
1206
+ newAgent.addEventListener('change', function(){
1207
+ if (formMode === 'new'){
1208
+ // Reset flags + env to the new agent's defaults so fields reflect intent.
1209
+ newFlags.value = agentDefaultFlags(newAgent.value);
1210
+ newEnv.value = envToText(agentDefaultEnv(newAgent.value));
1211
+ syncFlagsHint(newAgent.value);
1212
+ syncEnvHint(newAgent.value);
1213
+ }
1214
+ });
1215
+
1216
+ newBtn.addEventListener('click', function(){
1217
+ if (newForm.classList.contains('open') && formMode === 'new'){ closeForm(); return; }
1218
+ openNewForm();
1219
+ });
1220
+
1221
+ newCancel.addEventListener('click', function(){ closeForm(); });
1222
+
1223
+ newSessionForm.addEventListener('submit', async function(e){
1224
+ e.preventDefault();
1225
+ const name = newName.value.trim();
1226
+ const cwd = newCwd.value.trim();
1227
+ const flags = newFlags.value;
1228
+ const env = newEnv.value;
1229
+ newSubmit.disabled = true;
1230
+ const originalLabel = newSubmit.textContent;
1231
+ try {
1232
+ if (formMode && formMode.edit){
1233
+ newSubmit.textContent = 'saving\u2026';
1234
+ // For edit, always send flags + env so input values are canonical.
1235
+ // name/cwd still only sent if user typed (so blank = no change).
1236
+ const body = { flags: flags, env: env };
1237
+ if (name) body.name = name;
1238
+ if (cwd) body.cwd = cwd;
1239
+ const r = await fetch('/api/sessions/' + encodeURIComponent(formMode.edit), {
1240
+ method: 'PATCH',
1241
+ headers: { 'content-type': 'application/json' },
1242
+ body: JSON.stringify(body),
1243
+ });
1244
+ const data = await r.json().catch(function(){ return {}; });
1245
+ if (!r.ok || data.ok === false) throw new Error(data.error || 'edit failed');
1246
+ showToast('updated ' + data.session.name);
1247
+ } else {
1248
+ const agent = newAgent.value;
1249
+ if (!agent){ showToast('pick an agent', true); return; }
1250
+ newSubmit.textContent = 'spawning\u2026';
1251
+ const body = { agent };
1252
+ if (name) body.name = name;
1253
+ if (cwd) body.cwd = cwd;
1254
+ // Always send flags + env as the inputs are pre-filled with agent defaults;
1255
+ // empty values = explicit "no flags" / "no env override".
1256
+ body.flags = flags;
1257
+ body.env = env;
1258
+ const r = await fetch('/api/sessions', {
1259
+ method: 'POST',
1260
+ headers: { 'content-type': 'application/json' },
1261
+ body: JSON.stringify(body),
1262
+ });
1263
+ const data = await r.json().catch(function(){ return {}; });
1264
+ if (!r.ok || data.ok === false) throw new Error(data.error || 'spawn failed');
1265
+ showToast('spawned ' + data.session.name);
1266
+ }
1267
+ closeForm();
1268
+ poll();
1269
+ } catch(e){
1270
+ showToast((formMode && formMode.edit ? 'edit' : 'spawn') + ' failed: ' + (e.message || e), true);
1271
+ } finally {
1272
+ newSubmit.disabled = false;
1273
+ newSubmit.textContent = originalLabel;
1274
+ }
1275
+ });
1276
+
1277
+ document.addEventListener('visibilitychange', function(){
1278
+ if (!document.hidden) poll();
1279
+ });
1280
+
1281
+ poll();
1282
+ pollTimer = setInterval(poll, 3000);
1283
+ setInterval(staleCheck, 1000);
1284
+ })();
1285
+ </script>
1286
+ </body></html>`;
1287
+ }
1288
+ function renderSessionTable(sessions) {
1289
+ if (sessions.length === 0) {
1290
+ return `<div class="empty">no sessions yet \u2014 spawn one from the CLI:<br><br><code>llmux session start claude --name <em>name</em></code></div>`;
1291
+ }
1292
+ const rows = sessions.map((s) => {
1293
+ const cls = `state-${s.status}`;
1294
+ const linkOpen = `<a class="session-link" href="/session/${encodeURIComponent(s.name)}">`;
1295
+ const respawnText = s.status === "running" ? "restart" : "respawn";
1296
+ const respawnBtn = `<button class="respawn" data-action="respawn" data-name="${escapeHtml(s.name)}" aria-label="${respawnText}"><span class="icon">\u21BB</span><span class="label">${respawnText}</span></button>`;
1297
+ const editBtn = `<button class="edit" data-action="edit" data-name="${escapeHtml(s.name)}" data-cwd="${escapeHtml(s.cwd)}" data-agent="${escapeHtml(s.agent)}" data-flags="${escapeHtml(s.flags || "")}" data-env="${escapeHtml(JSON.stringify(s.env || {}))}" aria-label="edit"><span class="icon">\u270E</span><span class="label">edit</span></button>`;
1298
+ const resumeBtn = s.hasHistory && s.conversationCount > 0 ? `<button class="resume-btn" data-action="resume" data-name="${escapeHtml(s.name)}" aria-label="resume"><span class="icon">\u2630</span><span class="label">${s.conversationCount}</span></button>` : "";
1299
+ const killText = s.status === "running" ? "kill" : "remove";
1300
+ const killBtn = `<button class="kill" data-action="kill" data-name="${escapeHtml(s.name)}" data-status="${s.status}" aria-label="${killText}"><span class="icon">\u2715</span><span class="label">${killText}</span></button>`;
1301
+ const cwdShort = s.cwdDisplay || s.cwd;
1302
+ return `<tr data-name="${escapeHtml(s.name)}">
1303
+ <td class="name-block"><span class="name">${linkOpen}${escapeHtml(s.name)}</a></span><span class="cwd" title="${escapeHtml(s.cwd)}"><code>${escapeHtml(cwdShort)}</code></span></td>
1304
+ <td>${escapeHtml(s.agent)}</td>
1305
+ <td class="${cls}">${s.status}</td>
1306
+ <td class="cwd cwd-col" title="${escapeHtml(s.cwd)}"><code>${escapeHtml(cwdShort)}</code></td>
1307
+ <td class="actions">${resumeBtn}${respawnBtn}${editBtn}${killBtn}</td>
1308
+ </tr>`;
1309
+ }).join("\n");
1310
+ return `<table><thead><tr><th>name</th><th>agent</th><th>state</th><th class="cwd-col">cwd</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1311
+ }
1312
+ function deadSessionPage(s) {
1313
+ return `<!doctype html><html lang="en"><head>
1314
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1315
+ <title>${escapeHtml(s.name)} \u2014 exited</title>
1316
+ <style>
1317
+ :root{color-scheme:dark}
1318
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px}
1319
+ body{padding:24px;max-width:560px;margin:0 auto}
1320
+ h1{font-size:18px;margin:0 0 4px}
1321
+ .sub{color:#7a7f87;font-size:12px;margin-bottom:18px}
1322
+ .card{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:18px}
1323
+ dl{margin:0;display:grid;grid-template-columns:80px 1fr;gap:6px 12px;font-size:13px}
1324
+ dt{color:#7a7f87}
1325
+ dd{margin:0;color:#c9d1d9;word-break:break-all}
1326
+ .row{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
1327
+ button{flex:1 1 auto;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:10px 14px;font:13px ui-monospace,monospace;cursor:pointer;min-width:120px}
1328
+ button:hover{background:#252b34}
1329
+ button.primary{color:#7cc4ff;border-color:#2d4a66}
1330
+ button.danger{color:#f85149;border-color:#4a2329}
1331
+ button.ghost{color:#9aa0a6}
1332
+ button:disabled{opacity:.5;cursor:wait}
1333
+ #status{margin-top:14px;font-size:12px;color:#9aa0a6;min-height:18px}
1334
+ #status.error{color:#f85149}
1335
+ </style></head>
1336
+ <body>
1337
+ <h1>${escapeHtml(s.name)}</h1>
1338
+ <div class="sub">session is not running</div>
1339
+ <div class="card">
1340
+ <dl>
1341
+ <dt>agent</dt><dd>${escapeHtml(s.agent)}</dd>
1342
+ <dt>cwd</dt><dd>${escapeHtml(s.cwd)}</dd>
1343
+ <dt>created</dt><dd>${escapeHtml(s.createdAt)}</dd>
1344
+ ${s.parent ? `<dt>parent</dt><dd>${escapeHtml(s.parent)}</dd>` : ""}
1345
+ </dl>
1346
+ <div class="row">
1347
+ <button class="primary" id="btn-respawn">\u21BB respawn</button>
1348
+ <button class="danger" id="btn-remove">\xD7 remove</button>
1349
+ <button class="ghost" id="btn-back">\u2190 sessions</button>
1350
+ </div>
1351
+ <div id="status"></div>
1352
+ </div>
1353
+ <script>
1354
+ (function(){
1355
+ const name = ${JSON.stringify(s.name)};
1356
+ const status = document.getElementById('status');
1357
+ function setStatus(msg, isError){
1358
+ status.textContent = msg;
1359
+ status.classList.toggle('error', !!isError);
1360
+ }
1361
+ async function call(kind){
1362
+ const btns = document.querySelectorAll('button');
1363
+ btns.forEach(function(b){ b.disabled = true; });
1364
+ setStatus(kind === 'respawn' ? 'respawning\u2026' : 'removing\u2026');
1365
+ try {
1366
+ const r = await fetch('/api/sessions/' + encodeURIComponent(name) + '/' + kind, { method: 'POST' });
1367
+ const body = await r.json().catch(function(){ return {}; });
1368
+ if (!r.ok || body.ok === false) throw new Error(body.error || 'request failed');
1369
+ if (kind === 'respawn') location.href = '/session/' + encodeURIComponent(name);
1370
+ else location.href = '/';
1371
+ } catch(e){
1372
+ setStatus(kind + ' failed: ' + (e.message || e), true);
1373
+ btns.forEach(function(b){ b.disabled = false; });
1374
+ }
1375
+ }
1376
+ document.getElementById('btn-respawn').addEventListener('click', function(){ call('respawn'); });
1377
+ document.getElementById('btn-remove').addEventListener('click', function(){ call('kill'); });
1378
+ document.getElementById('btn-back').addEventListener('click', function(){ location.href = '/'; });
1379
+ })();
1380
+ </script>
1381
+ </body></html>`;
1382
+ }
1383
+ function sessionPage(name) {
1384
+ const escapedName = escapeHtml(name);
1385
+ const jsonName = JSON.stringify(name);
1386
+ const jsonVersion = JSON.stringify(DAEMON_VERSION);
1387
+ return `<!doctype html><html lang="en"><head>
1388
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
1389
+ <title>${escapedName} \u2014 llmux</title>
1390
+ <link rel="icon" href="${FAVICON_DATA_URL}">
1391
+ <link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
1392
+ <link rel="stylesheet" href="${XTERM_CSS}">
1393
+ <style>
1394
+ :root{--topbar-h:38px;--bar-h:92px;--allkeys-h:0px;color-scheme:dark}
1395
+ html,body{margin:0;background:#0b0c10;color:#eee;font-family:ui-monospace,monospace;overscroll-behavior:none}
1396
+ html{height:100dvh}
1397
+ body{height:100dvh;min-height:100dvh}
1398
+ #topbar{position:fixed;top:0;left:0;right:0;height:var(--topbar-h);background:#11141a;border-bottom:1px solid #1f2329;display:flex;align-items:center;gap:8px;padding:0 10px;z-index:21;box-sizing:border-box}
1399
+ #topbar #back{flex:0 0 auto;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;height:26px;width:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font-family:system-ui,sans-serif;font-size:16px;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none}
1400
+ #topbar #back:active{background:#252b34;border-color:#3a414b}
1401
+ #title-block{flex:1 1 auto;display:flex;align-items:center;gap:8px;color:#c9d1d9;font-size:12px;min-width:0}
1402
+ #title-dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#9aa0a6;transition:background .2s,box-shadow .2s;cursor:pointer}
1403
+ #title-dot[data-state="live"]{background:#7ee787;box-shadow:0 0 6px #7ee78766}
1404
+ #title-dot[data-state="error"],#title-dot[data-state="closed"],#title-dot[data-state="reconnecting"]{background:#f85149}
1405
+ #title-dot[data-state="reconnecting"]{animation:pulse 1s ease-in-out infinite}
1406
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
1407
+ #title-name{flex:0 1 auto;font-weight:600;color:#e6e8eb;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1408
+ #title-brand{flex:0 0 auto;color:#7cc4ff;font-size:11px;font-weight:600;letter-spacing:.08em;margin-left:auto;padding-left:8px}
1409
+ #title-version{flex:0 0 auto;color:#7a7f87;font-size:10px;padding-left:6px}
1410
+ #bar{position:fixed;bottom:0;left:0;right:0;height:var(--bar-h);background:#11141a;border-top:1px solid #1f2329;display:flex;flex-direction:column;gap:8px;padding:6px 0 14px;z-index:20;box-sizing:border-box}
1411
+ #bar .row{display:flex;align-items:center;gap:6px;padding:0 6px;flex:0 0 auto;height:32px}
1412
+ #bar .row.arrows{justify-content:center}
1413
+ #bar .row.keys{justify-content:flex-start}
1414
+ #bar #more{flex:0 0 auto;margin-left:auto}
1415
+ #bar button{flex:0 0 auto;min-width:40px;height:30px;padding:0 10px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:13px ui-monospace,monospace;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none;transition:background .15s,border-color .15s}
1416
+ #bar button:active{background:#252b34;border-color:#3a414b}
1417
+ #bar button[aria-pressed="true"]{background:#1e3a52;border-color:#2d5a85;color:#7cc4ff}
1418
+ #bar button[aria-pressed="locked"]{background:#2d5a85;border-color:#4a7fae;color:#fff}
1419
+ #bar button.fail{background:#4a2329;border-color:#f85149;color:#f85149}
1420
+ #all-keys{position:fixed;bottom:var(--bar-h);left:0;right:0;background:#0e1116;border-top:1px solid #1f2329;display:none;padding:8px;z-index:19;max-height:40vh;overflow-y:auto;box-sizing:border-box}
1421
+ #all-keys.open{display:block}
1422
+ #all-keys h4{margin:14px 4px 6px;font:500 10px/1 ui-monospace,monospace;color:#7a7f87;text-transform:uppercase;letter-spacing:.06em}
1423
+ #all-keys h4:first-child{margin-top:4px}
1424
+ #all-keys .row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
1425
+ #all-keys button{flex:0 0 auto;min-width:36px;height:30px;padding:0 8px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:12px ui-monospace,monospace;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none}
1426
+ #all-keys button:active{background:#252b34;border-color:#3a414b}
1427
+ #term{position:fixed;top:var(--topbar-h);left:0;right:0;bottom:var(--bar-h)}
1428
+ body.allkeys-open #term{bottom:calc(var(--bar-h) + var(--allkeys-h))}
1429
+ #overlay{position:fixed;inset:0;background:rgba(11,12,16,.92);display:none;align-items:center;justify-content:center;z-index:30;padding:20px}
1430
+ #overlay.show{display:flex}
1431
+ #overlay .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:20px;max-width:340px;width:100%;text-align:center}
1432
+ #overlay h3{margin:0 0 6px;font-size:15px;color:#f85149}
1433
+ #overlay p{margin:0 0 14px;font-size:13px;color:#c9d1d9;line-height:1.5}
1434
+ #overlay .actions{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
1435
+ #overlay button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:12px ui-monospace,monospace;cursor:pointer}
1436
+ #overlay button.primary{color:#7cc4ff;border-color:#2d4a66}
1437
+ @media (orientation: landscape) and (max-height: 500px){
1438
+ :root{--topbar-h:28px;--bar-h:64px}
1439
+ #topbar{padding:0 6px;gap:6px}
1440
+ #topbar #back{height:20px;width:30px;font-size:13px}
1441
+ #title-block{font-size:11px}
1442
+ #title-brand{font-size:10px;padding-left:6px}
1443
+ #title-version{font-size:9px;padding-left:4px}
1444
+ #bar button{height:22px;min-width:36px;padding:0 8px;font-size:11px}
1445
+ #bar{padding:4px 0 10px;gap:4px}
1446
+ #bar .row{gap:4px;height:24px}
1447
+ #all-keys{max-height:60vh}
1448
+ #all-keys button{height:24px;min-width:30px;padding:0 7px;font-size:11px}
1449
+ }
1450
+ </style></head>
1451
+ <body>
1452
+ <div id="topbar">
1453
+ <button id="back" title="Back to sessions">\u2302</button>
1454
+ <span id="title-block"><span id="title-dot" data-state="connecting" title="connecting\u2026"></span><span id="title-name">${escapedName}</span></span>
1455
+ <span id="title-brand">LLMUX</span>
1456
+ <span id="title-version">v${escapeHtml(DAEMON_VERSION)}</span>
1457
+ </div>
1458
+ <div id="bar">
1459
+ <div class="row arrows">
1460
+ <button data-mod="shift" title="Shift (next char uppercase; double-tap to lock)">Shift</button>
1461
+ <button data-key="home" title="Home">Home</button>
1462
+ <button data-key="up" title="Up">\u25B2</button>
1463
+ <button data-key="down" title="Down">\u25BC</button>
1464
+ <button data-key="left" title="Left">\u25C0</button>
1465
+ <button data-key="right" title="Right">\u25B6</button>
1466
+ <button data-key="end" title="End">End</button>
1467
+ </div>
1468
+ <div class="row keys">
1469
+ <button data-key="esc" title="Escape">Esc</button>
1470
+ <button data-key="tab" title="Tab">Tab</button>
1471
+ <button data-mod="ctrl" title="Ctrl (tap then key, double-tap to lock)">Ctrl</button>
1472
+ <button data-mod="alt" title="Alt (tap then key, double-tap to lock)">Alt</button>
1473
+ <button id="more" title="All keys">\u22EF</button>
1474
+ </div>
1475
+ </div>
1476
+ <div id="all-keys" aria-hidden="true">
1477
+ <h4>shell</h4>
1478
+ <div class="row">
1479
+ <button data-char="~" title="tilde">~</button>
1480
+ <button data-char="\`" title="backtick">\`</button>
1481
+ <button data-char="/" title="slash">/</button>
1482
+ <button data-char="\\\\" title="backslash">\\</button>
1483
+ <button data-char="|" title="pipe">|</button>
1484
+ <button data-char="-" title="dash">-</button>
1485
+ <button data-char="_" title="underscore">_</button>
1486
+ </div>
1487
+ <h4>numbers</h4>
1488
+ <div class="row">
1489
+ <button data-char="0">0</button><button data-char="1">1</button><button data-char="2">2</button>
1490
+ <button data-char="3">3</button><button data-char="4">4</button><button data-char="5">5</button>
1491
+ <button data-char="6">6</button><button data-char="7">7</button><button data-char="8">8</button>
1492
+ <button data-char="9">9</button>
1493
+ </div>
1494
+ <h4>brackets &amp; quotes</h4>
1495
+ <div class="row">
1496
+ <button data-char="(">(</button><button data-char=")">)</button>
1497
+ <button data-char="[">[</button><button data-char="]">]</button>
1498
+ <button data-char="{">{</button><button data-char="}">}</button>
1499
+ <button data-char="&lt;">&lt;</button><button data-char="&gt;">&gt;</button>
1500
+ <button data-char="'">'</button><button data-char="&quot;">&quot;</button>
1501
+ </div>
1502
+ <h4>operators</h4>
1503
+ <div class="row">
1504
+ <button data-char="=">=</button><button data-char="+">+</button>
1505
+ <button data-char="*">*</button><button data-char="&amp;">&amp;</button>
1506
+ <button data-char="^">^</button><button data-char="%">%</button>
1507
+ <button data-char="$">$</button><button data-char="#">#</button>
1508
+ <button data-char="@">@</button><button data-char="!">!</button>
1509
+ <button data-char="?">?</button>
1510
+ </div>
1511
+ <h4>punctuation</h4>
1512
+ <div class="row">
1513
+ <button data-char=":">:</button><button data-char=";">;</button>
1514
+ <button data-char=",">,</button><button data-char=".">.</button>
1515
+ </div>
1516
+ <h4>navigation &amp; edit</h4>
1517
+ <div class="row">
1518
+ <button data-key="home">Home</button><button data-key="end">End</button>
1519
+ <button data-key="pgup">PgUp</button><button data-key="pgdn">PgDn</button>
1520
+ <button data-key="del">Del</button><button data-key="ins">Ins</button>
1521
+ <button data-key="bsp">\u232B Bsp</button><button data-key="enter">\u21B5 Enter</button>
1522
+ </div>
1523
+ <h4>function keys</h4>
1524
+ <div class="row">
1525
+ <button data-key="f1">F1</button><button data-key="f2">F2</button>
1526
+ <button data-key="f3">F3</button><button data-key="f4">F4</button>
1527
+ <button data-key="f5">F5</button><button data-key="f6">F6</button>
1528
+ <button data-key="f7">F7</button><button data-key="f8">F8</button>
1529
+ <button data-key="f9">F9</button><button data-key="f10">F10</button>
1530
+ <button data-key="f11">F11</button><button data-key="f12">F12</button>
1531
+ </div>
1532
+ <h4>actions</h4>
1533
+ <div class="row">
1534
+ <button id="reset-term" title="Clear xterm buffer and send Ctrl-L to redraw">Reset terminal</button>
1535
+ </div>
1536
+ </div>
1537
+ <div id="term"></div>
1538
+ <div id="overlay" aria-hidden="true">
1539
+ <div class="panel">
1540
+ <h3 id="overlay-title">session ended</h3>
1541
+ <p id="overlay-body">The tmux session exited. You can respawn it from the picker.</p>
1542
+ <div class="actions">
1543
+ <button class="primary" id="overlay-respawn">\u21BB respawn</button>
1544
+ <button id="overlay-back">\u2190 sessions</button>
1545
+ </div>
1546
+ </div>
1547
+ </div>
1548
+ <script src="${XTERM_JS}"></script>
1549
+ <script src="${XTERM_FIT_JS}"></script>
1550
+ <script>
1551
+ (function(){
1552
+ const name = ${jsonName};
1553
+ const version = ${jsonVersion};
1554
+ const dot = document.getElementById('title-dot');
1555
+ const titleName = document.getElementById('title-name');
1556
+ const termEl = document.getElementById('term');
1557
+ const overlay = document.getElementById('overlay');
1558
+ const overlayTitle = document.getElementById('overlay-title');
1559
+ const overlayBody = document.getElementById('overlay-body');
1560
+
1561
+ function setStatus(state, label){
1562
+ dot.dataset.state = state;
1563
+ dot.title = name + ' \u2014 ' + label;
1564
+ }
1565
+
1566
+ function showOverlay(title, body, kind){
1567
+ overlayTitle.textContent = title;
1568
+ overlayBody.textContent = body;
1569
+ overlay.classList.add('show');
1570
+ overlay.setAttribute('aria-hidden', 'false');
1571
+ overlay.dataset.kind = kind || '';
1572
+ }
1573
+ function hideOverlay(){
1574
+ overlay.classList.remove('show');
1575
+ overlay.setAttribute('aria-hidden', 'true');
1576
+ }
1577
+
1578
+ document.getElementById('overlay-back').addEventListener('click', function(){ location.href = '/'; });
1579
+ document.getElementById('overlay-respawn').addEventListener('click', async function(){
1580
+ const btn = this;
1581
+ btn.disabled = true;
1582
+ overlayBody.textContent = 'respawning\u2026';
1583
+ try {
1584
+ const r = await fetch('/api/sessions/' + encodeURIComponent(name) + '/respawn', { method: 'POST' });
1585
+ const body = await r.json().catch(function(){ return {}; });
1586
+ if (!r.ok || body.ok === false) throw new Error(body.error || 'request failed');
1587
+ location.reload();
1588
+ } catch(e){
1589
+ overlayBody.textContent = 'respawn failed: ' + (e.message || e);
1590
+ btn.disabled = false;
1591
+ }
1592
+ });
1593
+
1594
+ const term = new Terminal({fontSize:14,fontFamily:'ui-monospace,monospace',theme:{background:'#0b0c10'},cursorBlink:true,scrollback:5000});
1595
+ const fit = new FitAddon.FitAddon();
1596
+ term.loadAddon(fit);
1597
+ term.open(termEl);
1598
+
1599
+ // ---- WebSocket with exponential backoff ----
1600
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1601
+ const wsUrl = proto + '://' + location.host + '/ws/' + encodeURIComponent(name);
1602
+ let ws = null;
1603
+ let dataPiped = false;
1604
+ let reconnectTimer = null;
1605
+ let backoffMs = 1000;
1606
+ const BACKOFF_CAP = 30000;
1607
+ let everConnected = false;
1608
+ let intentionallyClosed = false;
1609
+
1610
+ function safeSend(data){
1611
+ if (!ws || ws.readyState !== WebSocket.OPEN){
1612
+ return false;
1613
+ }
1614
+ try { ws.send(data); return true; }
1615
+ catch(e){ return false; }
1616
+ }
1617
+
1618
+ function clearReconnect(){
1619
+ if (reconnectTimer){
1620
+ clearTimeout(reconnectTimer);
1621
+ reconnectTimer = null;
1622
+ }
1623
+ }
1624
+
1625
+ function scheduleReconnect(){
1626
+ if (intentionallyClosed) return;
1627
+ clearReconnect();
1628
+ setStatus('reconnecting', 'reconnecting in ' + Math.round(backoffMs/1000) + 's\u2026');
1629
+ reconnectTimer = setTimeout(function(){
1630
+ reconnectTimer = null;
1631
+ connect();
1632
+ }, backoffMs);
1633
+ backoffMs = Math.min(BACKOFF_CAP, backoffMs * 2);
1634
+ }
1635
+
1636
+ function ensureConnected(){
1637
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
1638
+ clearReconnect();
1639
+ backoffMs = 1000;
1640
+ connect();
1641
+ }
1642
+
1643
+ function connect(){
1644
+ setStatus('connecting', 'connecting\u2026');
1645
+ try {
1646
+ ws = new WebSocket(wsUrl);
1647
+ } catch(e){
1648
+ scheduleReconnect();
1649
+ return;
1650
+ }
1651
+ ws.binaryType = 'arraybuffer';
1652
+ ws.onopen = function(){
1653
+ setStatus('live', 'live');
1654
+ backoffMs = 1000;
1655
+ everConnected = true;
1656
+ hideOverlay();
1657
+ if (!dataPiped){
1658
+ // term.onData must only be wired once \u2014 repeat calls would
1659
+ // double-deliver every keystroke.
1660
+ term.onData(function(d){
1661
+ if (!safeSend(consumeMods(d))) flashDot();
1662
+ });
1663
+ dataPiped = true;
1664
+ }
1665
+ scheduleResize();
1666
+ term.focus();
1667
+ };
1668
+ ws.onmessage = function(ev){
1669
+ if (typeof ev.data === 'string') term.write(ev.data);
1670
+ else term.write(new Uint8Array(ev.data));
1671
+ };
1672
+ ws.onclose = function(ev){
1673
+ // Close code 1011/4040 from the server means the tmux session is gone \u2014
1674
+ // surface a session-ended overlay instead of reconnect-looping.
1675
+ if (ev && (ev.code === 4040 || /pty exited/.test(ev.reason || ''))){
1676
+ intentionallyClosed = true;
1677
+ setStatus('closed', 'session ended');
1678
+ showOverlay('session ended', 'The tmux session is no longer running.', 'ended');
1679
+ return;
1680
+ }
1681
+ if (everConnected) setStatus('closed', 'disconnected \u2014 reconnecting');
1682
+ scheduleReconnect();
1683
+ };
1684
+ ws.onerror = function(){
1685
+ setStatus('error', 'connection error');
1686
+ };
1687
+ }
1688
+
1689
+ let flashTimer = null;
1690
+ function flashDot(){
1691
+ dot.style.boxShadow = '0 0 8px #f85149';
1692
+ clearTimeout(flashTimer);
1693
+ flashTimer = setTimeout(function(){ dot.style.boxShadow = ''; }, 250);
1694
+ }
1695
+ function flashBtnFail(btn){
1696
+ btn.classList.add('fail');
1697
+ setTimeout(function(){ btn.classList.remove('fail'); }, 250);
1698
+ }
1699
+
1700
+ connect();
1701
+
1702
+ // ---- Key sequence table ----
1703
+ const KEYS = {
1704
+ esc: '\\x1b', tab: '\\t', enter: '\\r', bsp: '\\x7f',
1705
+ up: '\\x1b[A', down: '\\x1b[B', right: '\\x1b[C', left: '\\x1b[D',
1706
+ home: '\\x1b[H', end: '\\x1b[F',
1707
+ pgup: '\\x1b[5~', pgdn: '\\x1b[6~',
1708
+ del: '\\x1b[3~', ins: '\\x1b[2~',
1709
+ f1:'\\x1bOP', f2:'\\x1bOQ', f3:'\\x1bOR', f4:'\\x1bOS',
1710
+ f5:'\\x1b[15~', f6:'\\x1b[17~', f7:'\\x1b[18~', f8:'\\x1b[19~',
1711
+ f9:'\\x1b[20~', f10:'\\x1b[21~', f11:'\\x1b[23~', f12:'\\x1b[24~'
1712
+ };
1713
+ Object.keys(KEYS).forEach(function(k){ KEYS[k] = KEYS[k].replace(/\\\\x([0-9a-f]{2})/gi, function(_,h){ return String.fromCharCode(parseInt(h,16)); }); });
1714
+
1715
+ // ---- Modifier state: 'off' | 'pending' | 'locked' ----
1716
+ const mods = { ctrl: 'off', alt: 'off', shift: 'off' };
1717
+ function setMod(mod, val){
1718
+ mods[mod] = val;
1719
+ const btn = document.querySelector('[data-mod="'+mod+'"]');
1720
+ if (btn){
1721
+ if (val === 'off') btn.removeAttribute('aria-pressed');
1722
+ else btn.setAttribute('aria-pressed', val === 'locked' ? 'locked' : 'true');
1723
+ }
1724
+ }
1725
+ function consumeMods(d){
1726
+ let out = d;
1727
+ if (mods.shift !== 'off' && d.length === 1){
1728
+ out = d.toUpperCase();
1729
+ if (mods.shift === 'pending') setMod('shift', 'off');
1730
+ }
1731
+ if (mods.ctrl !== 'off' && out.length === 1){
1732
+ const c = out.charCodeAt(0);
1733
+ if (c >= 0x40 && c <= 0x7f) out = String.fromCharCode(c & 0x1f);
1734
+ else if (c === 0x20) out = '\\x00';
1735
+ if (mods.ctrl === 'pending') setMod('ctrl', 'off');
1736
+ }
1737
+ if (mods.alt !== 'off'){
1738
+ out = '\\x1b' + out;
1739
+ if (mods.alt === 'pending') setMod('alt', 'off');
1740
+ }
1741
+ return out.replace(/\\\\x([0-9a-f]{2})/gi, function(_,h){ return String.fromCharCode(parseInt(h,16)); });
1742
+ }
1743
+
1744
+ // ---- Layout / resize ----
1745
+ let resizeTimer = null;
1746
+ const allKeysEl = document.getElementById('all-keys');
1747
+ function getAllKeysH(){
1748
+ if (!allKeysEl.classList.contains('open')) return 0;
1749
+ return Math.min(allKeysEl.scrollHeight, Math.floor((window.visualViewport ? window.visualViewport.height : window.innerHeight) * 0.4));
1750
+ }
1751
+ function applyLayout(){
1752
+ const allKeysH = getAllKeysH();
1753
+ document.documentElement.style.setProperty('--allkeys-h', allKeysH + 'px');
1754
+ const cs = getComputedStyle(document.documentElement);
1755
+ const barH = parseInt(cs.getPropertyValue('--bar-h'),10) || 42;
1756
+ const topbarH = parseInt(cs.getPropertyValue('--topbar-h'),10) || 0;
1757
+ const vv = window.visualViewport;
1758
+ const visibleH = vv ? vv.height : window.innerHeight;
1759
+ termEl.style.top = topbarH + 'px';
1760
+ termEl.style.bottom = (barH + allKeysH) + 'px';
1761
+ termEl.style.height = Math.max(60, visibleH - topbarH - barH - allKeysH) + 'px';
1762
+ }
1763
+ function scheduleResize(){
1764
+ clearTimeout(resizeTimer);
1765
+ resizeTimer = setTimeout(function(){
1766
+ applyLayout();
1767
+ try { fit.fit(); } catch(e){}
1768
+ safeSend(JSON.stringify({type:'resize', cols:term.cols, rows:term.rows}));
1769
+ }, 60);
1770
+ }
1771
+
1772
+ applyLayout();
1773
+ try { fit.fit(); } catch(e){}
1774
+
1775
+ // ---- Wire toolbar ----
1776
+ document.querySelectorAll('#topbar button, #bar button, #all-keys button').forEach(function(b){ b.tabIndex = -1; });
1777
+
1778
+ document.getElementById('back').addEventListener('click', function(e){ e.preventDefault(); location.href = '/'; });
1779
+
1780
+ document.getElementById('reset-term').addEventListener('click', function(e){
1781
+ e.preventDefault();
1782
+ try { term.reset(); } catch(err){}
1783
+ safeSend('\\x0c');
1784
+ term.focus();
1785
+ });
1786
+
1787
+ document.getElementById('more').addEventListener('click', function(e){
1788
+ e.preventDefault();
1789
+ const open = allKeysEl.classList.toggle('open');
1790
+ allKeysEl.setAttribute('aria-hidden', open ? 'false' : 'true');
1791
+ document.body.classList.toggle('allkeys-open', open);
1792
+ scheduleResize();
1793
+ term.focus();
1794
+ });
1795
+
1796
+ document.querySelectorAll('[data-key]').forEach(function(btn){
1797
+ btn.addEventListener('pointerdown', function(e){ e.preventDefault(); });
1798
+ btn.addEventListener('click', function(e){
1799
+ e.preventDefault();
1800
+ const seq = KEYS[btn.dataset.key];
1801
+ if (seq != null && !safeSend(consumeMods(seq))) flashBtnFail(btn);
1802
+ term.focus();
1803
+ });
1804
+ });
1805
+
1806
+ document.querySelectorAll('[data-char]').forEach(function(btn){
1807
+ btn.addEventListener('pointerdown', function(e){ e.preventDefault(); });
1808
+ btn.addEventListener('click', function(e){
1809
+ e.preventDefault();
1810
+ if (!safeSend(consumeMods(btn.dataset.char))) flashBtnFail(btn);
1811
+ term.focus();
1812
+ });
1813
+ });
1814
+
1815
+ document.querySelectorAll('[data-mod]').forEach(function(btn){
1816
+ let lastTap = 0;
1817
+ btn.addEventListener('pointerdown', function(e){ e.preventDefault(); });
1818
+ btn.addEventListener('click', function(e){
1819
+ e.preventDefault();
1820
+ const mod = btn.dataset.mod;
1821
+ const now = Date.now();
1822
+ const fast = now - lastTap < 400;
1823
+ lastTap = now;
1824
+ if (mods[mod] === 'locked') setMod(mod, 'off');
1825
+ else if (fast && mods[mod] === 'pending') setMod(mod, 'locked');
1826
+ else if (mods[mod] === 'off') setMod(mod, 'pending');
1827
+ else setMod(mod, 'off');
1828
+ term.focus();
1829
+ });
1830
+ });
1831
+
1832
+ // ---- Resize triggers ----
1833
+ addEventListener('resize', function(){ scheduleResize(); });
1834
+ addEventListener('orientationchange', function(){ scheduleResize(); });
1835
+ if (window.visualViewport){
1836
+ window.visualViewport.addEventListener('resize', function(){ scheduleResize(); });
1837
+ window.visualViewport.addEventListener('scroll', function(){ scheduleResize(); });
1838
+ }
1839
+ let pendingRefocus = false;
1840
+ function armRefocus(){
1841
+ if (pendingRefocus) return;
1842
+ pendingRefocus = true;
1843
+ function onUserTouch(){
1844
+ pendingRefocus = false;
1845
+ try { term.focus(); } catch(e){}
1846
+ document.removeEventListener('touchstart', onUserTouch, true);
1847
+ document.removeEventListener('mousedown', onUserTouch, true);
1848
+ }
1849
+ document.addEventListener('touchstart', onUserTouch, true);
1850
+ document.addEventListener('mousedown', onUserTouch, true);
1851
+ }
1852
+ document.addEventListener('visibilitychange', function(){
1853
+ if (!document.hidden){
1854
+ ensureConnected();
1855
+ scheduleResize();
1856
+ try { term.focus(); } catch(e){}
1857
+ armRefocus();
1858
+ }
1859
+ });
1860
+ addEventListener('pageshow', function(){
1861
+ ensureConnected();
1862
+ scheduleResize();
1863
+ try { term.focus(); } catch(e){}
1864
+ armRefocus();
1865
+ });
1866
+ })();
1867
+ </script>
1868
+ </body></html>`;
1869
+ }
1870
+ var COOKIE_NAME = "llmuxd_token";
1871
+ var COOKIE_RE = new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`);
1872
+ function isLocalhost(req) {
1873
+ const ra = req.socket.remoteAddress;
1874
+ return ra === "127.0.0.1" || ra === "::1" || ra === "::ffff:127.0.0.1";
1875
+ }
1876
+ function extractToken(req) {
1877
+ const auth = req.headers["authorization"];
1878
+ if (typeof auth === "string" && auth.startsWith("Bearer ")) {
1879
+ return auth.slice("Bearer ".length).trim();
1880
+ }
1881
+ const cookie = req.headers["cookie"];
1882
+ if (typeof cookie === "string") {
1883
+ const m = COOKIE_RE.exec(cookie);
1884
+ if (m) return decodeURIComponent(m[1] ?? "");
1885
+ }
1886
+ return void 0;
1887
+ }
1888
+ function extractWsToken(req, urlSearch) {
1889
+ const fromQuery = urlSearch.get("token");
1890
+ if (fromQuery) return fromQuery;
1891
+ return extractToken(req);
1892
+ }
1893
+ function isAuthorized(req) {
1894
+ if (isLocalhost(req)) return true;
1895
+ if (!authEnabled()) return true;
1896
+ return validateAuthToken(extractToken(req));
1897
+ }
1898
+ function isWsAuthorized(req, urlSearch) {
1899
+ if (isLocalhost(req)) return true;
1900
+ if (!authEnabled()) return true;
1901
+ return validateAuthToken(extractWsToken(req, urlSearch));
1902
+ }
1903
+ function gatePage(reason) {
1904
+ const message = reason === "invalid" ? "Token rejected. Try again." : "This llmux daemon requires a token.";
1905
+ return `<!doctype html><html lang="en"><head>
1906
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1907
+ <title>llmux \u2014 auth</title>
1908
+ <link rel="icon" href="${FAVICON_DATA_URL}">
1909
+ <style>
1910
+ :root{color-scheme:dark}
1911
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px}
1912
+ body{padding:24px;max-width:520px;margin:0 auto;min-height:100dvh;box-sizing:border-box;display:flex;flex-direction:column;justify-content:center}
1913
+ h1{font-size:18px;margin:0 0 4px;display:flex;align-items:center;gap:8px}
1914
+ h1 .brand{color:#7cc4ff;letter-spacing:.08em}
1915
+ .sub{color:#7a7f87;font-size:12px;margin-bottom:18px}
1916
+ .card{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:20px}
1917
+ label{display:block;font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
1918
+ input{width:100%;box-sizing:border-box;background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:10px;font:13px ui-monospace,monospace;outline:none}
1919
+ input:focus{border-color:#2d4a66}
1920
+ button{margin-top:14px;width:100%;background:#1c2128;color:#7cc4ff;border:1px solid #2d4a66;border-radius:6px;padding:10px 14px;font:13px ui-monospace,monospace;cursor:pointer}
1921
+ button:hover{background:#252b34}
1922
+ button:disabled{opacity:.5;cursor:wait}
1923
+ .msg{margin-top:12px;font-size:12px;color:#f85149;min-height:18px}
1924
+ .hint{margin-top:18px;font-size:11px;color:#7a7f87;line-height:1.5}
1925
+ .hint code{color:#c9d1d9;background:#0b0c10;padding:2px 5px;border-radius:3px}
1926
+ </style></head>
1927
+ <body>
1928
+ <h1><span class="brand">LLMUX</span> \u2014 auth required</h1>
1929
+ <div class="sub">${escapeHtml(message)}</div>
1930
+ <div class="card">
1931
+ <form id="auth-form">
1932
+ <label for="token">access token</label>
1933
+ <input id="token" type="password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" placeholder="sas_\u2026" required>
1934
+ <button type="submit">unlock</button>
1935
+ <div class="msg" id="msg"></div>
1936
+ </form>
1937
+ <div class="hint">
1938
+ Generate a token on the daemon host: <code>llmux token create</code><br>
1939
+ The token is sent as a cookie after unlock. Localhost bypasses this gate.
1940
+ </div>
1941
+ </div>
1942
+ <script>
1943
+ (function(){
1944
+ const form = document.getElementById('auth-form');
1945
+ const input = document.getElementById('token');
1946
+ const msg = document.getElementById('msg');
1947
+ form.addEventListener('submit', async function(e){
1948
+ e.preventDefault();
1949
+ const token = input.value.trim();
1950
+ if (!token) return;
1951
+ msg.textContent = '';
1952
+ const btn = form.querySelector('button');
1953
+ btn.disabled = true;
1954
+ try {
1955
+ const r = await fetch('/api/auth', {
1956
+ method: 'POST',
1957
+ headers: { 'content-type': 'application/json' },
1958
+ body: JSON.stringify({ token })
1959
+ });
1960
+ if (!r.ok) {
1961
+ const body = await r.json().catch(function(){ return {}; });
1962
+ msg.textContent = body.error || 'token rejected';
1963
+ btn.disabled = false;
1964
+ input.focus();
1965
+ input.select();
1966
+ return;
1967
+ }
1968
+ // Cookie set by server; reload the originally requested URL so the
1969
+ // user lands where they wanted, not at /. Strip any stale ?token= from
1970
+ // the URL \u2014 if we left it, the canonical-url rule on the next request
1971
+ // would invalidate the cookie we just set (infinite gate loop).
1972
+ const params = new URLSearchParams(location.search);
1973
+ params.delete('token');
1974
+ const query = params.toString();
1975
+ location.href = location.pathname + (query ? '?' + query : '');
1976
+ } catch(err){
1977
+ msg.textContent = 'request failed: ' + (err.message || err);
1978
+ btn.disabled = false;
1979
+ }
1980
+ });
1981
+ input.focus();
1982
+ })();
1983
+ </script>
1984
+ </body></html>`;
1985
+ }
1986
+ function sendGate(res, reason = "missing") {
1987
+ res.writeHead(401, { "content-type": "text/html; charset=utf-8" });
1988
+ res.end(gatePage(reason));
1989
+ }
1990
+ function buildCookie(token) {
1991
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`;
1992
+ }
1993
+ async function readJsonBody(req, limit = 64 * 1024) {
1994
+ return new Promise((resolve4, reject) => {
1995
+ let size = 0;
1996
+ const chunks = [];
1997
+ req.on("data", (chunk) => {
1998
+ size += chunk.length;
1999
+ if (size > limit) {
2000
+ req.destroy();
2001
+ reject(new Error("body too large"));
2002
+ return;
2003
+ }
2004
+ chunks.push(chunk);
2005
+ });
2006
+ req.on("end", () => {
2007
+ try {
2008
+ const text = Buffer.concat(chunks).toString("utf8");
2009
+ resolve4(text ? JSON.parse(text) : {});
2010
+ } catch (e) {
2011
+ reject(e);
2012
+ }
2013
+ });
2014
+ req.on("error", reject);
2015
+ });
2016
+ }
2017
+ function sendHtml(res, body, status2 = 200) {
2018
+ res.writeHead(status2, { "content-type": "text/html; charset=utf-8" });
2019
+ res.end(body);
2020
+ }
2021
+ function sendText(res, body, status2 = 200) {
2022
+ res.writeHead(status2, { "content-type": "text/plain; charset=utf-8" });
2023
+ res.end(body);
2024
+ }
2025
+ function sendJson(res, body, status2 = 200) {
2026
+ res.writeHead(status2, { "content-type": "application/json" });
2027
+ res.end(JSON.stringify(body));
2028
+ }
2029
+ function buildAgentCommand(agent, flagsOverride, resumeFrom) {
2030
+ const flags = flagsOverride !== void 0 ? flagsOverride : agent.flags ?? "";
2031
+ const resumeFragment = resumeFrom && agent.history ? agent.history.resumeFlag(resumeFrom) : "";
2032
+ const tail = [flags, resumeFragment].filter((s) => s.length > 0).join(" ");
2033
+ return tail ? `${agent.cmd} ${tail}` : agent.cmd;
2034
+ }
2035
+ function viewOf(s, live) {
2036
+ const agentDef = DEFAULT_AGENTS[s.agent];
2037
+ let conversationCount = 0;
2038
+ if (agentDef?.history) {
2039
+ try {
2040
+ conversationCount = agentDef.history.listConversations(s.cwd).length;
2041
+ } catch {
2042
+ conversationCount = 0;
2043
+ }
2044
+ }
2045
+ return {
2046
+ name: s.name,
2047
+ agent: s.agent,
2048
+ cwd: s.cwd,
2049
+ cwdDisplay: shortenCwd(s.cwd),
2050
+ ...s.flags !== void 0 ? { flags: s.flags } : {},
2051
+ defaultFlags: agentDef?.flags ?? "",
2052
+ ...s.env !== void 0 ? { env: s.env } : {},
2053
+ defaultEnv: agentDef?.envDefaults ?? {},
2054
+ ...s.resumeFrom !== void 0 ? { resumeFrom: s.resumeFrom } : {},
2055
+ hasHistory: Boolean(agentDef?.history),
2056
+ conversationCount,
2057
+ createdAt: s.createdAt,
2058
+ parent: s.parent,
2059
+ status: live ? "running" : "exited"
2060
+ };
2061
+ }
2062
+ function parseEnvText(text) {
2063
+ const out = {};
2064
+ for (const raw of text.split(/\r?\n/)) {
2065
+ const line = raw.trim();
2066
+ if (!line || line.startsWith("#")) continue;
2067
+ const eq = line.indexOf("=");
2068
+ if (eq <= 0) continue;
2069
+ const key = line.slice(0, eq).trim();
2070
+ if (!key) continue;
2071
+ out[key] = line.slice(eq + 1);
2072
+ }
2073
+ return out;
2074
+ }
2075
+ function mergeSpawnEnv(agent, sessionEnv, llmuxEnv) {
2076
+ return { ...agent.envDefaults ?? {}, ...sessionEnv ?? {}, ...llmuxEnv };
2077
+ }
2078
+ var SESSION_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
2079
+ function createSession(input) {
2080
+ if (!input.agent) return { ok: false, error: "agent is required" };
2081
+ const agentDef = DEFAULT_AGENTS[input.agent];
2082
+ if (!agentDef) return { ok: false, error: `unknown agent "${input.agent}"` };
2083
+ if (!isAgentInstalled(agentDef)) return { ok: false, error: `agent "${input.agent}" is not installed on the daemon host` };
2084
+ const name = input.name && input.name.trim() || agentDef.key;
2085
+ if (!SESSION_NAME_RE.test(name)) {
2086
+ return { ok: false, error: "name must start alphanumeric and contain only letters, numbers, _ or -" };
2087
+ }
2088
+ if (get(name) || hasSession(name)) {
2089
+ return { ok: false, error: `session "${name}" already exists` };
2090
+ }
2091
+ const cwdRaw = input.cwd && input.cwd.trim() || process.env.HOME || process.cwd();
2092
+ const cwd = expandTilde(cwdRaw);
2093
+ if (!existsSync4(cwd)) return { ok: false, error: `cwd does not exist: ${cwdRaw}` };
2094
+ const flagsOverride = input.flags !== void 0 ? input.flags.trim() : void 0;
2095
+ const envOverride = input.env !== void 0 ? parseEnvText(input.env) : void 0;
2096
+ const resumeFrom = input.resumeFrom && agentDef.history ? input.resumeFrom : void 0;
2097
+ try {
2098
+ newSession({
2099
+ name,
2100
+ command: buildAgentCommand(agentDef, flagsOverride, resumeFrom),
2101
+ cwd,
2102
+ env: mergeSpawnEnv(agentDef, envOverride, { LLMUX_SESSION: name, LLMUX_AGENT: agentDef.key })
2103
+ });
2104
+ } catch (err) {
2105
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2106
+ }
2107
+ const session = {
2108
+ name,
2109
+ agent: agentDef.key,
2110
+ cwd,
2111
+ ...flagsOverride !== void 0 ? { flags: flagsOverride } : {},
2112
+ ...envOverride !== void 0 ? { env: envOverride } : {},
2113
+ ...resumeFrom !== void 0 ? { resumeFrom } : {},
2114
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2115
+ parent: null,
2116
+ restart: "on-failure"
2117
+ };
2118
+ record(session);
2119
+ return { ok: true, session: viewOf(session, true) };
2120
+ }
2121
+ function respawnSession(name) {
2122
+ const session = get(name);
2123
+ if (!session) return { ok: false, error: `no tracked session "${name}"` };
2124
+ const agent = DEFAULT_AGENTS[session.agent];
2125
+ if (!agent) return { ok: false, error: `unknown agent "${session.agent}"` };
2126
+ if (!isAgentInstalled(agent)) return { ok: false, error: `agent "${session.agent}" is not installed` };
2127
+ if (hasSession(name)) {
2128
+ try {
2129
+ killSession(name);
2130
+ } catch (err) {
2131
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2132
+ }
2133
+ }
2134
+ try {
2135
+ newSession({
2136
+ name: session.name,
2137
+ command: buildAgentCommand(agent, session.flags, session.resumeFrom),
2138
+ cwd: session.cwd,
2139
+ env: mergeSpawnEnv(agent, session.env, { LLMUX_SESSION: session.name, LLMUX_AGENT: session.agent })
2140
+ });
2141
+ } catch (err) {
2142
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2143
+ }
2144
+ const refreshed = { ...session, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
2145
+ record(refreshed);
2146
+ return { ok: true, session: viewOf(refreshed, true) };
2147
+ }
2148
+ function resumeConversation(name, conversationId) {
2149
+ const session = get(name);
2150
+ if (!session) return { ok: false, error: `no tracked session "${name}"` };
2151
+ const agent = DEFAULT_AGENTS[session.agent];
2152
+ if (!agent) return { ok: false, error: `unknown agent "${session.agent}"` };
2153
+ if (!agent.history) return { ok: false, error: `agent "${session.agent}" has no history adapter` };
2154
+ if (!isAgentInstalled(agent)) return { ok: false, error: `agent "${session.agent}" is not installed` };
2155
+ if (hasSession(name)) {
2156
+ try {
2157
+ killSession(name);
2158
+ } catch (err) {
2159
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2160
+ }
2161
+ }
2162
+ try {
2163
+ newSession({
2164
+ name: session.name,
2165
+ command: buildAgentCommand(agent, session.flags, conversationId),
2166
+ cwd: session.cwd,
2167
+ env: mergeSpawnEnv(agent, session.env, { LLMUX_SESSION: session.name, LLMUX_AGENT: session.agent })
2168
+ });
2169
+ } catch (err) {
2170
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2171
+ }
2172
+ const refreshed = {
2173
+ ...session,
2174
+ resumeFrom: conversationId,
2175
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2176
+ };
2177
+ record(refreshed);
2178
+ return { ok: true, session: viewOf(refreshed, true) };
2179
+ }
2180
+ function editSession(oldName, patch) {
2181
+ const session = get(oldName);
2182
+ if (!session) return { ok: false, error: `no tracked session "${oldName}"` };
2183
+ const newName = patch.name?.trim();
2184
+ const newCwd = patch.cwd?.trim();
2185
+ if (newName !== void 0 && newName !== oldName) {
2186
+ if (!SESSION_NAME_RE.test(newName)) {
2187
+ return { ok: false, error: "name must start alphanumeric and contain only letters, numbers, _ or -" };
2188
+ }
2189
+ if (get(newName) || hasSession(newName)) {
2190
+ return { ok: false, error: `session "${newName}" already exists` };
2191
+ }
2192
+ }
2193
+ if (newCwd !== void 0 && newCwd.length > 0 && !existsSync4(expandTilde(newCwd))) {
2194
+ return { ok: false, error: `cwd does not exist: ${newCwd}` };
2195
+ }
2196
+ const renaming = newName !== void 0 && newName !== oldName && newName.length > 0;
2197
+ if (renaming) {
2198
+ try {
2199
+ renameSession(oldName, newName);
2200
+ } catch (err) {
2201
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2202
+ }
2203
+ }
2204
+ const nextFlags = patch.flags !== void 0 ? patch.flags.trim() : session.flags;
2205
+ const nextEnv = patch.env !== void 0 ? parseEnvText(patch.env) : session.env;
2206
+ const updated = {
2207
+ name: renaming ? newName : oldName,
2208
+ agent: session.agent,
2209
+ cwd: newCwd !== void 0 && newCwd.length > 0 ? expandTilde(newCwd) : session.cwd,
2210
+ ...nextFlags !== void 0 ? { flags: nextFlags } : {},
2211
+ ...nextEnv !== void 0 ? { env: nextEnv } : {},
2212
+ ...session.resumeFrom !== void 0 ? { resumeFrom: session.resumeFrom } : {},
2213
+ createdAt: session.createdAt,
2214
+ parent: session.parent,
2215
+ restart: session.restart
2216
+ };
2217
+ if (renaming) forget(oldName);
2218
+ record(updated);
2219
+ const cwdChanged = newCwd !== void 0 && newCwd.length > 0 && updated.cwd !== session.cwd;
2220
+ if (cwdChanged && hasSession(updated.name)) {
2221
+ const agent = DEFAULT_AGENTS[updated.agent];
2222
+ if (agent && isAgentInstalled(agent)) {
2223
+ try {
2224
+ killSession(updated.name);
2225
+ newSession({
2226
+ name: updated.name,
2227
+ command: buildAgentCommand(agent, updated.flags, updated.resumeFrom),
2228
+ cwd: updated.cwd,
2229
+ env: mergeSpawnEnv(agent, updated.env, { LLMUX_SESSION: updated.name, LLMUX_AGENT: updated.agent })
2230
+ });
2231
+ const refreshed = { ...updated, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
2232
+ record(refreshed);
2233
+ return { ok: true, session: viewOf(refreshed, true) };
2234
+ } catch (err) {
2235
+ return { ok: false, error: `cwd updated but restart failed: ${err instanceof Error ? err.message : String(err)}` };
2236
+ }
2237
+ }
2238
+ }
2239
+ const live = listSessions().some((s) => s.name === updated.name);
2240
+ return { ok: true, session: viewOf(updated, live) };
2241
+ }
2242
+ function killSession2(name) {
2243
+ const session = get(name);
2244
+ if (!session) return { ok: false, error: `no tracked session "${name}"` };
2245
+ const wasRunning = hasSession(name);
2246
+ try {
2247
+ killSession(name);
2248
+ } catch (err) {
2249
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
2250
+ }
2251
+ forget(name);
2252
+ return { ok: true, status: wasRunning ? "running" : "exited" };
2253
+ }
2254
+ var RESPAWN_RE = /^\/api\/sessions\/([^/]+)\/respawn$/;
2255
+ var KILL_RE = /^\/api\/sessions\/([^/]+)\/kill$/;
2256
+ var RESUME_RE = /^\/api\/sessions\/([^/]+)\/resume$/;
2257
+ var SEND_RE = /^\/api\/sessions\/([^/]+)\/send$/;
2258
+ var CONVERSATIONS_RE = /^\/api\/sessions\/([^/]+)\/conversations$/;
2259
+ var EDIT_RE = /^\/api\/sessions\/([^/]+)$/;
2260
+ function startServer(opts) {
2261
+ const http = createServer(async (req, res) => {
2262
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
2263
+ const method = req.method ?? "GET";
2264
+ const queryToken = url.searchParams.get("token");
2265
+ if (queryToken) {
2266
+ if (validateAuthToken(queryToken)) {
2267
+ url.searchParams.delete("token");
2268
+ const cleanPath = url.pathname + (url.searchParams.toString() ? "?" + url.searchParams.toString() : "");
2269
+ res.writeHead(302, {
2270
+ location: cleanPath,
2271
+ "set-cookie": buildCookie(queryToken)
2272
+ });
2273
+ return res.end();
2274
+ }
2275
+ res.writeHead(401, {
2276
+ "content-type": "text/html; charset=utf-8",
2277
+ "set-cookie": `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
2278
+ });
2279
+ return res.end(gatePage("invalid"));
2280
+ }
2281
+ if (url.pathname === "/health") {
2282
+ return sendJson(res, {
2283
+ ok: true,
2284
+ version: DAEMON_VERSION,
2285
+ sessions: list().length,
2286
+ authEnabled: authEnabled()
2287
+ });
2288
+ }
2289
+ if (url.pathname === "/api/version" && method === "GET") {
2290
+ return sendJson(res, { version: DAEMON_VERSION });
2291
+ }
2292
+ if (url.pathname === "/api/auth" && method === "POST") {
2293
+ try {
2294
+ const body = await readJsonBody(req);
2295
+ const candidate = typeof body.token === "string" ? body.token : "";
2296
+ if (!validateAuthToken(candidate)) {
2297
+ return sendJson(res, { ok: false, error: "invalid token" }, 401);
2298
+ }
2299
+ res.writeHead(200, {
2300
+ "content-type": "application/json",
2301
+ "set-cookie": buildCookie(candidate)
2302
+ });
2303
+ return res.end(JSON.stringify({ ok: true }));
2304
+ } catch (err) {
2305
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : "bad request" }, 400);
2306
+ }
2307
+ }
2308
+ if (!isAuthorized(req)) {
2309
+ const isApi = url.pathname.startsWith("/api/");
2310
+ if (isApi) {
2311
+ return sendJson(res, { ok: false, error: "unauthorized" }, 401);
2312
+ }
2313
+ const hasInvalidToken = Boolean(extractToken(req));
2314
+ return sendGate(res, hasInvalidToken ? "invalid" : "missing");
2315
+ }
2316
+ if (url.pathname === "/api/sessions" && method === "GET") {
2317
+ return sendJson(res, listSessionViews());
2318
+ }
2319
+ if (url.pathname === "/api/agents" && method === "GET") {
2320
+ const installed = Object.entries(DEFAULT_AGENTS).filter(([, def]) => isAgentInstalled(def)).map(([key, def]) => ({
2321
+ key,
2322
+ displayName: def.displayName,
2323
+ cmd: def.cmd,
2324
+ flags: def.flags ?? "",
2325
+ envDefaults: def.envDefaults ?? {}
2326
+ }));
2327
+ return sendJson(res, installed);
2328
+ }
2329
+ if (url.pathname === "/api/agents/all" && method === "GET") {
2330
+ const all = Object.entries(DEFAULT_AGENTS).map(([key, def]) => ({
2331
+ key,
2332
+ displayName: def.displayName,
2333
+ cmd: def.cmd,
2334
+ installed: isAgentInstalled(def),
2335
+ installHint: def.installHint ?? "",
2336
+ docsUrl: def.docsUrl ?? ""
2337
+ }));
2338
+ return sendJson(res, all);
2339
+ }
2340
+ if (url.pathname === "/api/sessions" && method === "POST") {
2341
+ try {
2342
+ const body = await readJsonBody(req);
2343
+ const result = createSession({
2344
+ agent: typeof body.agent === "string" ? body.agent : "",
2345
+ ...typeof body.name === "string" ? { name: body.name } : {},
2346
+ ...typeof body.cwd === "string" ? { cwd: body.cwd } : {},
2347
+ ...typeof body.flags === "string" ? { flags: body.flags } : {},
2348
+ ...typeof body.env === "string" ? { env: body.env } : {},
2349
+ ...typeof body.resumeFrom === "string" ? { resumeFrom: body.resumeFrom } : {}
2350
+ });
2351
+ return sendJson(res, result, result.ok ? 200 : 400);
2352
+ } catch (err) {
2353
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : "bad request" }, 400);
2354
+ }
2355
+ }
2356
+ if (method === "POST") {
2357
+ const mRespawn = url.pathname.match(RESPAWN_RE);
2358
+ if (mRespawn) {
2359
+ const name = decodeURIComponent(mRespawn[1]);
2360
+ const result = respawnSession(name);
2361
+ return sendJson(res, result, result.ok ? 200 : 400);
2362
+ }
2363
+ const mKill = url.pathname.match(KILL_RE);
2364
+ if (mKill) {
2365
+ const name = decodeURIComponent(mKill[1]);
2366
+ const result = killSession2(name);
2367
+ return sendJson(res, result, result.ok ? 200 : 400);
2368
+ }
2369
+ const mSend = url.pathname.match(SEND_RE);
2370
+ if (mSend) {
2371
+ const name = decodeURIComponent(mSend[1]);
2372
+ try {
2373
+ const body = await readJsonBody(req);
2374
+ if (typeof body.prompt !== "string" || body.prompt.length === 0) {
2375
+ return sendJson(res, { ok: false, error: "prompt required" }, 400);
2376
+ }
2377
+ if (!get(name)) return sendJson(res, { ok: false, error: `no tracked session "${name}"` }, 404);
2378
+ if (!hasSession(name)) return sendJson(res, { ok: false, error: `session "${name}" is not running` }, 409);
2379
+ const enter = body.enter !== false;
2380
+ try {
2381
+ sendKeys(name, body.prompt, { enter });
2382
+ } catch (err) {
2383
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500);
2384
+ }
2385
+ return sendJson(res, { ok: true });
2386
+ } catch (err) {
2387
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : "bad request" }, 400);
2388
+ }
2389
+ }
2390
+ const mResume = url.pathname.match(RESUME_RE);
2391
+ if (mResume) {
2392
+ const name = decodeURIComponent(mResume[1]);
2393
+ try {
2394
+ const body = await readJsonBody(req);
2395
+ if (typeof body.conversationId !== "string" || body.conversationId.length === 0) {
2396
+ return sendJson(res, { ok: false, error: "conversationId required" }, 400);
2397
+ }
2398
+ const result = resumeConversation(name, body.conversationId);
2399
+ return sendJson(res, result, result.ok ? 200 : 400);
2400
+ } catch (err) {
2401
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : "bad request" }, 400);
2402
+ }
2403
+ }
2404
+ }
2405
+ if (method === "GET") {
2406
+ const mConvs = url.pathname.match(CONVERSATIONS_RE);
2407
+ if (mConvs) {
2408
+ const name = decodeURIComponent(mConvs[1]);
2409
+ const session = get(name);
2410
+ if (!session) return sendJson(res, { ok: false, error: "session not found" }, 404);
2411
+ const agent = DEFAULT_AGENTS[session.agent];
2412
+ if (!agent?.history) return sendJson(res, []);
2413
+ try {
2414
+ const convs = agent.history.listConversations(session.cwd);
2415
+ return sendJson(res, convs);
2416
+ } catch (err) {
2417
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : "history read failed" }, 500);
2418
+ }
2419
+ }
2420
+ }
2421
+ if (method === "PATCH") {
2422
+ const mEdit = url.pathname.match(EDIT_RE);
2423
+ if (mEdit) {
2424
+ const name = decodeURIComponent(mEdit[1]);
2425
+ try {
2426
+ const body = await readJsonBody(req);
2427
+ const result = editSession(name, {
2428
+ ...typeof body.name === "string" ? { name: body.name } : {},
2429
+ ...typeof body.cwd === "string" ? { cwd: body.cwd } : {},
2430
+ ...typeof body.flags === "string" ? { flags: body.flags } : {},
2431
+ ...typeof body.env === "string" ? { env: body.env } : {}
2432
+ });
2433
+ return sendJson(res, result, result.ok ? 200 : 400);
2434
+ } catch (err) {
2435
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : "bad request" }, 400);
2436
+ }
2437
+ }
2438
+ }
2439
+ if (url.pathname === "/") {
2440
+ return sendHtml(res, pickerPage());
2441
+ }
2442
+ if (url.pathname.startsWith("/session/")) {
2443
+ const name = decodeURIComponent(url.pathname.slice("/session/".length));
2444
+ const session = get(name);
2445
+ if (!session) return sendText(res, "session not found", 404);
2446
+ if (!hasSession(name)) {
2447
+ return sendHtml(res, deadSessionPage(viewOf(session, false)));
2448
+ }
2449
+ return sendHtml(res, sessionPage(name));
2450
+ }
2451
+ return sendText(res, "not found", 404);
2452
+ });
2453
+ const wss = new WebSocketServer({ noServer: true });
2454
+ http.on("upgrade", (req, socket, head) => {
2455
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
2456
+ if (!url.pathname.startsWith("/ws/")) {
2457
+ socket.destroy();
2458
+ return;
2459
+ }
2460
+ if (!isWsAuthorized(req, url.searchParams)) {
2461
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2462
+ socket.destroy();
2463
+ return;
2464
+ }
2465
+ const name = decodeURIComponent(url.pathname.slice("/ws/".length));
2466
+ if (!get(name)) {
2467
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
2468
+ socket.destroy();
2469
+ return;
2470
+ }
2471
+ if (!hasSession(name)) {
2472
+ socket.write("HTTP/1.1 409 Conflict\r\n\r\n");
2473
+ socket.destroy();
2474
+ return;
2475
+ }
2476
+ wss.handleUpgrade(req, socket, head, (ws) => attachSession(ws, name));
2477
+ });
2478
+ http.listen(opts.port, opts.host);
2479
+ return {
2480
+ port: opts.port,
2481
+ stop: () => new Promise((resolve4) => {
2482
+ wss.close(() => http.close(() => resolve4()));
2483
+ })
2484
+ };
2485
+ }
2486
+ function attachSession(ws, sessionName) {
2487
+ const env = {};
2488
+ for (const [k, v] of Object.entries(process.env)) {
2489
+ if (v === void 0) continue;
2490
+ if (k === "TMUX" || k === "TMUX_PANE") continue;
2491
+ env[k] = v;
2492
+ }
2493
+ env.TERM = "xterm-256color";
2494
+ let term = null;
2495
+ try {
2496
+ term = pty.spawn("tmux", ["attach", "-t", sessionName], {
2497
+ name: "xterm-256color",
2498
+ cols: 80,
2499
+ rows: 24,
2500
+ cwd: process.env.HOME ?? process.cwd(),
2501
+ env
2502
+ });
2503
+ } catch (err) {
2504
+ ws.close(4040, `spawn failed: ${err instanceof Error ? err.message : String(err)}`);
2505
+ return;
2506
+ }
2507
+ term.onData((d) => {
2508
+ try {
2509
+ ws.send(d);
2510
+ } catch {
2511
+ term?.kill();
2512
+ }
2513
+ });
2514
+ term.onExit(({ exitCode, signal }) => {
2515
+ try {
2516
+ ws.close(4040, `pty exited code=${exitCode} signal=${signal ?? "none"}`);
2517
+ } catch {
2518
+ }
2519
+ });
2520
+ ws.on("message", (raw, isBinary) => {
2521
+ if (!term) return;
2522
+ const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : Buffer.from(raw).toString("utf8");
2523
+ if (!isBinary && text.startsWith("{")) {
2524
+ try {
2525
+ const parsed = JSON.parse(text);
2526
+ if (parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
2527
+ term.resize(parsed.cols, parsed.rows);
2528
+ return;
2529
+ }
2530
+ } catch {
2531
+ }
2532
+ }
2533
+ term.write(text);
2534
+ });
2535
+ ws.on("close", () => {
2536
+ term?.kill();
2537
+ term = null;
2538
+ });
2539
+ }
2540
+ function printBanner(port) {
2541
+ console.log(`llmux v${DAEMON_VERSION}
2542
+ `);
2543
+ const addrs = getAddresses(port);
2544
+ const width = Math.max(10, ...addrs.map((a) => a.label.length + 2));
2545
+ for (const addr of addrs) {
2546
+ console.log(` \u25B8 ${addr.label.padEnd(width)}${addr.url}`);
2547
+ }
2548
+ if (authEnabled()) {
2549
+ const count = listAuthTokens().length;
2550
+ console.log(`
2551
+ \u2713 auth required \u2014 ${count} active token${count === 1 ? "" : "s"} (localhost bypasses)
2552
+ `);
2553
+ } else {
2554
+ console.log(`
2555
+ \u26A0 running without auth \u2014 anyone on the network can attach.`);
2556
+ console.log(` create a token with \`llmux token create\` to enable auth.
2557
+ `);
2558
+ }
2559
+ }
2560
+
2561
+ // src/daemon/handlers.ts
2562
+ function expandAgentList(spec) {
2563
+ if (spec === "all") return Object.values(DEFAULT_AGENTS).filter(isAgentInstalled);
2564
+ const keys = spec.split(",").map((k) => k.trim()).filter(Boolean);
2565
+ const out = [];
2566
+ for (const k of keys) {
2567
+ const def = DEFAULT_AGENTS[k];
2568
+ if (!def) throw new Error(`unknown agent "${k}". Known: ${Object.keys(DEFAULT_AGENTS).join(", ")}`);
2569
+ if (!isAgentInstalled(def)) throw new Error(`agent "${k}" is not installed (looked for: ${def.cmd})`);
2570
+ out.push(def);
2571
+ }
2572
+ return out;
2573
+ }
2574
+ function buildCommand(agent) {
2575
+ return agent.flags ? `${agent.cmd} ${agent.flags}` : agent.cmd;
2576
+ }
2577
+ function resolveCwd(input) {
2578
+ if (!input) return process.cwd();
2579
+ const out = resolve2(input);
2580
+ if (!existsSync5(out)) throw new Error(`cwd does not exist: ${out}`);
2581
+ return out;
2582
+ }
2583
+ function resolveTarget(target) {
2584
+ const direct = get(target);
2585
+ if (direct) return { session: direct };
2586
+ const byAgent = list().filter((s) => s.agent === target);
2587
+ if (byAgent.length === 0) {
2588
+ throw new Error(`no session matches "${target}" (not a session name; no agent of that type running)`);
2589
+ }
2590
+ if (byAgent.length > 1) {
2591
+ const names = byAgent.map((s) => s.name).join(", ");
2592
+ throw new Error(`"${target}" is ambiguous \u2014 ${byAgent.length} ${target} sessions: ${names}`);
2593
+ }
2594
+ return { session: byAgent[0] };
2595
+ }
2596
+ function handleSpawn(args) {
2597
+ requireTmux();
2598
+ const spec = args.positional[0];
2599
+ if (!spec) throw new Error("spawn requires an agent (or `all`)");
2600
+ const name = args.flags.name;
2601
+ const prefix = args.flags.prefix;
2602
+ const cwd = resolveCwd(args.flags.cwd);
2603
+ if (name && prefix) throw new Error("--name and --prefix are mutually exclusive");
2604
+ const agents2 = expandAgentList(spec);
2605
+ if (name && agents2.length > 1) {
2606
+ throw new Error("--name is only valid with a single agent");
2607
+ }
2608
+ const parent = process.env.LLMUX_SESSION ?? null;
2609
+ const created = [];
2610
+ for (const agent of agents2) {
2611
+ const sessionName = name ?? (prefix ? `${prefix}${agent.key}` : agent.key);
2612
+ if (get(sessionName) || hasSession(sessionName)) {
2613
+ throw new Error(`session "${sessionName}" already exists`);
2614
+ }
2615
+ newSession({
2616
+ name: sessionName,
2617
+ command: buildCommand(agent),
2618
+ cwd,
2619
+ env: { LLMUX_SESSION: sessionName, LLMUX_AGENT: agent.key }
2620
+ });
2621
+ record({
2622
+ name: sessionName,
2623
+ agent: agent.key,
2624
+ cwd,
2625
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2626
+ parent,
2627
+ restart: "on-failure"
2628
+ });
2629
+ created.push(sessionName);
2630
+ console.log(`spawned ${sessionName} (agent: ${agent.key}, cwd: ${cwd})`);
2631
+ }
2632
+ if (created.length === 0) {
2633
+ console.log("no sessions spawned");
2634
+ }
2635
+ }
2636
+ function handleStatus(args) {
2637
+ const tracked = list();
2638
+ const live = new Set(listSessions().map((s) => s.name));
2639
+ if (args.flags.json) {
2640
+ const out = tracked.map((s) => ({
2641
+ ...s,
2642
+ state: live.has(s.name) ? "running" : "exited"
2643
+ }));
2644
+ console.log(JSON.stringify(out, null, 2));
2645
+ return;
2646
+ }
2647
+ if (tracked.length === 0) {
2648
+ console.log("no llmux sessions");
2649
+ return;
2650
+ }
2651
+ const rows = tracked.map((s) => [
2652
+ s.name,
2653
+ s.agent,
2654
+ live.has(s.name) ? "running" : "exited",
2655
+ s.parent ?? "-",
2656
+ s.cwd
2657
+ ]);
2658
+ const headers = ["NAME", "AGENT", "STATE", "PARENT", "CWD"];
2659
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2660
+ const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(" ");
2661
+ console.log(fmt(headers));
2662
+ for (const r of rows) console.log(fmt(r));
2663
+ }
2664
+ function handleSend(args) {
2665
+ requireTmux();
2666
+ const [target, ...promptParts] = args.positional;
2667
+ if (!target || promptParts.length === 0) {
2668
+ throw new Error('send requires <session> and "<prompt>"');
2669
+ }
2670
+ const prompt = promptParts.join(" ");
2671
+ const { session } = resolveTarget(target);
2672
+ if (!hasSession(session.name)) {
2673
+ throw new Error(`session "${session.name}" is in state but not live in tmux (exited?). Try \`llmux session restart ${session.name}\`.`);
2674
+ }
2675
+ sendKeys(session.name, prompt, { enter: true });
2676
+ console.log(`sent ${prompt.length} bytes \u2192 ${session.name}`);
2677
+ }
2678
+ function handleBroadcast(args) {
2679
+ requireTmux();
2680
+ const [agentKey, ...promptParts] = args.positional;
2681
+ if (!agentKey || promptParts.length === 0) {
2682
+ throw new Error('broadcast requires <agent> and "<prompt>"');
2683
+ }
2684
+ if (!DEFAULT_AGENTS[agentKey]) {
2685
+ throw new Error(`unknown agent "${agentKey}". Known: ${Object.keys(DEFAULT_AGENTS).join(", ")}`);
2686
+ }
2687
+ const prompt = promptParts.join(" ");
2688
+ const sessions = list().filter((s) => s.agent === agentKey);
2689
+ if (sessions.length === 0) {
2690
+ console.log(`no ${agentKey} sessions running`);
2691
+ return;
2692
+ }
2693
+ let n = 0;
2694
+ for (const s of sessions) {
2695
+ if (!hasSession(s.name)) continue;
2696
+ sendKeys(s.name, prompt, { enter: true });
2697
+ console.log(`sent \u2192 ${s.name}`);
2698
+ n++;
2699
+ }
2700
+ console.log(`broadcast to ${n}/${sessions.length} ${agentKey} sessions`);
2701
+ }
2702
+ function handleChat(args) {
2703
+ requireTmux();
2704
+ const target = args.positional[0];
2705
+ if (!target) throw new Error("chat requires <session>");
2706
+ if (args.flags.browser) {
2707
+ throw new Error("--browser requires the web server (`llmux server start`). Without --browser, use `llmux session attach` for raw TTY pass-through.");
2708
+ }
2709
+ const { session } = resolveTarget(target);
2710
+ if (!hasSession(session.name)) {
2711
+ throw new Error(`session "${session.name}" is not live in tmux`);
2712
+ }
2713
+ attachOrSwitch(session.name);
2714
+ }
2715
+ async function handleServe(args) {
2716
+ requireTmux();
2717
+ const portRaw = args.flags.port ?? process.env.LLMUXD_PORT ?? "3000";
2718
+ const port = Number(portRaw);
2719
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
2720
+ throw new Error(`invalid port: ${portRaw}`);
2721
+ }
2722
+ const host = process.env.LLMUXD_HOST ?? "0.0.0.0";
2723
+ const handle = startServer({ port, host });
2724
+ printBanner(handle.port);
2725
+ const shutdown = async (sig) => {
2726
+ console.log(`
2727
+ ${sig} received \u2014 shutting down`);
2728
+ await handle.stop();
2729
+ process.exit(0);
2730
+ };
2731
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2732
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2733
+ await new Promise(() => {
2734
+ });
2735
+ }
2736
+ function endpointPort() {
2737
+ return Number(process.env.LLMUX_PORT) || 3030;
2738
+ }
2739
+ function selectorOf(label) {
2740
+ return label.toLowerCase().replace(/\s+/g, "-");
2741
+ }
2742
+ function resolveQrEndpoint(selector, port) {
2743
+ const addrs = getAddresses(port);
2744
+ const wanted = selector.toLowerCase().trim();
2745
+ const matches = addrs.filter((a) => selectorOf(a.label) === wanted);
2746
+ if (matches.length === 0) {
2747
+ const available = Array.from(new Set(addrs.map((a) => selectorOf(a.label)))).join(", ");
2748
+ throw new Error(`unknown --qr-endpoint "${selector}". Available: ${available}`);
2749
+ }
2750
+ if (matches.length > 1) {
2751
+ throw new Error(
2752
+ `--qr-endpoint "${selector}" is ambiguous (${matches.length} matches). Use \`llmux token create --qr\` without an endpoint to pick interactively.`
2753
+ );
2754
+ }
2755
+ return matches[0];
2756
+ }
2757
+ async function pickEndpointInteractively(port) {
2758
+ const addrs = getAddresses(port);
2759
+ if (addrs.length === 0) throw new Error("no reachable endpoints found");
2760
+ console.log("");
2761
+ console.log("Pick an endpoint for the QR code:");
2762
+ for (let i = 0; i < addrs.length; i++) {
2763
+ console.log(` ${i + 1}) ${addrs[i].label.padEnd(18)} ${addrs[i].url}`);
2764
+ }
2765
+ if (!process.stdin.isTTY) {
2766
+ throw new Error("--qr without --qr-endpoint requires an interactive terminal");
2767
+ }
2768
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2769
+ const answer = await new Promise((resolve4) => rl.question(" > ", (a) => resolve4(a)));
2770
+ rl.close();
2771
+ const idx = Number(answer.trim()) - 1;
2772
+ if (!Number.isInteger(idx) || idx < 0 || idx >= addrs.length) {
2773
+ throw new Error(`invalid selection "${answer}"`);
2774
+ }
2775
+ return addrs[idx];
2776
+ }
2777
+ function printQr(url, token, label) {
2778
+ const deepLink = `${url.replace(/\/$/, "")}/?token=${encodeURIComponent(token)}`;
2779
+ console.log("");
2780
+ console.log(`QR for ${label}:`);
2781
+ console.log("");
2782
+ qrcodeTerminal.generate(deepLink, { small: true });
2783
+ console.log(` ${deepLink}`);
2784
+ }
2785
+ async function handleTokenCreate(args) {
2786
+ const name = args.flags.name;
2787
+ const expiry = args.flags.expiry;
2788
+ const qrFlag = Boolean(args.flags.qr);
2789
+ const qrEndpoint = args.flags["qr-endpoint"];
2790
+ if (expiry && isNaN(new Date(expiry).getTime())) {
2791
+ throw new Error(`--expiry must be an ISO-8601 timestamp (got "${expiry}")`);
2792
+ }
2793
+ const wantsQr = qrFlag || qrEndpoint !== void 0;
2794
+ let endpoint;
2795
+ if (wantsQr) {
2796
+ const port = endpointPort();
2797
+ endpoint = qrEndpoint ? resolveQrEndpoint(qrEndpoint, port) : await pickEndpointInteractively(port);
2798
+ }
2799
+ const wasEnabled = authEnabled();
2800
+ const rec = createAuthToken({
2801
+ ...name !== void 0 ? { name } : {},
2802
+ ...expiry !== void 0 ? { expiresAt: expiry } : {}
2803
+ });
2804
+ console.log(`token created (id: ${rec.id})${rec.name ? ` "${rec.name}"` : ""}`);
2805
+ console.log("");
2806
+ console.log(` ${rec.token}`);
2807
+ console.log("");
2808
+ console.log("Save this token now \u2014 it is shown once. Use in the LLMUX_TOKEN env var, the");
2809
+ console.log("`Authorization: Bearer <token>` header, or paste it into the web gate page.");
2810
+ if (!wasEnabled) {
2811
+ console.log("");
2812
+ console.log("Auth is now enabled. All non-localhost requests require this (or another) token.");
2813
+ }
2814
+ if (endpoint) {
2815
+ printQr(endpoint.url, rec.token, endpoint.label);
2816
+ }
2817
+ }
2818
+ function handleTokenShow(args) {
2819
+ const tokens = listAuthTokens();
2820
+ if (args.flags.json) {
2821
+ const out = tokens.map((t) => ({ id: t.id, name: t.name, createdAt: t.createdAt, expiresAt: t.expiresAt }));
2822
+ console.log(JSON.stringify(out, null, 2));
2823
+ return;
2824
+ }
2825
+ if (tokens.length === 0) {
2826
+ console.log("no tokens \u2014 auth is disabled. Create one with `llmux token create`.");
2827
+ return;
2828
+ }
2829
+ const headers = ["ID", "NAME", "CREATED", "EXPIRES"];
2830
+ const rows = tokens.map((t) => [t.id, t.name ?? "-", t.createdAt, t.expiresAt ?? "-"]);
2831
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2832
+ console.log(headers.map((h, i) => h.padEnd(widths[i])).join(" "));
2833
+ for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" "));
2834
+ }
2835
+ function handleTokenRevoke(args) {
2836
+ const idPrefix = args.positional[0] === "revoke" ? args.positional[1] : args.positional[0];
2837
+ if (!idPrefix) throw new Error("token revoke requires an <id> (the 8-char prefix shown by `token show`)");
2838
+ const ok = revokeAuthToken(idPrefix);
2839
+ if (!ok) throw new Error(`no token with id "${idPrefix}"`);
2840
+ console.log(`revoked ${idPrefix}`);
2841
+ if (!authEnabled()) {
2842
+ console.log("No tokens remain \u2014 auth is now disabled.");
2843
+ }
2844
+ }
2845
+ function handleRespawn(args) {
2846
+ requireTmux();
2847
+ const target = args.positional[0];
2848
+ if (!target) throw new Error("respawn requires <session>");
2849
+ const session = get(target);
2850
+ if (!session) throw new Error(`no tracked session "${target}"`);
2851
+ const agent = DEFAULT_AGENTS[session.agent];
2852
+ if (!agent) throw new Error(`unknown agent "${session.agent}" \u2014 cannot respawn`);
2853
+ if (!isAgentInstalled(agent)) {
2854
+ throw new Error(`agent "${session.agent}" is not installed (looked for: ${agent.cmd})`);
2855
+ }
2856
+ if (hasSession(target)) {
2857
+ killSession(target);
2858
+ }
2859
+ newSession({
2860
+ name: session.name,
2861
+ command: buildCommand(agent),
2862
+ cwd: session.cwd,
2863
+ env: { LLMUX_SESSION: session.name, LLMUX_AGENT: session.agent }
2864
+ });
2865
+ record({ ...session, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
2866
+ console.log(`respawned ${target} (agent: ${session.agent}, cwd: ${session.cwd})`);
2867
+ }
2868
+ function handleKill(args) {
2869
+ requireTmux();
2870
+ const target = args.positional[0];
2871
+ if (!target) throw new Error("kill requires <session> or `all`");
2872
+ const cascade = Boolean(args.flags.cascade);
2873
+ if (target === "all") {
2874
+ const all = list();
2875
+ for (const s of all) {
2876
+ killSession(s.name);
2877
+ forget(s.name);
2878
+ console.log(`killed ${s.name}`);
2879
+ }
2880
+ if (all.length === 0) console.log("no sessions to kill");
2881
+ return;
2882
+ }
2883
+ const session = get(target);
2884
+ if (!session) throw new Error(`no tracked session "${target}"`);
2885
+ if (cascade) {
2886
+ const queue = [target];
2887
+ const killed = /* @__PURE__ */ new Set();
2888
+ while (queue.length) {
2889
+ const name = queue.shift();
2890
+ if (killed.has(name)) continue;
2891
+ for (const child of children(name)) queue.push(child.name);
2892
+ killSession(name);
2893
+ forget(name);
2894
+ killed.add(name);
2895
+ console.log(`killed ${name}`);
2896
+ }
2897
+ return;
2898
+ }
2899
+ killSession(target);
2900
+ forget(target);
2901
+ console.log(`killed ${target}`);
2902
+ }
7
2903
 
8
- // src/client.ts
2904
+ // src/client/client.ts
9
2905
  import { createConnection } from "net";
10
- import { Buffer } from "buffer";
11
- import { createHash, randomBytes } from "crypto";
2906
+ import { Buffer as Buffer2 } from "buffer";
2907
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
12
2908
  function help(name, summary, usage) {
13
2909
  return () => [
14
2910
  `llmux ${name} \u2014 ${summary}`,
@@ -17,7 +2913,7 @@ function help(name, summary, usage) {
17
2913
  ` ${usage}`,
18
2914
  "",
19
2915
  "Environment:",
20
- " LLMUX_SERVER base URL of the llmuxd daemon (e.g. http://localhost:3030)",
2916
+ " LLMUX_SERVER base URL of the llmux daemon (e.g. http://localhost:3030)",
21
2917
  " LLMUX_TOKEN auth token (sas_\u2026); not required for localhost",
22
2918
  ""
23
2919
  ].join("\n");
@@ -25,7 +2921,7 @@ function help(name, summary, usage) {
25
2921
  function resolveContext() {
26
2922
  const baseUrl = process.env.LLMUX_SERVER;
27
2923
  if (!baseUrl) {
28
- throw new Error("LLMUX_SERVER is not set. Point it at your llmuxd (e.g. http://localhost:3030).");
2924
+ throw new Error("LLMUX_SERVER is not set. Point it at your llmux daemon (e.g. http://localhost:3030).");
29
2925
  }
30
2926
  return { baseUrl: baseUrl.replace(/\/$/, ""), token: process.env.LLMUX_TOKEN };
31
2927
  }
@@ -44,7 +2940,7 @@ async function request(ctx, method, path, body) {
44
2940
  }
45
2941
  if (r.status === 401) {
46
2942
  throw new Error(
47
- "unauthorized \u2014 set LLMUX_TOKEN (use `llmuxd token create` on the daemon host to mint one)"
2943
+ "unauthorized \u2014 set LLMUX_TOKEN (use `llmux token create` on the daemon host to mint one)"
48
2944
  );
49
2945
  }
50
2946
  if (r.status === 404) {
@@ -65,7 +2961,7 @@ async function request(ctx, method, path, body) {
65
2961
  }
66
2962
  return parsed;
67
2963
  }
68
- function parseArgs(argv) {
2964
+ function parseArgs2(argv) {
69
2965
  const positional = [];
70
2966
  const flags = {};
71
2967
  for (let i = 0; i < argv.length; i++) {
@@ -132,11 +3028,11 @@ function openWs(opts) {
132
3028
  const port = u.port ? Number(u.port) : 80;
133
3029
  const host = u.hostname;
134
3030
  const path = u.pathname + u.search;
135
- const key = randomBytes(16).toString("base64");
3031
+ const key = randomBytes2(16).toString("base64");
136
3032
  const expectedAccept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
137
3033
  const socket = createConnection({ host, port });
138
3034
  let handshakeDone = false;
139
- let recvBuf = Buffer.alloc(0);
3035
+ let recvBuf = Buffer2.alloc(0);
140
3036
  socket.on("connect", () => {
141
3037
  const lines = [
142
3038
  `GET ${path} HTTP/1.1`,
@@ -203,7 +3099,7 @@ function openWs(opts) {
203
3099
  return { frame: { opcode, payload }, rest: buf.slice(offset + len) };
204
3100
  }
205
3101
  socket.on("data", (chunk) => {
206
- recvBuf = Buffer.concat([recvBuf, chunk]);
3102
+ recvBuf = Buffer2.concat([recvBuf, chunk]);
207
3103
  if (!handshakeDone) {
208
3104
  const r = parseHandshake(recvBuf);
209
3105
  if (r.err) {
@@ -235,30 +3131,30 @@ function openWs(opts) {
235
3131
  socket.on("error", (err) => opts.onError(err));
236
3132
  socket.on("close", () => opts.onClose(1006, "socket closed"));
237
3133
  function sendFrame(opcode, payload) {
238
- const mask = randomBytes(4);
3134
+ const mask = randomBytes2(4);
239
3135
  const len = payload.length;
240
3136
  let header;
241
3137
  if (len < 126) {
242
- header = Buffer.alloc(2 + 4);
3138
+ header = Buffer2.alloc(2 + 4);
243
3139
  header[0] = 128 | opcode;
244
3140
  header[1] = 128 | len;
245
3141
  mask.copy(header, 2);
246
3142
  } else if (len < 65536) {
247
- header = Buffer.alloc(2 + 2 + 4);
3143
+ header = Buffer2.alloc(2 + 2 + 4);
248
3144
  header[0] = 128 | opcode;
249
3145
  header[1] = 128 | 126;
250
3146
  header.writeUInt16BE(len, 2);
251
3147
  mask.copy(header, 4);
252
3148
  } else {
253
- header = Buffer.alloc(2 + 8 + 4);
3149
+ header = Buffer2.alloc(2 + 8 + 4);
254
3150
  header[0] = 128 | opcode;
255
3151
  header[1] = 128 | 127;
256
3152
  header.writeBigUInt64BE(BigInt(len), 2);
257
3153
  mask.copy(header, 10);
258
3154
  }
259
- const masked = Buffer.allocUnsafe(payload.length);
3155
+ const masked = Buffer2.allocUnsafe(payload.length);
260
3156
  for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
261
- const out = Buffer.allocUnsafe(header.length + masked.length);
3157
+ const out = Buffer2.allocUnsafe(header.length + masked.length);
262
3158
  header.copy(out, 0);
263
3159
  masked.copy(out, header.length);
264
3160
  socket.write(out);
@@ -266,12 +3162,12 @@ function openWs(opts) {
266
3162
  return {
267
3163
  send(data) {
268
3164
  if (!handshakeDone) return;
269
- const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
3165
+ const buf = typeof data === "string" ? Buffer2.from(data, "utf8") : data;
270
3166
  sendFrame(typeof data === "string" ? 1 : 2, buf);
271
3167
  },
272
3168
  close() {
273
3169
  try {
274
- sendFrame(8, Buffer.alloc(0));
3170
+ sendFrame(8, Buffer2.alloc(0));
275
3171
  } catch {
276
3172
  }
277
3173
  socket.end();
@@ -287,7 +3183,7 @@ var ls = {
287
3183
  usage: "llmux ls [--json]",
288
3184
  help: help("ls", "List sessions on the daemon", "llmux ls [--json]"),
289
3185
  run: async (argv) => {
290
- const args = parseArgs(argv);
3186
+ const args = parseArgs2(argv);
291
3187
  const ctx = resolveContext();
292
3188
  const sessions = await request(ctx, "GET", "/api/sessions");
293
3189
  maybeJson(args, sessions, () => {
@@ -312,7 +3208,7 @@ var sendCmd = {
312
3208
  usage: 'llmux send <session> "<prompt>" [--no-enter]',
313
3209
  help: help("send", "Send a prompt to a session (fire-and-forget)", 'llmux send <session> "<prompt>" [--no-enter]'),
314
3210
  run: async (argv) => {
315
- const args = parseArgs(argv);
3211
+ const args = parseArgs2(argv);
316
3212
  const ctx = resolveContext();
317
3213
  const session = args.positional[0];
318
3214
  const prompt = args.positional[1];
@@ -323,12 +3219,12 @@ var sendCmd = {
323
3219
  else console.log(JSON.stringify({ ok: true }));
324
3220
  }
325
3221
  };
326
- var spawn = {
3222
+ var spawn2 = {
327
3223
  summary: "Spawn a new session on the daemon",
328
3224
  usage: 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]',
329
3225
  help: help("spawn", "Spawn a new session on the daemon", 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]'),
330
3226
  run: async (argv) => {
331
- const args = parseArgs(argv);
3227
+ const args = parseArgs2(argv);
332
3228
  const ctx = resolveContext();
333
3229
  const agent = args.positional[0];
334
3230
  if (!agent) throw new Error("spawn requires an <agent>");
@@ -347,7 +3243,7 @@ var kill = {
347
3243
  usage: "llmux kill <session>",
348
3244
  help: help("kill", "Kill a session", "llmux kill <session>"),
349
3245
  run: async (argv) => {
350
- const args = parseArgs(argv);
3246
+ const args = parseArgs2(argv);
351
3247
  const ctx = resolveContext();
352
3248
  const session = args.positional[0];
353
3249
  if (!session) throw new Error("kill requires <session>");
@@ -361,7 +3257,7 @@ var restart = {
361
3257
  usage: "llmux restart <session>",
362
3258
  help: help("restart", "Restart a session", "llmux restart <session>"),
363
3259
  run: async (argv) => {
364
- const args = parseArgs(argv);
3260
+ const args = parseArgs2(argv);
365
3261
  const ctx = resolveContext();
366
3262
  const session = args.positional[0];
367
3263
  if (!session) throw new Error("restart requires <session>");
@@ -374,7 +3270,7 @@ var resume = {
374
3270
  usage: "llmux resume <session> (--conversation <id> | --latest) [--json]",
375
3271
  help: help("resume", "Resume a past conversation", "llmux resume <session> (--conversation <id> | --latest)"),
376
3272
  run: async (argv) => {
377
- const args = parseArgs(argv);
3273
+ const args = parseArgs2(argv);
378
3274
  const ctx = resolveContext();
379
3275
  const session = args.positional[0];
380
3276
  if (!session) throw new Error("resume requires <session>");
@@ -401,7 +3297,7 @@ var conversations = {
401
3297
  usage: "llmux conversations <session> [--json]",
402
3298
  help: help("conversations", "List past conversations for this session's agent + cwd", "llmux conversations <session> [--json]"),
403
3299
  run: async (argv) => {
404
- const args = parseArgs(argv);
3300
+ const args = parseArgs2(argv);
405
3301
  const ctx = resolveContext();
406
3302
  const session = args.positional[0];
407
3303
  if (!session) throw new Error("conversations requires <session>");
@@ -426,12 +3322,12 @@ var agents = {
426
3322
  usage: "llmux agents [--all] [--json]",
427
3323
  help: help("agents", "List agents", "llmux agents [--all] [--json]"),
428
3324
  run: async (argv) => {
429
- const args = parseArgs(argv);
3325
+ const args = parseArgs2(argv);
430
3326
  const ctx = resolveContext();
431
3327
  const path = boolFlag(args, "all") ? "/api/agents/all" : "/api/agents";
432
- const list = await request(ctx, "GET", path);
433
- maybeJson(args, list, () => {
434
- const rows = list.map((a) => [
3328
+ const list2 = await request(ctx, "GET", path);
3329
+ maybeJson(args, list2, () => {
3330
+ const rows = list2.map((a) => [
435
3331
  a.key,
436
3332
  a.displayName,
437
3333
  a.cmd,
@@ -451,7 +3347,7 @@ var attach = {
451
3347
  "llmux attach <session>\n\nEscape: Ctrl+] to detach.\nResize is auto-detected via SIGWINCH.\n\nNote: only http:// is supported by the built-in WS client.\nFor https:// daemons, set LLMUX_SERVER to the local http:// URL,\nor use the browser web terminal."
452
3348
  ),
453
3349
  run: async (argv) => {
454
- const args = parseArgs(argv);
3350
+ const args = parseArgs2(argv);
455
3351
  const ctx = resolveContext();
456
3352
  const session = args.positional[0];
457
3353
  if (!session) throw new Error("attach requires <session>");
@@ -484,7 +3380,7 @@ var attach = {
484
3380
  const rows = stdout.rows ?? 24;
485
3381
  ws?.send(JSON.stringify({ type: "resize", cols, rows }));
486
3382
  }
487
- await new Promise((resolve2, reject) => {
3383
+ await new Promise((resolve4, reject) => {
488
3384
  ws = openWs({
489
3385
  url: wsUrl,
490
3386
  ...ctx.token !== void 0 ? { token: ctx.token } : {},
@@ -498,9 +3394,9 @@ var attach = {
498
3394
  stdout.write(`\r
499
3395
  [session ended: ${reason || "pty exited"}]\r
500
3396
  `);
501
- resolve2();
3397
+ resolve4();
502
3398
  } else if (code === 1e3) {
503
- resolve2();
3399
+ resolve4();
504
3400
  } else {
505
3401
  reject(new Error(`ws closed: code=${code} reason=${reason}`));
506
3402
  }
@@ -514,7 +3410,7 @@ var attach = {
514
3410
  if (chunk.includes("")) {
515
3411
  teardown();
516
3412
  stdout.write("\r\n[detached]\r\n");
517
- resolve2();
3413
+ resolve4();
518
3414
  return;
519
3415
  }
520
3416
  ws?.send(chunk);
@@ -532,7 +3428,7 @@ var clientCommands = {
532
3428
  ls,
533
3429
  status,
534
3430
  send: sendCmd,
535
- spawn,
3431
+ spawn: spawn2,
536
3432
  kill,
537
3433
  restart,
538
3434
  resume,
@@ -544,10 +3440,10 @@ var clientCommands = {
544
3440
  // src/index.ts
545
3441
  function readVersion() {
546
3442
  try {
547
- const here = dirname(fileURLToPath(import.meta.url));
548
- for (const candidate of [resolve(here, "../package.json"), resolve(here, "../../package.json")]) {
3443
+ const here = dirname4(fileURLToPath2(import.meta.url));
3444
+ for (const candidate of [resolve3(here, "../package.json"), resolve3(here, "../../package.json")]) {
549
3445
  try {
550
- const pkg = JSON.parse(readFileSync(candidate, "utf8"));
3446
+ const pkg = JSON.parse(readFileSync5(candidate, "utf8"));
551
3447
  if (pkg.name === "@cordfuse/llmux" && typeof pkg.version === "string") return pkg.version;
552
3448
  } catch {
553
3449
  }
@@ -557,55 +3453,349 @@ function readVersion() {
557
3453
  return "unknown";
558
3454
  }
559
3455
  var VERSION = readVersion();
3456
+ function stripGlobals(argv) {
3457
+ const env = {};
3458
+ const rest = [];
3459
+ let help2 = false;
3460
+ let version = false;
3461
+ if (process.env.LLMUX_SERVER) env.server = process.env.LLMUX_SERVER;
3462
+ if (process.env.LLMUX_TOKEN) env.token = process.env.LLMUX_TOKEN;
3463
+ for (let i = 0; i < argv.length; i++) {
3464
+ const t = argv[i];
3465
+ if (t === "--server") {
3466
+ const next = argv[i + 1];
3467
+ if (next === void 0 || next.startsWith("-")) throw new Error("--server requires a URL");
3468
+ env.server = next;
3469
+ i++;
3470
+ continue;
3471
+ }
3472
+ if (t.startsWith("--server=")) {
3473
+ env.server = t.slice("--server=".length);
3474
+ continue;
3475
+ }
3476
+ if (t === "--token") {
3477
+ const next = argv[i + 1];
3478
+ if (next === void 0 || next.startsWith("-")) throw new Error("--token requires a value");
3479
+ env.token = next;
3480
+ i++;
3481
+ continue;
3482
+ }
3483
+ if (t.startsWith("--token=")) {
3484
+ env.token = t.slice("--token=".length);
3485
+ continue;
3486
+ }
3487
+ if (t === "--help" || t === "-h") {
3488
+ help2 = true;
3489
+ continue;
3490
+ }
3491
+ if (t === "--version" || t === "-v") {
3492
+ version = true;
3493
+ continue;
3494
+ }
3495
+ rest.push(t);
3496
+ }
3497
+ return { rest, env, help: help2, version };
3498
+ }
560
3499
  function printRootHelp() {
561
- const lines = [];
562
- lines.push("llmux \u2014 HTTP client for llmuxd");
563
- lines.push("");
564
- lines.push(`Version: ${VERSION}`);
565
- lines.push("");
566
- lines.push("Usage:");
567
- lines.push(" llmux <command> [options]");
568
- lines.push("");
569
- lines.push("Commands:");
570
- const width = Math.max(...Object.keys(clientCommands).map((k) => k.length));
571
- for (const [name, cmd] of Object.entries(clientCommands)) {
572
- lines.push(` ${name.padEnd(width + 2)}${cmd.summary}`);
573
- }
574
- lines.push("");
575
- lines.push("Environment:");
576
- lines.push(" LLMUX_SERVER Base URL of llmuxd (e.g. http://host:3000)");
577
- lines.push(" LLMUX_TOKEN SAS token for authenticated requests");
578
- lines.push("");
579
- lines.push("Run `llmux <command> --help` for command-specific options.");
580
- console.log(lines.join("\n"));
3500
+ console.log(
3501
+ `llmux v${VERSION} \u2014 tmux-based AI agent dispatcher (daemon + client in one binary)
3502
+
3503
+ Usage:
3504
+ llmux <noun> <verb> [args] [--server <url>] [--token <sas>]
3505
+
3506
+ Session verbs (local by default; pass --server <url> to target a remote daemon):
3507
+ session list list tracked sessions
3508
+ session start <agent> [--name N] [--cwd P] spawn a new agent in tmux
3509
+ [--flags "F"] [--env "K=V"] [--resume-from <id>]
3510
+ session stop <name> kill + forget the session
3511
+ session restart <name> kill + relaunch with persisted config
3512
+ session attach <name> open the terminal (tmux locally, WS remotely)
3513
+ session prompt <name> "<text>" [--no-enter] send a prompt
3514
+ session broadcast <agent> "<text>" send to every session of an agent type (local)
3515
+ session resume <name> --conversation <id> | --latest
3516
+ rebind to a past agent conversation
3517
+ session history <name> list past conversations for the session's cwd
3518
+
3519
+ Server verbs (always local):
3520
+ server start [--port N] [--no-qr] run the HTTP/WS daemon (formerly: llmuxd serve)
3521
+
3522
+ Token verbs (always local \u2014 managing the daemon-host's auth store):
3523
+ token create [--name N] [--expiry ISO] [--qr] [--qr-endpoint <label>]
3524
+ token list show active tokens
3525
+ token revoke <id> revoke a token by id
3526
+
3527
+ Agent verbs:
3528
+ agent list [--all] [--installed] [--json] list agents (default: installed-only)
3529
+
3530
+ Global flags:
3531
+ --server <url> route session/agent verbs to a remote daemon over HTTP
3532
+ --token <sas> SAS token for remote auth (LLMUX_TOKEN env fallback)
3533
+ --help / -h print this help
3534
+ --version / -v print version
3535
+
3536
+ Environment:
3537
+ LLMUX_SERVER default --server URL
3538
+ LLMUX_TOKEN default --token value`
3539
+ );
581
3540
  }
582
- async function main() {
583
- const argv = process.argv.slice(2);
584
- if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
585
- printRootHelp();
3541
+ function printVerbHelp(noun, verb) {
3542
+ if (!verb) {
3543
+ console.log(`llmux ${noun} \u2014 see \`llmux --help\` for verbs under this noun`);
586
3544
  return;
587
3545
  }
588
- if (argv[0] === "--version" || argv[0] === "-v") {
589
- console.log(VERSION);
3546
+ console.log(`llmux ${noun} ${verb} \u2014 see \`llmux --help\` for usage`);
3547
+ }
3548
+ async function dispatchSession(verb, args, env) {
3549
+ if (!verb) {
3550
+ printVerbHelp("session", verb);
3551
+ return;
3552
+ }
3553
+ const v = verb === "ls" ? "list" : verb === "send" ? "prompt" : verb === "spawn" ? "start" : verb === "kill" ? "stop" : verb === "respawn" ? "restart" : verb === "conversations" ? "history" : verb;
3554
+ if (env.server !== void 0) {
3555
+ const cmdMap = {
3556
+ list: "ls",
3557
+ start: "spawn",
3558
+ stop: "kill",
3559
+ restart: "restart",
3560
+ attach: "attach",
3561
+ prompt: "send",
3562
+ resume: "resume",
3563
+ history: "conversations"
3564
+ };
3565
+ const clientCmd = cmdMap[v];
3566
+ if (!clientCmd) throw new Error(`session ${v}: no remote equivalent`);
3567
+ process.env.LLMUX_SERVER = env.server;
3568
+ if (env.token) process.env.LLMUX_TOKEN = env.token;
3569
+ const cmd = clientCommands[clientCmd];
3570
+ if (!cmd) throw new Error(`internal: client command "${clientCmd}" missing`);
3571
+ await cmd.run(args);
3572
+ return;
3573
+ }
3574
+ const parsed = parseArgs(args, sessionLocalFlags());
3575
+ switch (v) {
3576
+ case "list":
3577
+ handleStatus(parsed);
3578
+ return;
3579
+ case "start":
3580
+ handleSpawn(parsed);
3581
+ return;
3582
+ case "stop":
3583
+ handleKill(parsed);
3584
+ return;
3585
+ case "restart":
3586
+ handleRespawn(parsed);
3587
+ return;
3588
+ case "attach":
3589
+ handleChat(parsed);
3590
+ return;
3591
+ case "prompt":
3592
+ handleSend(parsed);
3593
+ return;
3594
+ case "broadcast":
3595
+ handleBroadcast(parsed);
3596
+ return;
3597
+ case "resume": {
3598
+ const name = parsed.positional[0];
3599
+ if (!name) throw new Error("session resume requires <name>");
3600
+ const session = get(name);
3601
+ if (!session) throw new Error(`no tracked session "${name}"`);
3602
+ const agent = DEFAULT_AGENTS[session.agent];
3603
+ if (!agent?.history) throw new Error(`agent "${session.agent}" has no history adapter`);
3604
+ if (!isAgentInstalled(agent)) throw new Error(`agent "${session.agent}" is not installed`);
3605
+ let conversationId = parsed.flags.conversation;
3606
+ if (!conversationId) {
3607
+ if (!parsed.flags.latest) throw new Error("resume requires --conversation <id> or --latest");
3608
+ const convs = agent.history.listConversations(session.cwd);
3609
+ if (convs.length === 0) throw new Error(`no past conversations for ${name}`);
3610
+ conversationId = convs[0].id;
3611
+ }
3612
+ if (hasSession(name)) killSession(name);
3613
+ const cmd = `${agent.cmd} ${agent.flags ?? ""} ${agent.history.resumeFlag(conversationId)}`.trim();
3614
+ newSession({
3615
+ name,
3616
+ command: cmd,
3617
+ cwd: session.cwd,
3618
+ env: { ...agent.envDefaults ?? {}, ...session.env ?? {}, LLMUX_SESSION: name, LLMUX_AGENT: session.agent }
3619
+ });
3620
+ record({ ...session, resumeFrom: conversationId, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
3621
+ console.log(`${name} resumed from ${conversationId.slice(0, 8)}\u2026`);
3622
+ return;
3623
+ }
3624
+ case "history": {
3625
+ const name = parsed.positional[0];
3626
+ if (!name) throw new Error("session history requires <name>");
3627
+ const session = get(name);
3628
+ if (!session) throw new Error(`no tracked session "${name}"`);
3629
+ const agent = DEFAULT_AGENTS[session.agent];
3630
+ if (!agent?.history) {
3631
+ console.log("agent has no history adapter");
3632
+ return;
3633
+ }
3634
+ const convs = agent.history.listConversations(session.cwd);
3635
+ if (convs.length === 0) {
3636
+ console.log("no past conversations");
3637
+ return;
3638
+ }
3639
+ for (const c of convs) console.log(`${c.id.slice(0, 8)}\u2026 ${c.messageCount.toString().padStart(5)} ${c.title.slice(0, 80)}`);
3640
+ return;
3641
+ }
3642
+ default:
3643
+ throw new Error(`unknown session verb "${v}"`);
3644
+ }
3645
+ }
3646
+ function sessionLocalFlags() {
3647
+ return {
3648
+ name: { kind: "string", description: "session name" },
3649
+ cwd: { kind: "string", description: "working directory" },
3650
+ flags: { kind: "string", description: "launch flags override" },
3651
+ env: { kind: "string", description: "env vars (KEY=VAL one per line)" },
3652
+ prefix: { kind: "string", description: "session-name prefix (start only)" },
3653
+ cascade: { kind: "boolean", description: "cascade kill to children" },
3654
+ conversation: { kind: "string", description: "conversation id (resume)" },
3655
+ latest: { kind: "boolean", description: "resume the most recent conversation" },
3656
+ "no-enter": { kind: "boolean", description: "do not append Enter to prompt" },
3657
+ browser: { kind: "boolean", description: "open in web browser (attach)" },
3658
+ it: { kind: "boolean", description: "interactive (attach)" },
3659
+ json: { kind: "boolean", description: "emit JSON" }
3660
+ };
3661
+ }
3662
+ async function dispatchServer(verb, args) {
3663
+ if (!verb) {
3664
+ printVerbHelp("server", verb);
3665
+ return;
3666
+ }
3667
+ const parsed = parseArgs(args, {
3668
+ config: { kind: "string", description: "Path to .llmux.yaml" },
3669
+ port: { kind: "string", description: "Listen port" },
3670
+ "no-qr": { kind: "boolean", description: "Suppress QR codes" }
3671
+ });
3672
+ switch (verb) {
3673
+ case "start":
3674
+ case "serve":
3675
+ await handleServe(parsed);
3676
+ return;
3677
+ default:
3678
+ throw new Error(`unknown server verb "${verb}"`);
3679
+ }
3680
+ }
3681
+ async function dispatchToken(verb, args) {
3682
+ if (!verb) {
3683
+ printVerbHelp("token", verb);
3684
+ return;
3685
+ }
3686
+ const parsed = parseArgs(args, {
3687
+ name: { kind: "string", description: "token label" },
3688
+ expiry: { kind: "string", description: "ISO-8601 expiry" },
3689
+ qr: { kind: "boolean", description: "render QR for first-tap login" },
3690
+ "qr-endpoint": { kind: "string", description: "endpoint label or URL for QR target" },
3691
+ json: { kind: "boolean", description: "emit JSON" }
3692
+ });
3693
+ switch (verb) {
3694
+ case "create":
3695
+ await handleTokenCreate(parsed);
3696
+ return;
3697
+ case "list":
3698
+ case "show":
3699
+ handleTokenShow(parsed);
3700
+ return;
3701
+ case "revoke":
3702
+ handleTokenRevoke(parsed);
3703
+ return;
3704
+ default:
3705
+ throw new Error(`unknown token verb "${verb}"`);
3706
+ }
3707
+ }
3708
+ async function dispatchAgent(verb, args, env) {
3709
+ if (!verb) {
3710
+ printVerbHelp("agent", verb);
590
3711
  return;
591
3712
  }
592
- const name = argv[0];
593
- const rest = argv.slice(1);
594
- const command = clientCommands[name];
595
- if (!command) {
596
- console.error(`llmux: unknown command "${name}"`);
597
- console.error("Run `llmux --help` to see available commands.");
598
- process.exit(64);
3713
+ if (env.server !== void 0 && verb === "list") {
3714
+ process.env.LLMUX_SERVER = env.server;
3715
+ if (env.token) process.env.LLMUX_TOKEN = env.token;
3716
+ const cmd = clientCommands["agents"];
3717
+ if (!cmd) throw new Error("internal: client agents command missing");
3718
+ await cmd.run(args);
3719
+ return;
3720
+ }
3721
+ const parsed = parseArgs(args, {
3722
+ all: { kind: "boolean", description: "include not-installed agents" },
3723
+ installed: { kind: "boolean", description: "only installed agents (default)" },
3724
+ json: { kind: "boolean", description: "emit JSON" }
3725
+ });
3726
+ switch (verb) {
3727
+ case "list": {
3728
+ const showAll = Boolean(parsed.flags.all);
3729
+ const rows = Object.values(DEFAULT_AGENTS).filter((d) => showAll || isAgentInstalled(d)).map((d) => ({ key: d.key, displayName: d.displayName, cmd: d.cmd, flags: d.flags ?? "", installed: isAgentInstalled(d) }));
3730
+ if (parsed.flags.json) {
3731
+ console.log(JSON.stringify(rows, null, 2));
3732
+ return;
3733
+ }
3734
+ for (const r of rows) {
3735
+ console.log(`${r.key.padEnd(10)} ${r.displayName.padEnd(24)} ${r.installed ? "installed" : "not installed"} ${r.flags || "-"}`);
3736
+ }
3737
+ return;
3738
+ }
3739
+ default:
3740
+ throw new Error(`unknown agent verb "${verb}"`);
3741
+ }
3742
+ }
3743
+ async function main() {
3744
+ const { rest, env, help: help2, version } = stripGlobals(process.argv.slice(2));
3745
+ if (version) {
3746
+ console.log(VERSION);
3747
+ return;
599
3748
  }
600
- if (rest.includes("--help") || rest.includes("-h")) {
601
- console.log(command.help());
3749
+ if (rest.length === 0 || help2) {
3750
+ printRootHelp();
602
3751
  return;
603
3752
  }
3753
+ const noun = rest[0];
3754
+ const verb = rest[1];
3755
+ const remainder = rest.slice(2);
604
3756
  try {
605
- await command.run(rest);
3757
+ switch (noun) {
3758
+ case "session":
3759
+ await dispatchSession(verb, remainder, env);
3760
+ return;
3761
+ case "server":
3762
+ await dispatchServer(verb, remainder);
3763
+ return;
3764
+ case "token":
3765
+ await dispatchToken(verb, remainder);
3766
+ return;
3767
+ case "agent":
3768
+ await dispatchAgent(verb, remainder, env);
3769
+ return;
3770
+ // Backward-compat shorthand — some shells will already have `llmuxd serve`
3771
+ // wired up. These verbs sit at noun-position so all of rest.slice(1) is
3772
+ // their args, not just slice(2).
3773
+ case "serve":
3774
+ await dispatchServer("start", rest.slice(1));
3775
+ return;
3776
+ case "ls":
3777
+ case "status":
3778
+ await dispatchSession("list", rest.slice(1), env);
3779
+ return;
3780
+ case "help":
3781
+ printRootHelp();
3782
+ return;
3783
+ default: {
3784
+ const cmd = clientCommands[noun];
3785
+ if (cmd) {
3786
+ if (env.server) process.env.LLMUX_SERVER = env.server;
3787
+ if (env.token) process.env.LLMUX_TOKEN = env.token;
3788
+ await cmd.run([verb, ...remainder].filter((x) => x !== void 0));
3789
+ return;
3790
+ }
3791
+ console.error(`llmux: unknown command "${noun}"`);
3792
+ console.error("Run `llmux --help` to see the noun-prefix surface.");
3793
+ process.exit(64);
3794
+ }
3795
+ }
606
3796
  } catch (err) {
607
3797
  const msg = err instanceof Error ? err.message : String(err);
608
- console.error(`llmux ${name}: ${msg}`);
3798
+ console.error(`llmux: ${msg}`);
609
3799
  process.exit(1);
610
3800
  }
611
3801
  }