@cordfuse/llmux 0.10.4 → 0.11.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.
Files changed (3) hide show
  1. package/dist/index.js +509 -47
  2. package/package.json +1 -1
  3. package/src/client.ts +620 -56
package/dist/index.js CHANGED
@@ -6,77 +6,539 @@ import { fileURLToPath } from "url";
6
6
  import { dirname, resolve } from "path";
7
7
 
8
8
  // src/client.ts
9
- function notImplemented(command) {
10
- console.error(`llmux ${command}: not yet implemented (scaffold)`);
11
- process.exit(70);
12
- }
9
+ import { createConnection } from "net";
10
+ import { Buffer } from "buffer";
11
+ import { createHash, randomBytes } from "crypto";
13
12
  function help(name, summary, usage) {
14
- return () => [`llmux ${name} \u2014 ${summary}`, "", "Usage:", ` ${usage}`, ""].join("\n");
13
+ return () => [
14
+ `llmux ${name} \u2014 ${summary}`,
15
+ "",
16
+ "Usage:",
17
+ ` ${usage}`,
18
+ "",
19
+ "Environment:",
20
+ " LLMUX_SERVER base URL of the llmuxd daemon (e.g. http://localhost:3030)",
21
+ " LLMUX_TOKEN auth token (sas_\u2026); not required for localhost",
22
+ ""
23
+ ].join("\n");
15
24
  }
16
- var send = {
17
- summary: "Send a prompt to a session (fire-and-forget)",
18
- usage: 'llmux send <session> "<prompt>"',
19
- help: help("send", "Send a prompt to a session (fire-and-forget)", 'llmux send <session> "<prompt>"'),
25
+ function resolveContext() {
26
+ const baseUrl = process.env.LLMUX_SERVER;
27
+ if (!baseUrl) {
28
+ throw new Error("LLMUX_SERVER is not set. Point it at your llmuxd (e.g. http://localhost:3030).");
29
+ }
30
+ return { baseUrl: baseUrl.replace(/\/$/, ""), token: process.env.LLMUX_TOKEN };
31
+ }
32
+ async function request(ctx, method, path, body) {
33
+ const url = ctx.baseUrl + path;
34
+ const headers = { accept: "application/json" };
35
+ if (body !== void 0) headers["content-type"] = "application/json";
36
+ if (ctx.token) headers["authorization"] = `Bearer ${ctx.token}`;
37
+ const init = { method, headers };
38
+ if (body !== void 0) init.body = JSON.stringify(body);
39
+ let r;
40
+ try {
41
+ r = await fetch(url, init);
42
+ } catch (err) {
43
+ throw new Error(`network error reaching ${url}: ${err instanceof Error ? err.message : String(err)}`);
44
+ }
45
+ if (r.status === 401) {
46
+ throw new Error(
47
+ "unauthorized \u2014 set LLMUX_TOKEN (use `llmuxd token create` on the daemon host to mint one)"
48
+ );
49
+ }
50
+ if (r.status === 404) {
51
+ throw new Error("not found \u2014 check the session name (try `llmux ls`)");
52
+ }
53
+ const text = await r.text();
54
+ let parsed = void 0;
55
+ if (text.length > 0) {
56
+ try {
57
+ parsed = JSON.parse(text);
58
+ } catch {
59
+ throw new Error(`unexpected non-JSON response (${r.status}): ${text.slice(0, 200)}`);
60
+ }
61
+ }
62
+ if (!r.ok) {
63
+ const msg = parsed?.error ?? `http ${r.status}`;
64
+ throw new Error(msg);
65
+ }
66
+ return parsed;
67
+ }
68
+ function parseArgs(argv) {
69
+ const positional = [];
70
+ const flags = {};
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const token = argv[i];
73
+ if (token === "--") {
74
+ positional.push(...argv.slice(i + 1));
75
+ break;
76
+ }
77
+ if (token.startsWith("--")) {
78
+ const body = token.slice(2);
79
+ const eq = body.indexOf("=");
80
+ if (eq >= 0) {
81
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
82
+ continue;
83
+ }
84
+ const next = argv[i + 1];
85
+ if (next === void 0 || next.startsWith("-")) {
86
+ flags[body] = true;
87
+ } else {
88
+ flags[body] = next;
89
+ i++;
90
+ }
91
+ continue;
92
+ }
93
+ positional.push(token);
94
+ }
95
+ return { positional, flags };
96
+ }
97
+ function flag(args, name) {
98
+ const v = args.flags[name];
99
+ return typeof v === "string" ? v : void 0;
100
+ }
101
+ function boolFlag(args, name) {
102
+ return args.flags[name] === true || args.flags[name] === "true";
103
+ }
104
+ function maybeJson(args, data, fallback) {
105
+ if (boolFlag(args, "json")) {
106
+ console.log(JSON.stringify(data, null, 2));
107
+ } else {
108
+ fallback();
109
+ }
110
+ }
111
+ function relTime(iso) {
112
+ const ms = Date.now() - new Date(iso).getTime();
113
+ if (isNaN(ms) || ms < 0) return iso;
114
+ if (ms < 6e4) return "just now";
115
+ const m = Math.floor(ms / 6e4);
116
+ if (m < 60) return `${m}m ago`;
117
+ const h = Math.floor(m / 60);
118
+ if (h < 24) return `${h}h ago`;
119
+ return `${Math.floor(h / 24)}d ago`;
120
+ }
121
+ function table(headers, rows) {
122
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
123
+ const lines = [headers.map((h, i) => h.padEnd(widths[i])).join(" ")];
124
+ for (const r of rows) lines.push(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" "));
125
+ return lines.join("\n");
126
+ }
127
+ function openWs(opts) {
128
+ const u = new URL(opts.url);
129
+ if (opts.token && !u.searchParams.has("token")) u.searchParams.set("token", opts.token);
130
+ const isSecure = u.protocol === "wss:";
131
+ if (isSecure) throw new Error("wss:// not supported by the built-in client yet \u2014 use ws:// (tailscale serve terminates TLS for browsers)");
132
+ const port = u.port ? Number(u.port) : 80;
133
+ const host = u.hostname;
134
+ const path = u.pathname + u.search;
135
+ const key = randomBytes(16).toString("base64");
136
+ const expectedAccept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
137
+ const socket = createConnection({ host, port });
138
+ let handshakeDone = false;
139
+ let recvBuf = Buffer.alloc(0);
140
+ socket.on("connect", () => {
141
+ const lines = [
142
+ `GET ${path} HTTP/1.1`,
143
+ `Host: ${u.host}`,
144
+ `Upgrade: websocket`,
145
+ `Connection: Upgrade`,
146
+ `Sec-WebSocket-Key: ${key}`,
147
+ `Sec-WebSocket-Version: 13`,
148
+ ``,
149
+ ``
150
+ ];
151
+ socket.write(lines.join("\r\n"));
152
+ });
153
+ function parseHandshake(buf) {
154
+ const headerEnd = buf.indexOf("\r\n\r\n");
155
+ if (headerEnd < 0) return { ok: false, offset: 0 };
156
+ const head = buf.slice(0, headerEnd).toString("utf8");
157
+ const lines = head.split("\r\n");
158
+ const statusLine = lines[0] ?? "";
159
+ const m = statusLine.match(/^HTTP\/1\.[01] (\d+) /);
160
+ if (!m || m[1] !== "101") {
161
+ return { ok: false, offset: 0, err: `expected 101 Switching Protocols, got: ${statusLine}` };
162
+ }
163
+ let acceptOk = false;
164
+ for (const ln of lines.slice(1)) {
165
+ const idx = ln.indexOf(":");
166
+ if (idx < 0) continue;
167
+ const k = ln.slice(0, idx).trim().toLowerCase();
168
+ const v = ln.slice(idx + 1).trim();
169
+ if (k === "sec-websocket-accept" && v === expectedAccept) acceptOk = true;
170
+ }
171
+ if (!acceptOk) return { ok: false, offset: 0, err: "Sec-WebSocket-Accept mismatch" };
172
+ return { ok: true, offset: headerEnd + 4 };
173
+ }
174
+ function parseFrame(buf) {
175
+ if (buf.length < 2) return { frame: null, rest: buf };
176
+ const b0 = buf[0];
177
+ const b1 = buf[1];
178
+ const opcode = b0 & 15;
179
+ const masked = (b1 & 128) !== 0;
180
+ let len = b1 & 127;
181
+ let offset = 2;
182
+ if (len === 126) {
183
+ if (buf.length < offset + 2) return { frame: null, rest: buf };
184
+ len = buf.readUInt16BE(offset);
185
+ offset += 2;
186
+ } else if (len === 127) {
187
+ if (buf.length < offset + 8) return { frame: null, rest: buf };
188
+ const big = buf.readBigUInt64BE(offset);
189
+ len = Number(big);
190
+ offset += 8;
191
+ }
192
+ let maskKey = null;
193
+ if (masked) {
194
+ if (buf.length < offset + 4) return { frame: null, rest: buf };
195
+ maskKey = buf.slice(offset, offset + 4);
196
+ offset += 4;
197
+ }
198
+ if (buf.length < offset + len) return { frame: null, rest: buf };
199
+ const payload = buf.slice(offset, offset + len);
200
+ if (maskKey) {
201
+ for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4];
202
+ }
203
+ return { frame: { opcode, payload }, rest: buf.slice(offset + len) };
204
+ }
205
+ socket.on("data", (chunk) => {
206
+ recvBuf = Buffer.concat([recvBuf, chunk]);
207
+ if (!handshakeDone) {
208
+ const r = parseHandshake(recvBuf);
209
+ if (r.err) {
210
+ opts.onError(new Error(r.err));
211
+ socket.destroy();
212
+ return;
213
+ }
214
+ if (!r.ok) return;
215
+ handshakeDone = true;
216
+ recvBuf = recvBuf.slice(r.offset);
217
+ }
218
+ while (recvBuf.length >= 2) {
219
+ const { frame, rest } = parseFrame(recvBuf);
220
+ if (!frame) break;
221
+ recvBuf = rest;
222
+ if (frame.opcode === 1) opts.onMessage(frame.payload.toString("utf8"));
223
+ else if (frame.opcode === 2) opts.onMessage(frame.payload);
224
+ else if (frame.opcode === 8) {
225
+ const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : 1e3;
226
+ const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString("utf8") : "";
227
+ opts.onClose(code, reason);
228
+ socket.end();
229
+ return;
230
+ } else if (frame.opcode === 9) {
231
+ sendFrame(10, frame.payload);
232
+ }
233
+ }
234
+ });
235
+ socket.on("error", (err) => opts.onError(err));
236
+ socket.on("close", () => opts.onClose(1006, "socket closed"));
237
+ function sendFrame(opcode, payload) {
238
+ const mask = randomBytes(4);
239
+ const len = payload.length;
240
+ let header;
241
+ if (len < 126) {
242
+ header = Buffer.alloc(2 + 4);
243
+ header[0] = 128 | opcode;
244
+ header[1] = 128 | len;
245
+ mask.copy(header, 2);
246
+ } else if (len < 65536) {
247
+ header = Buffer.alloc(2 + 2 + 4);
248
+ header[0] = 128 | opcode;
249
+ header[1] = 128 | 126;
250
+ header.writeUInt16BE(len, 2);
251
+ mask.copy(header, 4);
252
+ } else {
253
+ header = Buffer.alloc(2 + 8 + 4);
254
+ header[0] = 128 | opcode;
255
+ header[1] = 128 | 127;
256
+ header.writeBigUInt64BE(BigInt(len), 2);
257
+ mask.copy(header, 10);
258
+ }
259
+ const masked = Buffer.allocUnsafe(payload.length);
260
+ for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
261
+ const out = Buffer.allocUnsafe(header.length + masked.length);
262
+ header.copy(out, 0);
263
+ masked.copy(out, header.length);
264
+ socket.write(out);
265
+ }
266
+ return {
267
+ send(data) {
268
+ if (!handshakeDone) return;
269
+ const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
270
+ sendFrame(typeof data === "string" ? 1 : 2, buf);
271
+ },
272
+ close() {
273
+ try {
274
+ sendFrame(8, Buffer.alloc(0));
275
+ } catch {
276
+ }
277
+ socket.end();
278
+ }
279
+ };
280
+ }
281
+ function pushIf(o, k, v) {
282
+ if (v !== void 0 && v !== null && v !== "") o[k] = v;
283
+ return o;
284
+ }
285
+ var ls = {
286
+ summary: "List sessions on the daemon",
287
+ usage: "llmux ls [--json]",
288
+ help: help("ls", "List sessions on the daemon", "llmux ls [--json]"),
20
289
  run: async (argv) => {
21
- if (argv.length < 2) throw new Error('send requires <session> and "<prompt>"');
22
- notImplemented("send");
290
+ const args = parseArgs(argv);
291
+ const ctx = resolveContext();
292
+ const sessions = await request(ctx, "GET", "/api/sessions");
293
+ maybeJson(args, sessions, () => {
294
+ if (sessions.length === 0) {
295
+ console.log("no sessions");
296
+ return;
297
+ }
298
+ const rows = sessions.map((s) => [
299
+ s.name,
300
+ s.agent,
301
+ s.status,
302
+ relTime(s.createdAt),
303
+ s.cwdDisplay,
304
+ s.resumeFrom ? `\u21BB ${s.resumeFrom.slice(0, 8)}\u2026` : ""
305
+ ]);
306
+ console.log(table(["NAME", "AGENT", "STATE", "STARTED", "CWD", "RESUMED"], rows));
307
+ });
23
308
  }
24
309
  };
25
- var broadcast = {
26
- summary: "Send a prompt to ALL sessions of an agent type",
27
- usage: 'llmux broadcast <agent> "<prompt>"',
28
- help: help("broadcast", "Send a prompt to ALL sessions of an agent type", 'llmux broadcast <agent> "<prompt>"'),
310
+ var sendCmd = {
311
+ summary: "Send a prompt to a session (fire-and-forget)",
312
+ usage: 'llmux send <session> "<prompt>" [--no-enter]',
313
+ help: help("send", "Send a prompt to a session (fire-and-forget)", 'llmux send <session> "<prompt>" [--no-enter]'),
29
314
  run: async (argv) => {
30
- if (argv.length < 2) throw new Error('broadcast requires <agent> and "<prompt>"');
31
- notImplemented("broadcast");
315
+ const args = parseArgs(argv);
316
+ const ctx = resolveContext();
317
+ const session = args.positional[0];
318
+ const prompt = args.positional[1];
319
+ if (!session || !prompt) throw new Error('send requires <session> and "<prompt>"');
320
+ const enter = !boolFlag(args, "no-enter");
321
+ await request(ctx, "POST", `/api/sessions/${encodeURIComponent(session)}/send`, { prompt, enter });
322
+ if (!boolFlag(args, "json")) console.log(`sent to ${session}`);
323
+ else console.log(JSON.stringify({ ok: true }));
32
324
  }
33
325
  };
34
326
  var spawn = {
35
- summary: "Spawn one or more agent sessions (proxies to llmuxd spawn)",
36
- usage: "llmux spawn <agent|list|all> [--name <n>] [--prefix <p>] [--cwd <path>]",
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
- ),
327
+ summary: "Spawn a new session on the daemon",
328
+ usage: 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]',
329
+ help: help("spawn", "Spawn a new session on the daemon", 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]'),
42
330
  run: async (argv) => {
43
- if (argv.length < 1) throw new Error("spawn requires an agent (or `all`)");
44
- notImplemented("spawn");
331
+ const args = parseArgs(argv);
332
+ const ctx = resolveContext();
333
+ const agent = args.positional[0];
334
+ if (!agent) throw new Error("spawn requires an <agent>");
335
+ const env = flag(args, "env");
336
+ const body = { agent };
337
+ pushIf(body, "name", flag(args, "name"));
338
+ pushIf(body, "cwd", flag(args, "cwd"));
339
+ pushIf(body, "flags", flag(args, "flags"));
340
+ pushIf(body, "env", env);
341
+ const r = await request(ctx, "POST", "/api/sessions", body);
342
+ maybeJson(args, r.session, () => console.log(`spawned ${r.session.name} (agent: ${r.session.agent})`));
45
343
  }
46
344
  };
47
345
  var kill = {
48
- summary: "Terminate a session or all sessions",
49
- usage: "llmux kill <session|all>",
50
- help: help("kill", "Terminate a session or all sessions", "llmux kill <session|all>"),
346
+ summary: "Kill a session",
347
+ usage: "llmux kill <session>",
348
+ help: help("kill", "Kill a session", "llmux kill <session>"),
51
349
  run: async (argv) => {
52
- if (argv.length < 1) throw new Error("kill requires <session> or `all`");
53
- notImplemented("kill");
350
+ const args = parseArgs(argv);
351
+ const ctx = resolveContext();
352
+ const session = args.positional[0];
353
+ if (!session) throw new Error("kill requires <session>");
354
+ await request(ctx, "POST", `/api/sessions/${encodeURIComponent(session)}/kill`);
355
+ if (!boolFlag(args, "json")) console.log(`killed ${session}`);
356
+ else console.log(JSON.stringify({ ok: true }));
54
357
  }
55
358
  };
56
- var status = {
57
- summary: "List all running sessions",
58
- usage: "llmux status [--json]",
59
- help: help("status", "List all running sessions", "llmux status [--json]"),
60
- run: async () => {
61
- notImplemented("status");
359
+ var restart = {
360
+ summary: "Restart a session (kill + respawn with persisted config)",
361
+ usage: "llmux restart <session>",
362
+ help: help("restart", "Restart a session", "llmux restart <session>"),
363
+ run: async (argv) => {
364
+ const args = parseArgs(argv);
365
+ const ctx = resolveContext();
366
+ const session = args.positional[0];
367
+ if (!session) throw new Error("restart requires <session>");
368
+ const r = await request(ctx, "POST", `/api/sessions/${encodeURIComponent(session)}/respawn`);
369
+ maybeJson(args, r.session, () => console.log(`restarted ${r.session.name}`));
62
370
  }
63
371
  };
64
- var chat = {
65
- summary: "Open the browser web terminal for a session",
66
- usage: "llmux chat <session>",
67
- help: help("chat", "Open the browser web terminal for a session", "llmux chat <session>"),
372
+ var resume = {
373
+ summary: "Resume one of the agent's past conversations on this session",
374
+ usage: "llmux resume <session> (--conversation <id> | --latest) [--json]",
375
+ help: help("resume", "Resume a past conversation", "llmux resume <session> (--conversation <id> | --latest)"),
68
376
  run: async (argv) => {
69
- if (argv.length < 1) throw new Error("chat requires <session>");
70
- notImplemented("chat");
377
+ const args = parseArgs(argv);
378
+ const ctx = resolveContext();
379
+ const session = args.positional[0];
380
+ if (!session) throw new Error("resume requires <session>");
381
+ let conversationId = flag(args, "conversation");
382
+ if (!conversationId) {
383
+ if (!boolFlag(args, "latest")) {
384
+ throw new Error("resume requires --conversation <id> or --latest");
385
+ }
386
+ const convs = await request(ctx, "GET", `/api/sessions/${encodeURIComponent(session)}/conversations`);
387
+ if (convs.length === 0) throw new Error(`no past conversations for ${session}`);
388
+ conversationId = convs[0].id;
389
+ }
390
+ const r = await request(
391
+ ctx,
392
+ "POST",
393
+ `/api/sessions/${encodeURIComponent(session)}/resume`,
394
+ { conversationId }
395
+ );
396
+ maybeJson(args, r.session, () => console.log(`${r.session.name} resumed from ${conversationId.slice(0, 8)}\u2026`));
397
+ }
398
+ };
399
+ var conversations = {
400
+ summary: "List the agent's past conversations for this session's cwd",
401
+ usage: "llmux conversations <session> [--json]",
402
+ help: help("conversations", "List past conversations for this session's agent + cwd", "llmux conversations <session> [--json]"),
403
+ run: async (argv) => {
404
+ const args = parseArgs(argv);
405
+ const ctx = resolveContext();
406
+ const session = args.positional[0];
407
+ if (!session) throw new Error("conversations requires <session>");
408
+ const convs = await request(ctx, "GET", `/api/sessions/${encodeURIComponent(session)}/conversations`);
409
+ maybeJson(args, convs, () => {
410
+ if (convs.length === 0) {
411
+ console.log("no past conversations");
412
+ return;
413
+ }
414
+ const rows = convs.map((c) => [
415
+ c.id.slice(0, 8) + "\u2026",
416
+ relTime(c.lastMessageAt),
417
+ String(c.messageCount),
418
+ c.title.slice(0, 80)
419
+ ]);
420
+ console.log(table(["ID", "LAST", "MSGS", "TITLE"], rows));
421
+ });
71
422
  }
72
423
  };
424
+ var agents = {
425
+ summary: "List agents (installed by default; --all for the full catalog)",
426
+ usage: "llmux agents [--all] [--json]",
427
+ help: help("agents", "List agents", "llmux agents [--all] [--json]"),
428
+ run: async (argv) => {
429
+ const args = parseArgs(argv);
430
+ const ctx = resolveContext();
431
+ const path = boolFlag(args, "all") ? "/api/agents/all" : "/api/agents";
432
+ const list = await request(ctx, "GET", path);
433
+ maybeJson(args, list, () => {
434
+ const rows = list.map((a) => [
435
+ a.key,
436
+ a.displayName,
437
+ a.cmd,
438
+ a.flags || "-",
439
+ "installed" in a ? a.installed ? "yes" : "no" : "yes"
440
+ ]);
441
+ console.log(table(["KEY", "NAME", "CMD", "FLAGS", "INSTALLED"], rows));
442
+ });
443
+ }
444
+ };
445
+ var attach = {
446
+ summary: "Attach to a session via WebSocket (raw TTY pass-through)",
447
+ usage: "llmux attach <session>",
448
+ help: help(
449
+ "attach",
450
+ "Attach to a session via WebSocket",
451
+ "llmux attach <session>\n\nEscape: Ctrl+] to detach.\nResize is auto-detected via SIGWINCH.\n\nNote: only http:// is supported by the built-in WS client.\nFor https:// daemons, set LLMUX_SERVER to the local http:// URL,\nor use the browser web terminal."
452
+ ),
453
+ run: async (argv) => {
454
+ const args = parseArgs(argv);
455
+ const ctx = resolveContext();
456
+ const session = args.positional[0];
457
+ if (!session) throw new Error("attach requires <session>");
458
+ const baseUrl = new URL(ctx.baseUrl);
459
+ if (baseUrl.protocol === "https:") {
460
+ throw new Error("attach via wss:// is not supported by the built-in client; LLMUX_SERVER must be http://");
461
+ }
462
+ const wsUrl = (baseUrl.protocol === "https:" ? "wss://" : "ws://") + baseUrl.host + "/ws/" + encodeURIComponent(session);
463
+ const stdin = process.stdin;
464
+ const stdout = process.stdout;
465
+ if (stdin.isTTY) stdin.setRawMode(true);
466
+ stdin.resume();
467
+ stdin.setEncoding("utf8");
468
+ let ws = null;
469
+ let closed = false;
470
+ function teardown() {
471
+ if (closed) return;
472
+ closed = true;
473
+ try {
474
+ if (stdin.isTTY) stdin.setRawMode(false);
475
+ } catch {
476
+ }
477
+ stdin.removeAllListeners("data");
478
+ process.removeAllListeners("SIGWINCH");
479
+ ws?.close();
480
+ }
481
+ function sendResize() {
482
+ if (!stdout.isTTY) return;
483
+ const cols = stdout.columns ?? 80;
484
+ const rows = stdout.rows ?? 24;
485
+ ws?.send(JSON.stringify({ type: "resize", cols, rows }));
486
+ }
487
+ await new Promise((resolve2, reject) => {
488
+ ws = openWs({
489
+ url: wsUrl,
490
+ ...ctx.token !== void 0 ? { token: ctx.token } : {},
491
+ onMessage: (data) => {
492
+ if (typeof data === "string") stdout.write(data);
493
+ else stdout.write(data);
494
+ },
495
+ onClose: (code, reason) => {
496
+ teardown();
497
+ if (code === 4040) {
498
+ stdout.write(`\r
499
+ [session ended: ${reason || "pty exited"}]\r
500
+ `);
501
+ resolve2();
502
+ } else if (code === 1e3) {
503
+ resolve2();
504
+ } else {
505
+ reject(new Error(`ws closed: code=${code} reason=${reason}`));
506
+ }
507
+ },
508
+ onError: (err) => {
509
+ teardown();
510
+ reject(err);
511
+ }
512
+ });
513
+ stdin.on("data", (chunk) => {
514
+ if (chunk.includes("")) {
515
+ teardown();
516
+ stdout.write("\r\n[detached]\r\n");
517
+ resolve2();
518
+ return;
519
+ }
520
+ ws?.send(chunk);
521
+ });
522
+ setImmediate(sendResize);
523
+ process.on("SIGWINCH", sendResize);
524
+ });
525
+ }
526
+ };
527
+ var status = {
528
+ ...ls,
529
+ summary: "List sessions on the daemon (alias of `ls`)"
530
+ };
73
531
  var clientCommands = {
74
- send,
75
- broadcast,
532
+ ls,
533
+ status,
534
+ send: sendCmd,
76
535
  spawn,
77
536
  kill,
78
- status,
79
- chat
537
+ restart,
538
+ resume,
539
+ conversations,
540
+ agents,
541
+ attach
80
542
  };
81
543
 
82
544
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/llmux",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Thin HTTP client for llmuxd — send prompts to running AI agent CLI sessions over REST",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { createConnection } from 'node:net';
2
+ import { Buffer } from 'node:buffer';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+
1
5
  export interface ClientCommand {
2
6
  summary: string;
3
7
  usage: string;
@@ -5,13 +9,19 @@ export interface ClientCommand {
5
9
  run: (argv: readonly string[]) => Promise<void>;
6
10
  }
7
11
 
8
- function notImplemented(command: string): never {
9
- console.error(`llmux ${command}: not yet implemented (scaffold)`);
10
- process.exit(70);
11
- }
12
-
13
12
  function help(name: string, summary: string, usage: string): () => string {
14
- return () => [`llmux ${name} — ${summary}`, '', 'Usage:', ` ${usage}`, ''].join('\n');
13
+ return () =>
14
+ [
15
+ `llmux ${name} — ${summary}`,
16
+ '',
17
+ 'Usage:',
18
+ ` ${usage}`,
19
+ '',
20
+ 'Environment:',
21
+ ' LLMUX_SERVER base URL of the llmuxd daemon (e.g. http://localhost:3030)',
22
+ ' LLMUX_TOKEN auth token (sas_…); not required for localhost',
23
+ '',
24
+ ].join('\n');
15
25
  }
16
26
 
17
27
  interface ClientContext {
@@ -22,89 +32,643 @@ interface ClientContext {
22
32
  export function resolveContext(): ClientContext {
23
33
  const baseUrl = process.env.LLMUX_SERVER;
24
34
  if (!baseUrl) {
25
- throw new Error('LLMUX_SERVER is not set. Point it at your llmuxd (e.g. http://localhost:3000).');
35
+ throw new Error('LLMUX_SERVER is not set. Point it at your llmuxd (e.g. http://localhost:3030).');
36
+ }
37
+ return { baseUrl: baseUrl.replace(/\/$/, ''), token: process.env.LLMUX_TOKEN };
38
+ }
39
+
40
+ interface ServerError {
41
+ ok?: false;
42
+ error?: string;
43
+ }
44
+
45
+ async function request<T = unknown>(
46
+ ctx: ClientContext,
47
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
48
+ path: string,
49
+ body?: unknown,
50
+ ): Promise<T> {
51
+ const url = ctx.baseUrl + path;
52
+ const headers: Record<string, string> = { accept: 'application/json' };
53
+ if (body !== undefined) headers['content-type'] = 'application/json';
54
+ if (ctx.token) headers['authorization'] = `Bearer ${ctx.token}`;
55
+ const init: RequestInit = { method, headers };
56
+ if (body !== undefined) init.body = JSON.stringify(body);
57
+ let r: Response;
58
+ try {
59
+ r = await fetch(url, init);
60
+ } catch (err) {
61
+ throw new Error(`network error reaching ${url}: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ if (r.status === 401) {
64
+ throw new Error(
65
+ 'unauthorized — set LLMUX_TOKEN (use `llmuxd token create` on the daemon host to mint one)',
66
+ );
67
+ }
68
+ if (r.status === 404) {
69
+ throw new Error('not found — check the session name (try `llmux ls`)');
70
+ }
71
+ const text = await r.text();
72
+ let parsed: unknown = undefined;
73
+ if (text.length > 0) {
74
+ try {
75
+ parsed = JSON.parse(text);
76
+ } catch {
77
+ throw new Error(`unexpected non-JSON response (${r.status}): ${text.slice(0, 200)}`);
78
+ }
26
79
  }
27
- return { baseUrl, token: process.env.LLMUX_TOKEN };
80
+ if (!r.ok) {
81
+ const msg = (parsed as ServerError | undefined)?.error ?? `http ${r.status}`;
82
+ throw new Error(msg);
83
+ }
84
+ return parsed as T;
28
85
  }
29
86
 
30
- /** Helper for future use Phase 3 fills these in. */
87
+ // Re-exported for tests; thin wrapper around request() so the public surface is stable.
31
88
  export async function _request(
32
- _ctx: ClientContext,
33
- _method: 'GET' | 'POST' | 'DELETE',
34
- _path: string,
35
- _body?: unknown,
89
+ ctx: ClientContext,
90
+ method: 'GET' | 'POST' | 'DELETE',
91
+ path: string,
92
+ body?: unknown,
36
93
  ): Promise<unknown> {
37
- notImplemented('client._request');
94
+ return request(ctx, method, path, body);
38
95
  }
39
96
 
40
- const send: ClientCommand = {
41
- summary: 'Send a prompt to a session (fire-and-forget)',
42
- usage: 'llmux send <session> "<prompt>"',
43
- help: help('send', 'Send a prompt to a session (fire-and-forget)', 'llmux send <session> "<prompt>"'),
97
+ // ---------- argv parsing ----------
98
+
99
+ interface ParsedArgs {
100
+ positional: string[];
101
+ flags: Record<string, string | boolean>;
102
+ }
103
+
104
+ function parseArgs(argv: readonly string[]): ParsedArgs {
105
+ const positional: string[] = [];
106
+ const flags: Record<string, string | boolean> = {};
107
+ for (let i = 0; i < argv.length; i++) {
108
+ const token = argv[i]!;
109
+ if (token === '--') {
110
+ positional.push(...argv.slice(i + 1));
111
+ break;
112
+ }
113
+ if (token.startsWith('--')) {
114
+ const body = token.slice(2);
115
+ const eq = body.indexOf('=');
116
+ if (eq >= 0) {
117
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
118
+ continue;
119
+ }
120
+ const next = argv[i + 1];
121
+ if (next === undefined || next.startsWith('-')) {
122
+ flags[body] = true;
123
+ } else {
124
+ flags[body] = next;
125
+ i++;
126
+ }
127
+ continue;
128
+ }
129
+ positional.push(token);
130
+ }
131
+ return { positional, flags };
132
+ }
133
+
134
+ function flag(args: ParsedArgs, name: string): string | undefined {
135
+ const v = args.flags[name];
136
+ return typeof v === 'string' ? v : undefined;
137
+ }
138
+
139
+ function boolFlag(args: ParsedArgs, name: string): boolean {
140
+ return args.flags[name] === true || args.flags[name] === 'true';
141
+ }
142
+
143
+ // ---------- session-shape types (mirror server views) ----------
144
+
145
+ interface SessionView {
146
+ name: string;
147
+ agent: string;
148
+ cwd: string;
149
+ cwdDisplay: string;
150
+ flags?: string;
151
+ defaultFlags: string;
152
+ env?: Record<string, string>;
153
+ defaultEnv: Record<string, string>;
154
+ resumeFrom?: string;
155
+ hasHistory: boolean;
156
+ conversationCount: number;
157
+ createdAt: string;
158
+ parent: string | null;
159
+ status: 'running' | 'exited';
160
+ }
161
+
162
+ interface AgentView {
163
+ key: string;
164
+ displayName: string;
165
+ cmd: string;
166
+ flags: string;
167
+ envDefaults: Record<string, string>;
168
+ }
169
+
170
+ interface ConversationView {
171
+ id: string;
172
+ title: string;
173
+ startedAt: string;
174
+ lastMessageAt: string;
175
+ messageCount: number;
176
+ }
177
+
178
+ // ---------- printers ----------
179
+
180
+ function maybeJson<T>(args: ParsedArgs, data: T, fallback: () => void): void {
181
+ if (boolFlag(args, 'json')) {
182
+ console.log(JSON.stringify(data, null, 2));
183
+ } else {
184
+ fallback();
185
+ }
186
+ }
187
+
188
+ function relTime(iso: string): string {
189
+ const ms = Date.now() - new Date(iso).getTime();
190
+ if (isNaN(ms) || ms < 0) return iso;
191
+ if (ms < 60_000) return 'just now';
192
+ const m = Math.floor(ms / 60_000);
193
+ if (m < 60) return `${m}m ago`;
194
+ const h = Math.floor(m / 60);
195
+ if (h < 24) return `${h}h ago`;
196
+ return `${Math.floor(h / 24)}d ago`;
197
+ }
198
+
199
+ function table(headers: string[], rows: string[][]): string {
200
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
201
+ const lines = [headers.map((h, i) => h.padEnd(widths[i]!)).join(' ')];
202
+ for (const r of rows) lines.push(r.map((c, i) => (c ?? '').padEnd(widths[i]!)).join(' '));
203
+ return lines.join('\n');
204
+ }
205
+
206
+ // ---------- WebSocket client (hand-rolled, no deps) ----------
207
+
208
+ interface WsHandle {
209
+ send: (data: string | Buffer) => void;
210
+ close: () => void;
211
+ }
212
+
213
+ /**
214
+ * Minimal RFC 6455 client for the daemon's /ws/<name> endpoint. Returned
215
+ * handle gives raw send/close. We hand-roll because the llmux package keeps
216
+ * its dep tree empty (matches its current package.json — node-pty and ws are
217
+ * llmuxd-only).
218
+ */
219
+ function openWs(opts: {
220
+ url: string;
221
+ token?: string;
222
+ onMessage: (data: Buffer | string) => void;
223
+ onClose: (code: number, reason: string) => void;
224
+ onError: (err: Error) => void;
225
+ }): WsHandle {
226
+ const u = new URL(opts.url);
227
+ if (opts.token && !u.searchParams.has('token')) u.searchParams.set('token', opts.token);
228
+ const isSecure = u.protocol === 'wss:';
229
+ if (isSecure) throw new Error('wss:// not supported by the built-in client yet — use ws:// (tailscale serve terminates TLS for browsers)');
230
+ const port = u.port ? Number(u.port) : 80;
231
+ const host = u.hostname;
232
+ const path = u.pathname + u.search;
233
+ const key = randomBytes(16).toString('base64');
234
+ const expectedAccept = createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
235
+
236
+ const socket = createConnection({ host, port });
237
+ let handshakeDone = false;
238
+ let recvBuf: Buffer = Buffer.alloc(0);
239
+
240
+ socket.on('connect', () => {
241
+ const lines = [
242
+ `GET ${path} HTTP/1.1`,
243
+ `Host: ${u.host}`,
244
+ `Upgrade: websocket`,
245
+ `Connection: Upgrade`,
246
+ `Sec-WebSocket-Key: ${key}`,
247
+ `Sec-WebSocket-Version: 13`,
248
+ ``,
249
+ ``,
250
+ ];
251
+ socket.write(lines.join('\r\n'));
252
+ });
253
+
254
+ function parseHandshake(buf: Buffer): { ok: boolean; offset: number; err?: string } {
255
+ const headerEnd = buf.indexOf('\r\n\r\n');
256
+ if (headerEnd < 0) return { ok: false, offset: 0 };
257
+ const head = buf.slice(0, headerEnd).toString('utf8');
258
+ const lines = head.split('\r\n');
259
+ const statusLine = lines[0] ?? '';
260
+ const m = statusLine.match(/^HTTP\/1\.[01] (\d+) /);
261
+ if (!m || m[1] !== '101') {
262
+ return { ok: false, offset: 0, err: `expected 101 Switching Protocols, got: ${statusLine}` };
263
+ }
264
+ let acceptOk = false;
265
+ for (const ln of lines.slice(1)) {
266
+ const idx = ln.indexOf(':');
267
+ if (idx < 0) continue;
268
+ const k = ln.slice(0, idx).trim().toLowerCase();
269
+ const v = ln.slice(idx + 1).trim();
270
+ if (k === 'sec-websocket-accept' && v === expectedAccept) acceptOk = true;
271
+ }
272
+ if (!acceptOk) return { ok: false, offset: 0, err: 'Sec-WebSocket-Accept mismatch' };
273
+ return { ok: true, offset: headerEnd + 4 };
274
+ }
275
+
276
+ function parseFrame(buf: Buffer): { frame: { opcode: number; payload: Buffer } | null; rest: Buffer } {
277
+ if (buf.length < 2) return { frame: null, rest: buf };
278
+ const b0 = buf[0]!;
279
+ const b1 = buf[1]!;
280
+ const opcode = b0 & 0x0f;
281
+ const masked = (b1 & 0x80) !== 0;
282
+ let len = b1 & 0x7f;
283
+ let offset = 2;
284
+ if (len === 126) {
285
+ if (buf.length < offset + 2) return { frame: null, rest: buf };
286
+ len = buf.readUInt16BE(offset);
287
+ offset += 2;
288
+ } else if (len === 127) {
289
+ if (buf.length < offset + 8) return { frame: null, rest: buf };
290
+ const big = buf.readBigUInt64BE(offset);
291
+ len = Number(big);
292
+ offset += 8;
293
+ }
294
+ let maskKey: Buffer | null = null;
295
+ if (masked) {
296
+ if (buf.length < offset + 4) return { frame: null, rest: buf };
297
+ maskKey = buf.slice(offset, offset + 4);
298
+ offset += 4;
299
+ }
300
+ if (buf.length < offset + len) return { frame: null, rest: buf };
301
+ const payload = buf.slice(offset, offset + len);
302
+ if (maskKey) {
303
+ for (let i = 0; i < payload.length; i++) payload[i]! ^= maskKey[i % 4]!;
304
+ }
305
+ return { frame: { opcode, payload }, rest: buf.slice(offset + len) };
306
+ }
307
+
308
+ socket.on('data', (chunk: Buffer) => {
309
+ recvBuf = Buffer.concat([recvBuf, chunk]);
310
+ if (!handshakeDone) {
311
+ const r = parseHandshake(recvBuf);
312
+ if (r.err) {
313
+ opts.onError(new Error(r.err));
314
+ socket.destroy();
315
+ return;
316
+ }
317
+ if (!r.ok) return; // wait for more
318
+ handshakeDone = true;
319
+ recvBuf = recvBuf.slice(r.offset);
320
+ }
321
+ // frame parsing
322
+ while (recvBuf.length >= 2) {
323
+ const { frame, rest } = parseFrame(recvBuf);
324
+ if (!frame) break;
325
+ recvBuf = rest;
326
+ if (frame.opcode === 0x1) opts.onMessage(frame.payload.toString('utf8'));
327
+ else if (frame.opcode === 0x2) opts.onMessage(frame.payload);
328
+ else if (frame.opcode === 0x8) {
329
+ const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : 1000;
330
+ const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString('utf8') : '';
331
+ opts.onClose(code, reason);
332
+ socket.end();
333
+ return;
334
+ } else if (frame.opcode === 0x9) {
335
+ // ping — respond with pong
336
+ sendFrame(0xa, frame.payload);
337
+ }
338
+ }
339
+ });
340
+ socket.on('error', (err) => opts.onError(err));
341
+ socket.on('close', () => opts.onClose(1006, 'socket closed'));
342
+
343
+ function sendFrame(opcode: number, payload: Buffer): void {
344
+ const mask = randomBytes(4);
345
+ const len = payload.length;
346
+ let header: Buffer;
347
+ if (len < 126) {
348
+ header = Buffer.alloc(2 + 4);
349
+ header[0] = 0x80 | opcode;
350
+ header[1] = 0x80 | len;
351
+ mask.copy(header, 2);
352
+ } else if (len < 65536) {
353
+ header = Buffer.alloc(2 + 2 + 4);
354
+ header[0] = 0x80 | opcode;
355
+ header[1] = 0x80 | 126;
356
+ header.writeUInt16BE(len, 2);
357
+ mask.copy(header, 4);
358
+ } else {
359
+ header = Buffer.alloc(2 + 8 + 4);
360
+ header[0] = 0x80 | opcode;
361
+ header[1] = 0x80 | 127;
362
+ header.writeBigUInt64BE(BigInt(len), 2);
363
+ mask.copy(header, 10);
364
+ }
365
+ const masked = Buffer.allocUnsafe(payload.length);
366
+ for (let i = 0; i < payload.length; i++) masked[i] = payload[i]! ^ mask[i % 4]!;
367
+ const out = Buffer.allocUnsafe(header.length + masked.length);
368
+ header.copy(out, 0);
369
+ masked.copy(out, header.length);
370
+ socket.write(out);
371
+ }
372
+
373
+ return {
374
+ send(data) {
375
+ if (!handshakeDone) return;
376
+ const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
377
+ sendFrame(typeof data === 'string' ? 0x1 : 0x2, buf);
378
+ },
379
+ close() {
380
+ try {
381
+ sendFrame(0x8, Buffer.alloc(0));
382
+ } catch {}
383
+ socket.end();
384
+ },
385
+ };
386
+ }
387
+
388
+ // ---------- commands ----------
389
+
390
+ function pushIf<T extends Record<string, unknown>>(o: T, k: string, v: unknown): T {
391
+ if (v !== undefined && v !== null && v !== '') (o as Record<string, unknown>)[k] = v;
392
+ return o;
393
+ }
394
+
395
+ const ls: ClientCommand = {
396
+ summary: 'List sessions on the daemon',
397
+ usage: 'llmux ls [--json]',
398
+ help: help('ls', 'List sessions on the daemon', 'llmux ls [--json]'),
44
399
  run: async (argv) => {
45
- if (argv.length < 2) throw new Error('send requires <session> and "<prompt>"');
46
- notImplemented('send');
400
+ const args = parseArgs(argv);
401
+ const ctx = resolveContext();
402
+ const sessions = await request<SessionView[]>(ctx, 'GET', '/api/sessions');
403
+ maybeJson(args, sessions, () => {
404
+ if (sessions.length === 0) {
405
+ console.log('no sessions');
406
+ return;
407
+ }
408
+ const rows = sessions.map((s) => [
409
+ s.name,
410
+ s.agent,
411
+ s.status,
412
+ relTime(s.createdAt),
413
+ s.cwdDisplay,
414
+ s.resumeFrom ? `↻ ${s.resumeFrom.slice(0, 8)}…` : '',
415
+ ]);
416
+ console.log(table(['NAME', 'AGENT', 'STATE', 'STARTED', 'CWD', 'RESUMED'], rows));
417
+ });
47
418
  },
48
419
  };
49
420
 
50
- const broadcast: ClientCommand = {
51
- summary: 'Send a prompt to ALL sessions of an agent type',
52
- usage: 'llmux broadcast <agent> "<prompt>"',
53
- help: help('broadcast', 'Send a prompt to ALL sessions of an agent type', 'llmux broadcast <agent> "<prompt>"'),
421
+ const sendCmd: ClientCommand = {
422
+ summary: 'Send a prompt to a session (fire-and-forget)',
423
+ usage: 'llmux send <session> "<prompt>" [--no-enter]',
424
+ help: help('send', 'Send a prompt to a session (fire-and-forget)', 'llmux send <session> "<prompt>" [--no-enter]'),
54
425
  run: async (argv) => {
55
- if (argv.length < 2) throw new Error('broadcast requires <agent> and "<prompt>"');
56
- notImplemented('broadcast');
426
+ const args = parseArgs(argv);
427
+ const ctx = resolveContext();
428
+ const session = args.positional[0];
429
+ const prompt = args.positional[1];
430
+ if (!session || !prompt) throw new Error('send requires <session> and "<prompt>"');
431
+ const enter = !boolFlag(args, 'no-enter');
432
+ await request(ctx, 'POST', `/api/sessions/${encodeURIComponent(session)}/send`, { prompt, enter });
433
+ if (!boolFlag(args, 'json')) console.log(`sent to ${session}`);
434
+ else console.log(JSON.stringify({ ok: true }));
57
435
  },
58
436
  };
59
437
 
60
438
  const spawn: ClientCommand = {
61
- summary: 'Spawn one or more agent sessions (proxies to llmuxd spawn)',
62
- usage: 'llmux spawn <agent|list|all> [--name <n>] [--prefix <p>] [--cwd <path>]',
63
- help: help(
64
- 'spawn',
65
- 'Spawn one or more agent sessions',
66
- 'llmux spawn <agent|list|all> [--name <n>] [--prefix <p>] [--cwd <path>]',
67
- ),
439
+ summary: 'Spawn a new session on the daemon',
440
+ usage: 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]',
441
+ help: help('spawn', 'Spawn a new session on the daemon', 'llmux spawn <agent> [--name <n>] [--cwd <path>] [--flags "<f>"] [--env "K=V" ...]'),
68
442
  run: async (argv) => {
69
- if (argv.length < 1) throw new Error('spawn requires an agent (or `all`)');
70
- notImplemented('spawn');
443
+ const args = parseArgs(argv);
444
+ const ctx = resolveContext();
445
+ const agent = args.positional[0];
446
+ if (!agent) throw new Error('spawn requires an <agent>');
447
+ // --env may be provided multiple times (concatenated by repeated flag).
448
+ // Simple parser supports single --env value; for multiple, pass as a
449
+ // newline-separated value: --env "K1=V1\nK2=V2"
450
+ const env = flag(args, 'env');
451
+ const body: Record<string, unknown> = { agent };
452
+ pushIf(body, 'name', flag(args, 'name'));
453
+ pushIf(body, 'cwd', flag(args, 'cwd'));
454
+ pushIf(body, 'flags', flag(args, 'flags'));
455
+ pushIf(body, 'env', env);
456
+ const r = await request<{ ok: boolean; session: SessionView; error?: string }>(ctx, 'POST', '/api/sessions', body);
457
+ maybeJson(args, r.session, () => console.log(`spawned ${r.session.name} (agent: ${r.session.agent})`));
71
458
  },
72
459
  };
73
460
 
74
461
  const kill: ClientCommand = {
75
- summary: 'Terminate a session or all sessions',
76
- usage: 'llmux kill <session|all>',
77
- help: help('kill', 'Terminate a session or all sessions', 'llmux kill <session|all>'),
462
+ summary: 'Kill a session',
463
+ usage: 'llmux kill <session>',
464
+ help: help('kill', 'Kill a session', 'llmux kill <session>'),
78
465
  run: async (argv) => {
79
- if (argv.length < 1) throw new Error('kill requires <session> or `all`');
80
- notImplemented('kill');
466
+ const args = parseArgs(argv);
467
+ const ctx = resolveContext();
468
+ const session = args.positional[0];
469
+ if (!session) throw new Error('kill requires <session>');
470
+ await request(ctx, 'POST', `/api/sessions/${encodeURIComponent(session)}/kill`);
471
+ if (!boolFlag(args, 'json')) console.log(`killed ${session}`);
472
+ else console.log(JSON.stringify({ ok: true }));
81
473
  },
82
474
  };
83
475
 
84
- const status: ClientCommand = {
85
- summary: 'List all running sessions',
86
- usage: 'llmux status [--json]',
87
- help: help('status', 'List all running sessions', 'llmux status [--json]'),
88
- run: async () => {
89
- notImplemented('status');
476
+ const restart: ClientCommand = {
477
+ summary: 'Restart a session (kill + respawn with persisted config)',
478
+ usage: 'llmux restart <session>',
479
+ help: help('restart', 'Restart a session', 'llmux restart <session>'),
480
+ run: async (argv) => {
481
+ const args = parseArgs(argv);
482
+ const ctx = resolveContext();
483
+ const session = args.positional[0];
484
+ if (!session) throw new Error('restart requires <session>');
485
+ const r = await request<{ ok: boolean; session: SessionView }>(ctx, 'POST', `/api/sessions/${encodeURIComponent(session)}/respawn`);
486
+ maybeJson(args, r.session, () => console.log(`restarted ${r.session.name}`));
90
487
  },
91
488
  };
92
489
 
93
- const chat: ClientCommand = {
94
- summary: 'Open the browser web terminal for a session',
95
- usage: 'llmux chat <session>',
96
- help: help('chat', 'Open the browser web terminal for a session', 'llmux chat <session>'),
490
+ const resume: ClientCommand = {
491
+ summary: "Resume one of the agent's past conversations on this session",
492
+ usage: 'llmux resume <session> (--conversation <id> | --latest) [--json]',
493
+ help: help('resume', 'Resume a past conversation', 'llmux resume <session> (--conversation <id> | --latest)'),
97
494
  run: async (argv) => {
98
- if (argv.length < 1) throw new Error('chat requires <session>');
99
- notImplemented('chat');
495
+ const args = parseArgs(argv);
496
+ const ctx = resolveContext();
497
+ const session = args.positional[0];
498
+ if (!session) throw new Error('resume requires <session>');
499
+ let conversationId = flag(args, 'conversation');
500
+ if (!conversationId) {
501
+ if (!boolFlag(args, 'latest')) {
502
+ throw new Error('resume requires --conversation <id> or --latest');
503
+ }
504
+ const convs = await request<ConversationView[]>(ctx, 'GET', `/api/sessions/${encodeURIComponent(session)}/conversations`);
505
+ if (convs.length === 0) throw new Error(`no past conversations for ${session}`);
506
+ conversationId = convs[0]!.id;
507
+ }
508
+ const r = await request<{ ok: boolean; session: SessionView }>(
509
+ ctx,
510
+ 'POST',
511
+ `/api/sessions/${encodeURIComponent(session)}/resume`,
512
+ { conversationId },
513
+ );
514
+ maybeJson(args, r.session, () => console.log(`${r.session.name} resumed from ${conversationId!.slice(0, 8)}…`));
100
515
  },
101
516
  };
102
517
 
518
+ const conversations: ClientCommand = {
519
+ summary: "List the agent's past conversations for this session's cwd",
520
+ usage: 'llmux conversations <session> [--json]',
521
+ help: help('conversations', "List past conversations for this session's agent + cwd", 'llmux conversations <session> [--json]'),
522
+ run: async (argv) => {
523
+ const args = parseArgs(argv);
524
+ const ctx = resolveContext();
525
+ const session = args.positional[0];
526
+ if (!session) throw new Error('conversations requires <session>');
527
+ const convs = await request<ConversationView[]>(ctx, 'GET', `/api/sessions/${encodeURIComponent(session)}/conversations`);
528
+ maybeJson(args, convs, () => {
529
+ if (convs.length === 0) {
530
+ console.log('no past conversations');
531
+ return;
532
+ }
533
+ const rows = convs.map((c) => [
534
+ c.id.slice(0, 8) + '…',
535
+ relTime(c.lastMessageAt),
536
+ String(c.messageCount),
537
+ c.title.slice(0, 80),
538
+ ]);
539
+ console.log(table(['ID', 'LAST', 'MSGS', 'TITLE'], rows));
540
+ });
541
+ },
542
+ };
543
+
544
+ const agents: ClientCommand = {
545
+ summary: 'List agents (installed by default; --all for the full catalog)',
546
+ usage: 'llmux agents [--all] [--json]',
547
+ help: help('agents', 'List agents', 'llmux agents [--all] [--json]'),
548
+ run: async (argv) => {
549
+ const args = parseArgs(argv);
550
+ const ctx = resolveContext();
551
+ const path = boolFlag(args, 'all') ? '/api/agents/all' : '/api/agents';
552
+ const list = await request<Array<AgentView & { installed?: boolean; installHint?: string; docsUrl?: string }>>(ctx, 'GET', path);
553
+ maybeJson(args, list, () => {
554
+ const rows = list.map((a) => [
555
+ a.key,
556
+ a.displayName,
557
+ a.cmd,
558
+ a.flags || '-',
559
+ 'installed' in a ? (a.installed ? 'yes' : 'no') : 'yes',
560
+ ]);
561
+ console.log(table(['KEY', 'NAME', 'CMD', 'FLAGS', 'INSTALLED'], rows));
562
+ });
563
+ },
564
+ };
565
+
566
+ const attach: ClientCommand = {
567
+ summary: 'Attach to a session via WebSocket (raw TTY pass-through)',
568
+ usage: 'llmux attach <session>',
569
+ help: help(
570
+ 'attach',
571
+ 'Attach to a session via WebSocket',
572
+ '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.',
573
+ ),
574
+ run: async (argv) => {
575
+ const args = parseArgs(argv);
576
+ const ctx = resolveContext();
577
+ const session = args.positional[0];
578
+ if (!session) throw new Error('attach requires <session>');
579
+ const baseUrl = new URL(ctx.baseUrl);
580
+ if (baseUrl.protocol === 'https:') {
581
+ throw new Error('attach via wss:// is not supported by the built-in client; LLMUX_SERVER must be http://');
582
+ }
583
+ const wsUrl =
584
+ (baseUrl.protocol === 'https:' ? 'wss://' : 'ws://') +
585
+ baseUrl.host +
586
+ '/ws/' +
587
+ encodeURIComponent(session);
588
+
589
+ // Raw TTY input mode
590
+ const stdin = process.stdin;
591
+ const stdout = process.stdout;
592
+ if (stdin.isTTY) stdin.setRawMode(true);
593
+ stdin.resume();
594
+ stdin.setEncoding('utf8');
595
+
596
+ let ws: WsHandle | null = null;
597
+ let closed = false;
598
+
599
+ function teardown(): void {
600
+ if (closed) return;
601
+ closed = true;
602
+ try { if (stdin.isTTY) stdin.setRawMode(false); } catch {}
603
+ stdin.removeAllListeners('data');
604
+ process.removeAllListeners('SIGWINCH');
605
+ ws?.close();
606
+ }
607
+
608
+ function sendResize(): void {
609
+ if (!stdout.isTTY) return;
610
+ const cols = stdout.columns ?? 80;
611
+ const rows = stdout.rows ?? 24;
612
+ ws?.send(JSON.stringify({ type: 'resize', cols, rows }));
613
+ }
614
+
615
+ await new Promise<void>((resolve, reject) => {
616
+ ws = openWs({
617
+ url: wsUrl,
618
+ ...(ctx.token !== undefined ? { token: ctx.token } : {}),
619
+ onMessage: (data) => {
620
+ if (typeof data === 'string') stdout.write(data);
621
+ else stdout.write(data);
622
+ },
623
+ onClose: (code, reason) => {
624
+ teardown();
625
+ if (code === 4040) {
626
+ stdout.write(`\r\n[session ended: ${reason || 'pty exited'}]\r\n`);
627
+ resolve();
628
+ } else if (code === 1000) {
629
+ resolve();
630
+ } else {
631
+ reject(new Error(`ws closed: code=${code} reason=${reason}`));
632
+ }
633
+ },
634
+ onError: (err) => {
635
+ teardown();
636
+ reject(err);
637
+ },
638
+ });
639
+
640
+ // Pipe stdin → ws, with Ctrl+] (\x1d) as detach.
641
+ stdin.on('data', (chunk: string) => {
642
+ if (chunk.includes('\x1d')) {
643
+ teardown();
644
+ stdout.write('\r\n[detached]\r\n');
645
+ resolve();
646
+ return;
647
+ }
648
+ ws?.send(chunk);
649
+ });
650
+
651
+ // Initial size + react to terminal resize
652
+ setImmediate(sendResize);
653
+ process.on('SIGWINCH', sendResize);
654
+ });
655
+ },
656
+ };
657
+
658
+ const status: ClientCommand = {
659
+ ...ls,
660
+ summary: 'List sessions on the daemon (alias of `ls`)',
661
+ };
662
+
103
663
  export const clientCommands: Record<string, ClientCommand> = {
104
- send,
105
- broadcast,
664
+ ls,
665
+ status,
666
+ send: sendCmd,
106
667
  spawn,
107
668
  kill,
108
- status,
109
- chat,
669
+ restart,
670
+ resume,
671
+ conversations,
672
+ agents,
673
+ attach,
110
674
  };