@cordfuse/llmux 0.10.5 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3743 -91
- package/package.json +17 -4
- package/src/cli.ts +100 -0
- package/src/client/client.ts +674 -0
- package/src/daemon/agents.ts +193 -0
- package/src/daemon/auth-store.ts +85 -0
- package/src/daemon/config.ts +77 -0
- package/src/daemon/handlers.ts +414 -0
- package/src/daemon/net.ts +113 -0
- package/src/daemon/state.ts +78 -0
- package/src/daemon/tmux.ts +117 -0
- package/src/daemon/token.ts +13 -0
- package/src/daemon/web/server.ts +2277 -0
- package/src/index.ts +386 -37
- package/src/client.ts +0 -110
package/dist/index.js
CHANGED
|
@@ -1,91 +1,3449 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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>llmuxd 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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>llmuxd spawn 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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>llmuxd spawn 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 llmuxd</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 & 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="<"><</button><button data-char=">">></button>
|
|
1500
|
+
<button data-char="'">'</button><button data-char=""">"</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="&">&</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 & 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 llmuxd instance 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>llmuxd \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>llmuxd 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(`llmuxd 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 \`llmuxd token create\` to enable auth.
|
|
2557
|
+
`);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
7
2560
|
|
|
8
|
-
// src/
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
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 llmuxd 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 \`llmuxd respawn ${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 `llmuxd serve` (Phase 4). Use `llmuxd chat` without --browser for now.");
|
|
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 \`llmuxd 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 `llmuxd 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}`);
|
|
12
2902
|
}
|
|
2903
|
+
|
|
2904
|
+
// src/client/client.ts
|
|
2905
|
+
import { createConnection } from "net";
|
|
2906
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
2907
|
+
import { createHash, randomBytes as randomBytes2 } from "crypto";
|
|
13
2908
|
function help(name, summary, usage) {
|
|
14
|
-
return () => [
|
|
2909
|
+
return () => [
|
|
2910
|
+
`llmux ${name} \u2014 ${summary}`,
|
|
2911
|
+
"",
|
|
2912
|
+
"Usage:",
|
|
2913
|
+
` ${usage}`,
|
|
2914
|
+
"",
|
|
2915
|
+
"Environment:",
|
|
2916
|
+
" LLMUX_SERVER base URL of the llmuxd daemon (e.g. http://localhost:3030)",
|
|
2917
|
+
" LLMUX_TOKEN auth token (sas_\u2026); not required for localhost",
|
|
2918
|
+
""
|
|
2919
|
+
].join("\n");
|
|
15
2920
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
2921
|
+
function resolveContext() {
|
|
2922
|
+
const baseUrl = process.env.LLMUX_SERVER;
|
|
2923
|
+
if (!baseUrl) {
|
|
2924
|
+
throw new Error("LLMUX_SERVER is not set. Point it at your llmuxd (e.g. http://localhost:3030).");
|
|
2925
|
+
}
|
|
2926
|
+
return { baseUrl: baseUrl.replace(/\/$/, ""), token: process.env.LLMUX_TOKEN };
|
|
2927
|
+
}
|
|
2928
|
+
async function request(ctx, method, path, body) {
|
|
2929
|
+
const url = ctx.baseUrl + path;
|
|
2930
|
+
const headers = { accept: "application/json" };
|
|
2931
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
2932
|
+
if (ctx.token) headers["authorization"] = `Bearer ${ctx.token}`;
|
|
2933
|
+
const init = { method, headers };
|
|
2934
|
+
if (body !== void 0) init.body = JSON.stringify(body);
|
|
2935
|
+
let r;
|
|
2936
|
+
try {
|
|
2937
|
+
r = await fetch(url, init);
|
|
2938
|
+
} catch (err) {
|
|
2939
|
+
throw new Error(`network error reaching ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2940
|
+
}
|
|
2941
|
+
if (r.status === 401) {
|
|
2942
|
+
throw new Error(
|
|
2943
|
+
"unauthorized \u2014 set LLMUX_TOKEN (use `llmuxd token create` on the daemon host to mint one)"
|
|
2944
|
+
);
|
|
2945
|
+
}
|
|
2946
|
+
if (r.status === 404) {
|
|
2947
|
+
throw new Error("not found \u2014 check the session name (try `llmux ls`)");
|
|
2948
|
+
}
|
|
2949
|
+
const text = await r.text();
|
|
2950
|
+
let parsed = void 0;
|
|
2951
|
+
if (text.length > 0) {
|
|
2952
|
+
try {
|
|
2953
|
+
parsed = JSON.parse(text);
|
|
2954
|
+
} catch {
|
|
2955
|
+
throw new Error(`unexpected non-JSON response (${r.status}): ${text.slice(0, 200)}`);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
if (!r.ok) {
|
|
2959
|
+
const msg = parsed?.error ?? `http ${r.status}`;
|
|
2960
|
+
throw new Error(msg);
|
|
2961
|
+
}
|
|
2962
|
+
return parsed;
|
|
2963
|
+
}
|
|
2964
|
+
function parseArgs2(argv) {
|
|
2965
|
+
const positional = [];
|
|
2966
|
+
const flags = {};
|
|
2967
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2968
|
+
const token = argv[i];
|
|
2969
|
+
if (token === "--") {
|
|
2970
|
+
positional.push(...argv.slice(i + 1));
|
|
2971
|
+
break;
|
|
2972
|
+
}
|
|
2973
|
+
if (token.startsWith("--")) {
|
|
2974
|
+
const body = token.slice(2);
|
|
2975
|
+
const eq = body.indexOf("=");
|
|
2976
|
+
if (eq >= 0) {
|
|
2977
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
2978
|
+
continue;
|
|
2979
|
+
}
|
|
2980
|
+
const next = argv[i + 1];
|
|
2981
|
+
if (next === void 0 || next.startsWith("-")) {
|
|
2982
|
+
flags[body] = true;
|
|
2983
|
+
} else {
|
|
2984
|
+
flags[body] = next;
|
|
2985
|
+
i++;
|
|
2986
|
+
}
|
|
2987
|
+
continue;
|
|
2988
|
+
}
|
|
2989
|
+
positional.push(token);
|
|
2990
|
+
}
|
|
2991
|
+
return { positional, flags };
|
|
2992
|
+
}
|
|
2993
|
+
function flag(args, name) {
|
|
2994
|
+
const v = args.flags[name];
|
|
2995
|
+
return typeof v === "string" ? v : void 0;
|
|
2996
|
+
}
|
|
2997
|
+
function boolFlag(args, name) {
|
|
2998
|
+
return args.flags[name] === true || args.flags[name] === "true";
|
|
2999
|
+
}
|
|
3000
|
+
function maybeJson(args, data, fallback) {
|
|
3001
|
+
if (boolFlag(args, "json")) {
|
|
3002
|
+
console.log(JSON.stringify(data, null, 2));
|
|
3003
|
+
} else {
|
|
3004
|
+
fallback();
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
function relTime(iso) {
|
|
3008
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
3009
|
+
if (isNaN(ms) || ms < 0) return iso;
|
|
3010
|
+
if (ms < 6e4) return "just now";
|
|
3011
|
+
const m = Math.floor(ms / 6e4);
|
|
3012
|
+
if (m < 60) return `${m}m ago`;
|
|
3013
|
+
const h = Math.floor(m / 60);
|
|
3014
|
+
if (h < 24) return `${h}h ago`;
|
|
3015
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
3016
|
+
}
|
|
3017
|
+
function table(headers, rows) {
|
|
3018
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
3019
|
+
const lines = [headers.map((h, i) => h.padEnd(widths[i])).join(" ")];
|
|
3020
|
+
for (const r of rows) lines.push(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" "));
|
|
3021
|
+
return lines.join("\n");
|
|
3022
|
+
}
|
|
3023
|
+
function openWs(opts) {
|
|
3024
|
+
const u = new URL(opts.url);
|
|
3025
|
+
if (opts.token && !u.searchParams.has("token")) u.searchParams.set("token", opts.token);
|
|
3026
|
+
const isSecure = u.protocol === "wss:";
|
|
3027
|
+
if (isSecure) throw new Error("wss:// not supported by the built-in client yet \u2014 use ws:// (tailscale serve terminates TLS for browsers)");
|
|
3028
|
+
const port = u.port ? Number(u.port) : 80;
|
|
3029
|
+
const host = u.hostname;
|
|
3030
|
+
const path = u.pathname + u.search;
|
|
3031
|
+
const key = randomBytes2(16).toString("base64");
|
|
3032
|
+
const expectedAccept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
|
|
3033
|
+
const socket = createConnection({ host, port });
|
|
3034
|
+
let handshakeDone = false;
|
|
3035
|
+
let recvBuf = Buffer2.alloc(0);
|
|
3036
|
+
socket.on("connect", () => {
|
|
3037
|
+
const lines = [
|
|
3038
|
+
`GET ${path} HTTP/1.1`,
|
|
3039
|
+
`Host: ${u.host}`,
|
|
3040
|
+
`Upgrade: websocket`,
|
|
3041
|
+
`Connection: Upgrade`,
|
|
3042
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
3043
|
+
`Sec-WebSocket-Version: 13`,
|
|
3044
|
+
``,
|
|
3045
|
+
``
|
|
3046
|
+
];
|
|
3047
|
+
socket.write(lines.join("\r\n"));
|
|
3048
|
+
});
|
|
3049
|
+
function parseHandshake(buf) {
|
|
3050
|
+
const headerEnd = buf.indexOf("\r\n\r\n");
|
|
3051
|
+
if (headerEnd < 0) return { ok: false, offset: 0 };
|
|
3052
|
+
const head = buf.slice(0, headerEnd).toString("utf8");
|
|
3053
|
+
const lines = head.split("\r\n");
|
|
3054
|
+
const statusLine = lines[0] ?? "";
|
|
3055
|
+
const m = statusLine.match(/^HTTP\/1\.[01] (\d+) /);
|
|
3056
|
+
if (!m || m[1] !== "101") {
|
|
3057
|
+
return { ok: false, offset: 0, err: `expected 101 Switching Protocols, got: ${statusLine}` };
|
|
3058
|
+
}
|
|
3059
|
+
let acceptOk = false;
|
|
3060
|
+
for (const ln of lines.slice(1)) {
|
|
3061
|
+
const idx = ln.indexOf(":");
|
|
3062
|
+
if (idx < 0) continue;
|
|
3063
|
+
const k = ln.slice(0, idx).trim().toLowerCase();
|
|
3064
|
+
const v = ln.slice(idx + 1).trim();
|
|
3065
|
+
if (k === "sec-websocket-accept" && v === expectedAccept) acceptOk = true;
|
|
3066
|
+
}
|
|
3067
|
+
if (!acceptOk) return { ok: false, offset: 0, err: "Sec-WebSocket-Accept mismatch" };
|
|
3068
|
+
return { ok: true, offset: headerEnd + 4 };
|
|
3069
|
+
}
|
|
3070
|
+
function parseFrame(buf) {
|
|
3071
|
+
if (buf.length < 2) return { frame: null, rest: buf };
|
|
3072
|
+
const b0 = buf[0];
|
|
3073
|
+
const b1 = buf[1];
|
|
3074
|
+
const opcode = b0 & 15;
|
|
3075
|
+
const masked = (b1 & 128) !== 0;
|
|
3076
|
+
let len = b1 & 127;
|
|
3077
|
+
let offset = 2;
|
|
3078
|
+
if (len === 126) {
|
|
3079
|
+
if (buf.length < offset + 2) return { frame: null, rest: buf };
|
|
3080
|
+
len = buf.readUInt16BE(offset);
|
|
3081
|
+
offset += 2;
|
|
3082
|
+
} else if (len === 127) {
|
|
3083
|
+
if (buf.length < offset + 8) return { frame: null, rest: buf };
|
|
3084
|
+
const big = buf.readBigUInt64BE(offset);
|
|
3085
|
+
len = Number(big);
|
|
3086
|
+
offset += 8;
|
|
3087
|
+
}
|
|
3088
|
+
let maskKey = null;
|
|
3089
|
+
if (masked) {
|
|
3090
|
+
if (buf.length < offset + 4) return { frame: null, rest: buf };
|
|
3091
|
+
maskKey = buf.slice(offset, offset + 4);
|
|
3092
|
+
offset += 4;
|
|
3093
|
+
}
|
|
3094
|
+
if (buf.length < offset + len) return { frame: null, rest: buf };
|
|
3095
|
+
const payload = buf.slice(offset, offset + len);
|
|
3096
|
+
if (maskKey) {
|
|
3097
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4];
|
|
3098
|
+
}
|
|
3099
|
+
return { frame: { opcode, payload }, rest: buf.slice(offset + len) };
|
|
3100
|
+
}
|
|
3101
|
+
socket.on("data", (chunk) => {
|
|
3102
|
+
recvBuf = Buffer2.concat([recvBuf, chunk]);
|
|
3103
|
+
if (!handshakeDone) {
|
|
3104
|
+
const r = parseHandshake(recvBuf);
|
|
3105
|
+
if (r.err) {
|
|
3106
|
+
opts.onError(new Error(r.err));
|
|
3107
|
+
socket.destroy();
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
if (!r.ok) return;
|
|
3111
|
+
handshakeDone = true;
|
|
3112
|
+
recvBuf = recvBuf.slice(r.offset);
|
|
3113
|
+
}
|
|
3114
|
+
while (recvBuf.length >= 2) {
|
|
3115
|
+
const { frame, rest } = parseFrame(recvBuf);
|
|
3116
|
+
if (!frame) break;
|
|
3117
|
+
recvBuf = rest;
|
|
3118
|
+
if (frame.opcode === 1) opts.onMessage(frame.payload.toString("utf8"));
|
|
3119
|
+
else if (frame.opcode === 2) opts.onMessage(frame.payload);
|
|
3120
|
+
else if (frame.opcode === 8) {
|
|
3121
|
+
const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : 1e3;
|
|
3122
|
+
const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString("utf8") : "";
|
|
3123
|
+
opts.onClose(code, reason);
|
|
3124
|
+
socket.end();
|
|
3125
|
+
return;
|
|
3126
|
+
} else if (frame.opcode === 9) {
|
|
3127
|
+
sendFrame(10, frame.payload);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
});
|
|
3131
|
+
socket.on("error", (err) => opts.onError(err));
|
|
3132
|
+
socket.on("close", () => opts.onClose(1006, "socket closed"));
|
|
3133
|
+
function sendFrame(opcode, payload) {
|
|
3134
|
+
const mask = randomBytes2(4);
|
|
3135
|
+
const len = payload.length;
|
|
3136
|
+
let header;
|
|
3137
|
+
if (len < 126) {
|
|
3138
|
+
header = Buffer2.alloc(2 + 4);
|
|
3139
|
+
header[0] = 128 | opcode;
|
|
3140
|
+
header[1] = 128 | len;
|
|
3141
|
+
mask.copy(header, 2);
|
|
3142
|
+
} else if (len < 65536) {
|
|
3143
|
+
header = Buffer2.alloc(2 + 2 + 4);
|
|
3144
|
+
header[0] = 128 | opcode;
|
|
3145
|
+
header[1] = 128 | 126;
|
|
3146
|
+
header.writeUInt16BE(len, 2);
|
|
3147
|
+
mask.copy(header, 4);
|
|
3148
|
+
} else {
|
|
3149
|
+
header = Buffer2.alloc(2 + 8 + 4);
|
|
3150
|
+
header[0] = 128 | opcode;
|
|
3151
|
+
header[1] = 128 | 127;
|
|
3152
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
3153
|
+
mask.copy(header, 10);
|
|
3154
|
+
}
|
|
3155
|
+
const masked = Buffer2.allocUnsafe(payload.length);
|
|
3156
|
+
for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
|
|
3157
|
+
const out = Buffer2.allocUnsafe(header.length + masked.length);
|
|
3158
|
+
header.copy(out, 0);
|
|
3159
|
+
masked.copy(out, header.length);
|
|
3160
|
+
socket.write(out);
|
|
3161
|
+
}
|
|
3162
|
+
return {
|
|
3163
|
+
send(data) {
|
|
3164
|
+
if (!handshakeDone) return;
|
|
3165
|
+
const buf = typeof data === "string" ? Buffer2.from(data, "utf8") : data;
|
|
3166
|
+
sendFrame(typeof data === "string" ? 1 : 2, buf);
|
|
3167
|
+
},
|
|
3168
|
+
close() {
|
|
3169
|
+
try {
|
|
3170
|
+
sendFrame(8, Buffer2.alloc(0));
|
|
3171
|
+
} catch {
|
|
3172
|
+
}
|
|
3173
|
+
socket.end();
|
|
3174
|
+
}
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
function pushIf(o, k, v) {
|
|
3178
|
+
if (v !== void 0 && v !== null && v !== "") o[k] = v;
|
|
3179
|
+
return o;
|
|
3180
|
+
}
|
|
3181
|
+
var ls = {
|
|
3182
|
+
summary: "List sessions on the daemon",
|
|
3183
|
+
usage: "llmux ls [--json]",
|
|
3184
|
+
help: help("ls", "List sessions on the daemon", "llmux ls [--json]"),
|
|
20
3185
|
run: async (argv) => {
|
|
21
|
-
|
|
22
|
-
|
|
3186
|
+
const args = parseArgs2(argv);
|
|
3187
|
+
const ctx = resolveContext();
|
|
3188
|
+
const sessions = await request(ctx, "GET", "/api/sessions");
|
|
3189
|
+
maybeJson(args, sessions, () => {
|
|
3190
|
+
if (sessions.length === 0) {
|
|
3191
|
+
console.log("no sessions");
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
const rows = sessions.map((s) => [
|
|
3195
|
+
s.name,
|
|
3196
|
+
s.agent,
|
|
3197
|
+
s.status,
|
|
3198
|
+
relTime(s.createdAt),
|
|
3199
|
+
s.cwdDisplay,
|
|
3200
|
+
s.resumeFrom ? `\u21BB ${s.resumeFrom.slice(0, 8)}\u2026` : ""
|
|
3201
|
+
]);
|
|
3202
|
+
console.log(table(["NAME", "AGENT", "STATE", "STARTED", "CWD", "RESUMED"], rows));
|
|
3203
|
+
});
|
|
23
3204
|
}
|
|
24
3205
|
};
|
|
25
|
-
var
|
|
26
|
-
summary: "Send a prompt to
|
|
27
|
-
usage: 'llmux
|
|
28
|
-
help: help("
|
|
3206
|
+
var sendCmd = {
|
|
3207
|
+
summary: "Send a prompt to a session (fire-and-forget)",
|
|
3208
|
+
usage: 'llmux send <session> "<prompt>" [--no-enter]',
|
|
3209
|
+
help: help("send", "Send a prompt to a session (fire-and-forget)", 'llmux send <session> "<prompt>" [--no-enter]'),
|
|
29
3210
|
run: async (argv) => {
|
|
30
|
-
|
|
31
|
-
|
|
3211
|
+
const args = parseArgs2(argv);
|
|
3212
|
+
const ctx = resolveContext();
|
|
3213
|
+
const session = args.positional[0];
|
|
3214
|
+
const prompt = args.positional[1];
|
|
3215
|
+
if (!session || !prompt) throw new Error('send requires <session> and "<prompt>"');
|
|
3216
|
+
const enter = !boolFlag(args, "no-enter");
|
|
3217
|
+
await request(ctx, "POST", `/api/sessions/${encodeURIComponent(session)}/send`, { prompt, enter });
|
|
3218
|
+
if (!boolFlag(args, "json")) console.log(`sent to ${session}`);
|
|
3219
|
+
else console.log(JSON.stringify({ ok: true }));
|
|
32
3220
|
}
|
|
33
3221
|
};
|
|
34
|
-
var
|
|
35
|
-
summary: "Spawn
|
|
36
|
-
usage:
|
|
37
|
-
help: help(
|
|
38
|
-
"spawn",
|
|
39
|
-
"Spawn one or more agent sessions",
|
|
40
|
-
"llmux spawn <agent|list|all> [--name <n>] [--prefix <p>] [--cwd <path>]"
|
|
41
|
-
),
|
|
3222
|
+
var spawn2 = {
|
|
3223
|
+
summary: "Spawn a new session on the daemon",
|
|
3224
|
+
usage: 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]',
|
|
3225
|
+
help: help("spawn", "Spawn a new session on the daemon", 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]'),
|
|
42
3226
|
run: async (argv) => {
|
|
43
|
-
|
|
44
|
-
|
|
3227
|
+
const args = parseArgs2(argv);
|
|
3228
|
+
const ctx = resolveContext();
|
|
3229
|
+
const agent = args.positional[0];
|
|
3230
|
+
if (!agent) throw new Error("spawn requires an <agent>");
|
|
3231
|
+
const env = flag(args, "env");
|
|
3232
|
+
const body = { agent };
|
|
3233
|
+
pushIf(body, "name", flag(args, "name"));
|
|
3234
|
+
pushIf(body, "cwd", flag(args, "cwd"));
|
|
3235
|
+
pushIf(body, "flags", flag(args, "flags"));
|
|
3236
|
+
pushIf(body, "env", env);
|
|
3237
|
+
const r = await request(ctx, "POST", "/api/sessions", body);
|
|
3238
|
+
maybeJson(args, r.session, () => console.log(`spawned ${r.session.name} (agent: ${r.session.agent})`));
|
|
45
3239
|
}
|
|
46
3240
|
};
|
|
47
3241
|
var kill = {
|
|
48
|
-
summary: "
|
|
49
|
-
usage: "llmux kill <session
|
|
50
|
-
help: help("kill", "
|
|
3242
|
+
summary: "Kill a session",
|
|
3243
|
+
usage: "llmux kill <session>",
|
|
3244
|
+
help: help("kill", "Kill a session", "llmux kill <session>"),
|
|
51
3245
|
run: async (argv) => {
|
|
52
|
-
|
|
53
|
-
|
|
3246
|
+
const args = parseArgs2(argv);
|
|
3247
|
+
const ctx = resolveContext();
|
|
3248
|
+
const session = args.positional[0];
|
|
3249
|
+
if (!session) throw new Error("kill requires <session>");
|
|
3250
|
+
await request(ctx, "POST", `/api/sessions/${encodeURIComponent(session)}/kill`);
|
|
3251
|
+
if (!boolFlag(args, "json")) console.log(`killed ${session}`);
|
|
3252
|
+
else console.log(JSON.stringify({ ok: true }));
|
|
54
3253
|
}
|
|
55
3254
|
};
|
|
56
|
-
var
|
|
57
|
-
summary: "
|
|
58
|
-
usage: "llmux
|
|
59
|
-
help: help("
|
|
60
|
-
run: async () => {
|
|
61
|
-
|
|
3255
|
+
var restart = {
|
|
3256
|
+
summary: "Restart a session (kill + respawn with persisted config)",
|
|
3257
|
+
usage: "llmux restart <session>",
|
|
3258
|
+
help: help("restart", "Restart a session", "llmux restart <session>"),
|
|
3259
|
+
run: async (argv) => {
|
|
3260
|
+
const args = parseArgs2(argv);
|
|
3261
|
+
const ctx = resolveContext();
|
|
3262
|
+
const session = args.positional[0];
|
|
3263
|
+
if (!session) throw new Error("restart requires <session>");
|
|
3264
|
+
const r = await request(ctx, "POST", `/api/sessions/${encodeURIComponent(session)}/respawn`);
|
|
3265
|
+
maybeJson(args, r.session, () => console.log(`restarted ${r.session.name}`));
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
var resume = {
|
|
3269
|
+
summary: "Resume one of the agent's past conversations on this session",
|
|
3270
|
+
usage: "llmux resume <session> (--conversation <id> | --latest) [--json]",
|
|
3271
|
+
help: help("resume", "Resume a past conversation", "llmux resume <session> (--conversation <id> | --latest)"),
|
|
3272
|
+
run: async (argv) => {
|
|
3273
|
+
const args = parseArgs2(argv);
|
|
3274
|
+
const ctx = resolveContext();
|
|
3275
|
+
const session = args.positional[0];
|
|
3276
|
+
if (!session) throw new Error("resume requires <session>");
|
|
3277
|
+
let conversationId = flag(args, "conversation");
|
|
3278
|
+
if (!conversationId) {
|
|
3279
|
+
if (!boolFlag(args, "latest")) {
|
|
3280
|
+
throw new Error("resume requires --conversation <id> or --latest");
|
|
3281
|
+
}
|
|
3282
|
+
const convs = await request(ctx, "GET", `/api/sessions/${encodeURIComponent(session)}/conversations`);
|
|
3283
|
+
if (convs.length === 0) throw new Error(`no past conversations for ${session}`);
|
|
3284
|
+
conversationId = convs[0].id;
|
|
3285
|
+
}
|
|
3286
|
+
const r = await request(
|
|
3287
|
+
ctx,
|
|
3288
|
+
"POST",
|
|
3289
|
+
`/api/sessions/${encodeURIComponent(session)}/resume`,
|
|
3290
|
+
{ conversationId }
|
|
3291
|
+
);
|
|
3292
|
+
maybeJson(args, r.session, () => console.log(`${r.session.name} resumed from ${conversationId.slice(0, 8)}\u2026`));
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
3295
|
+
var conversations = {
|
|
3296
|
+
summary: "List the agent's past conversations for this session's cwd",
|
|
3297
|
+
usage: "llmux conversations <session> [--json]",
|
|
3298
|
+
help: help("conversations", "List past conversations for this session's agent + cwd", "llmux conversations <session> [--json]"),
|
|
3299
|
+
run: async (argv) => {
|
|
3300
|
+
const args = parseArgs2(argv);
|
|
3301
|
+
const ctx = resolveContext();
|
|
3302
|
+
const session = args.positional[0];
|
|
3303
|
+
if (!session) throw new Error("conversations requires <session>");
|
|
3304
|
+
const convs = await request(ctx, "GET", `/api/sessions/${encodeURIComponent(session)}/conversations`);
|
|
3305
|
+
maybeJson(args, convs, () => {
|
|
3306
|
+
if (convs.length === 0) {
|
|
3307
|
+
console.log("no past conversations");
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
const rows = convs.map((c) => [
|
|
3311
|
+
c.id.slice(0, 8) + "\u2026",
|
|
3312
|
+
relTime(c.lastMessageAt),
|
|
3313
|
+
String(c.messageCount),
|
|
3314
|
+
c.title.slice(0, 80)
|
|
3315
|
+
]);
|
|
3316
|
+
console.log(table(["ID", "LAST", "MSGS", "TITLE"], rows));
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3319
|
+
};
|
|
3320
|
+
var agents = {
|
|
3321
|
+
summary: "List agents (installed by default; --all for the full catalog)",
|
|
3322
|
+
usage: "llmux agents [--all] [--json]",
|
|
3323
|
+
help: help("agents", "List agents", "llmux agents [--all] [--json]"),
|
|
3324
|
+
run: async (argv) => {
|
|
3325
|
+
const args = parseArgs2(argv);
|
|
3326
|
+
const ctx = resolveContext();
|
|
3327
|
+
const path = boolFlag(args, "all") ? "/api/agents/all" : "/api/agents";
|
|
3328
|
+
const list2 = await request(ctx, "GET", path);
|
|
3329
|
+
maybeJson(args, list2, () => {
|
|
3330
|
+
const rows = list2.map((a) => [
|
|
3331
|
+
a.key,
|
|
3332
|
+
a.displayName,
|
|
3333
|
+
a.cmd,
|
|
3334
|
+
a.flags || "-",
|
|
3335
|
+
"installed" in a ? a.installed ? "yes" : "no" : "yes"
|
|
3336
|
+
]);
|
|
3337
|
+
console.log(table(["KEY", "NAME", "CMD", "FLAGS", "INSTALLED"], rows));
|
|
3338
|
+
});
|
|
62
3339
|
}
|
|
63
3340
|
};
|
|
64
|
-
var
|
|
65
|
-
summary: "
|
|
66
|
-
usage: "llmux
|
|
67
|
-
help: help(
|
|
3341
|
+
var attach = {
|
|
3342
|
+
summary: "Attach to a session via WebSocket (raw TTY pass-through)",
|
|
3343
|
+
usage: "llmux attach <session>",
|
|
3344
|
+
help: help(
|
|
3345
|
+
"attach",
|
|
3346
|
+
"Attach to a session via WebSocket",
|
|
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."
|
|
3348
|
+
),
|
|
68
3349
|
run: async (argv) => {
|
|
69
|
-
|
|
70
|
-
|
|
3350
|
+
const args = parseArgs2(argv);
|
|
3351
|
+
const ctx = resolveContext();
|
|
3352
|
+
const session = args.positional[0];
|
|
3353
|
+
if (!session) throw new Error("attach requires <session>");
|
|
3354
|
+
const baseUrl = new URL(ctx.baseUrl);
|
|
3355
|
+
if (baseUrl.protocol === "https:") {
|
|
3356
|
+
throw new Error("attach via wss:// is not supported by the built-in client; LLMUX_SERVER must be http://");
|
|
3357
|
+
}
|
|
3358
|
+
const wsUrl = (baseUrl.protocol === "https:" ? "wss://" : "ws://") + baseUrl.host + "/ws/" + encodeURIComponent(session);
|
|
3359
|
+
const stdin = process.stdin;
|
|
3360
|
+
const stdout = process.stdout;
|
|
3361
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
3362
|
+
stdin.resume();
|
|
3363
|
+
stdin.setEncoding("utf8");
|
|
3364
|
+
let ws = null;
|
|
3365
|
+
let closed = false;
|
|
3366
|
+
function teardown() {
|
|
3367
|
+
if (closed) return;
|
|
3368
|
+
closed = true;
|
|
3369
|
+
try {
|
|
3370
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
3371
|
+
} catch {
|
|
3372
|
+
}
|
|
3373
|
+
stdin.removeAllListeners("data");
|
|
3374
|
+
process.removeAllListeners("SIGWINCH");
|
|
3375
|
+
ws?.close();
|
|
3376
|
+
}
|
|
3377
|
+
function sendResize() {
|
|
3378
|
+
if (!stdout.isTTY) return;
|
|
3379
|
+
const cols = stdout.columns ?? 80;
|
|
3380
|
+
const rows = stdout.rows ?? 24;
|
|
3381
|
+
ws?.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
3382
|
+
}
|
|
3383
|
+
await new Promise((resolve4, reject) => {
|
|
3384
|
+
ws = openWs({
|
|
3385
|
+
url: wsUrl,
|
|
3386
|
+
...ctx.token !== void 0 ? { token: ctx.token } : {},
|
|
3387
|
+
onMessage: (data) => {
|
|
3388
|
+
if (typeof data === "string") stdout.write(data);
|
|
3389
|
+
else stdout.write(data);
|
|
3390
|
+
},
|
|
3391
|
+
onClose: (code, reason) => {
|
|
3392
|
+
teardown();
|
|
3393
|
+
if (code === 4040) {
|
|
3394
|
+
stdout.write(`\r
|
|
3395
|
+
[session ended: ${reason || "pty exited"}]\r
|
|
3396
|
+
`);
|
|
3397
|
+
resolve4();
|
|
3398
|
+
} else if (code === 1e3) {
|
|
3399
|
+
resolve4();
|
|
3400
|
+
} else {
|
|
3401
|
+
reject(new Error(`ws closed: code=${code} reason=${reason}`));
|
|
3402
|
+
}
|
|
3403
|
+
},
|
|
3404
|
+
onError: (err) => {
|
|
3405
|
+
teardown();
|
|
3406
|
+
reject(err);
|
|
3407
|
+
}
|
|
3408
|
+
});
|
|
3409
|
+
stdin.on("data", (chunk) => {
|
|
3410
|
+
if (chunk.includes("")) {
|
|
3411
|
+
teardown();
|
|
3412
|
+
stdout.write("\r\n[detached]\r\n");
|
|
3413
|
+
resolve4();
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
ws?.send(chunk);
|
|
3417
|
+
});
|
|
3418
|
+
setImmediate(sendResize);
|
|
3419
|
+
process.on("SIGWINCH", sendResize);
|
|
3420
|
+
});
|
|
71
3421
|
}
|
|
72
3422
|
};
|
|
3423
|
+
var status = {
|
|
3424
|
+
...ls,
|
|
3425
|
+
summary: "List sessions on the daemon (alias of `ls`)"
|
|
3426
|
+
};
|
|
73
3427
|
var clientCommands = {
|
|
74
|
-
|
|
75
|
-
broadcast,
|
|
76
|
-
spawn,
|
|
77
|
-
kill,
|
|
3428
|
+
ls,
|
|
78
3429
|
status,
|
|
79
|
-
|
|
3430
|
+
send: sendCmd,
|
|
3431
|
+
spawn: spawn2,
|
|
3432
|
+
kill,
|
|
3433
|
+
restart,
|
|
3434
|
+
resume,
|
|
3435
|
+
conversations,
|
|
3436
|
+
agents,
|
|
3437
|
+
attach
|
|
80
3438
|
};
|
|
81
3439
|
|
|
82
3440
|
// src/index.ts
|
|
83
3441
|
function readVersion() {
|
|
84
3442
|
try {
|
|
85
|
-
const here =
|
|
86
|
-
for (const candidate of [
|
|
3443
|
+
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
3444
|
+
for (const candidate of [resolve3(here, "../package.json"), resolve3(here, "../../package.json")]) {
|
|
87
3445
|
try {
|
|
88
|
-
const pkg = JSON.parse(
|
|
3446
|
+
const pkg = JSON.parse(readFileSync5(candidate, "utf8"));
|
|
89
3447
|
if (pkg.name === "@cordfuse/llmux" && typeof pkg.version === "string") return pkg.version;
|
|
90
3448
|
} catch {
|
|
91
3449
|
}
|
|
@@ -95,55 +3453,349 @@ function readVersion() {
|
|
|
95
3453
|
return "unknown";
|
|
96
3454
|
}
|
|
97
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
|
+
}
|
|
98
3499
|
function printRootHelp() {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
);
|
|
119
3540
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
printRootHelp();
|
|
3541
|
+
function printVerbHelp(noun, verb) {
|
|
3542
|
+
if (!verb) {
|
|
3543
|
+
console.log(`llmux ${noun} \u2014 see \`llmux --help\` for verbs under this noun`);
|
|
124
3544
|
return;
|
|
125
3545
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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);
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
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);
|
|
128
3719
|
return;
|
|
129
3720
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|
|
137
3748
|
}
|
|
138
|
-
if (rest.
|
|
139
|
-
|
|
3749
|
+
if (rest.length === 0 || help2) {
|
|
3750
|
+
printRootHelp();
|
|
140
3751
|
return;
|
|
141
3752
|
}
|
|
3753
|
+
const noun = rest[0];
|
|
3754
|
+
const verb = rest[1];
|
|
3755
|
+
const remainder = rest.slice(2);
|
|
142
3756
|
try {
|
|
143
|
-
|
|
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
|
+
}
|
|
144
3796
|
} catch (err) {
|
|
145
3797
|
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
-
console.error(`llmux
|
|
3798
|
+
console.error(`llmux: ${msg}`);
|
|
147
3799
|
process.exit(1);
|
|
148
3800
|
}
|
|
149
3801
|
}
|