@cordfuse/llmux 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -2,7 +2,16 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, resolve } from 'node:path';
5
- import { clientCommands, type ClientCommand } from './client.ts';
5
+
6
+ import { parseArgs, type ParsedArgs } from './cli.ts';
7
+ import * as h from './daemon/handlers.ts';
8
+ import * as state from './daemon/state.ts';
9
+ import * as tmux from './daemon/tmux.ts';
10
+ import * as authStore from './daemon/auth-store.ts';
11
+ import { DEFAULT_AGENTS, isAgentInstalled } from './daemon/agents.ts';
12
+ import { clientCommands } from './client/client.ts';
13
+
14
+ // ----------------- version helper -----------------
6
15
 
7
16
  function readVersion(): string {
8
17
  try {
@@ -18,61 +27,401 @@ function readVersion(): string {
18
27
  }
19
28
  const VERSION = readVersion();
20
29
 
30
+ // ----------------- argv shape -----------------
31
+
32
+ interface GlobalEnv {
33
+ /** When set, session/agent verbs go over HTTP to this daemon. */
34
+ server?: string;
35
+ /** SAS token for remote auth. */
36
+ token?: string;
37
+ }
38
+
39
+ /**
40
+ * Strip global flags (`--server`, `--token`, `--help`, `--version`) and their
41
+ * values from the head of argv. Returns the remaining tokens plus the
42
+ * extracted env, so the per-noun routers see clean positional args.
43
+ *
44
+ * Global flag values can appear anywhere — operators tend to put them at the
45
+ * end (`llmux session list --server http://…`) or the beginning. We sweep
46
+ * both passes.
47
+ */
48
+ function stripGlobals(argv: readonly string[]): { rest: string[]; env: GlobalEnv; help: boolean; version: boolean } {
49
+ const env: GlobalEnv = {};
50
+ const rest: string[] = [];
51
+ let help = false;
52
+ let version = false;
53
+ if (process.env.LLMUX_SERVER) env.server = process.env.LLMUX_SERVER;
54
+ if (process.env.LLMUX_TOKEN) env.token = process.env.LLMUX_TOKEN;
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const t = argv[i]!;
57
+ if (t === '--server') {
58
+ const next = argv[i + 1];
59
+ if (next === undefined || next.startsWith('-')) throw new Error('--server requires a URL');
60
+ env.server = next;
61
+ i++;
62
+ continue;
63
+ }
64
+ if (t.startsWith('--server=')) {
65
+ env.server = t.slice('--server='.length);
66
+ continue;
67
+ }
68
+ if (t === '--token') {
69
+ const next = argv[i + 1];
70
+ if (next === undefined || next.startsWith('-')) throw new Error('--token requires a value');
71
+ env.token = next;
72
+ i++;
73
+ continue;
74
+ }
75
+ if (t.startsWith('--token=')) {
76
+ env.token = t.slice('--token='.length);
77
+ continue;
78
+ }
79
+ if (t === '--help' || t === '-h') {
80
+ help = true;
81
+ continue;
82
+ }
83
+ if (t === '--version' || t === '-v') {
84
+ version = true;
85
+ continue;
86
+ }
87
+ rest.push(t);
88
+ }
89
+ return { rest, env, help, version };
90
+ }
91
+
92
+ // Map our positional argv → the legacy ParsedArgs shape so we can re-use the
93
+ // existing daemon handlers without rewriting them.
94
+ function asParsedArgs(positional: string[], flags: Record<string, string | boolean> = {}): ParsedArgs {
95
+ return { positional, flags };
96
+ }
97
+
98
+ // ----------------- help -----------------
99
+
21
100
  function printRootHelp(): void {
22
- const lines: string[] = [];
23
- lines.push('llmux — HTTP client for llmuxd');
24
- lines.push('');
25
- lines.push(`Version: ${VERSION}`);
26
- lines.push('');
27
- lines.push('Usage:');
28
- lines.push(' llmux <command> [options]');
29
- lines.push('');
30
- lines.push('Commands:');
31
- const width = Math.max(...Object.keys(clientCommands).map((k) => k.length));
32
- for (const [name, cmd] of Object.entries(clientCommands)) {
33
- lines.push(` ${name.padEnd(width + 2)}${cmd.summary}`);
101
+ console.log(
102
+ `llmux v${VERSION} tmux-based AI agent dispatcher (daemon + client in one binary)
103
+
104
+ Usage:
105
+ llmux <noun> <verb> [args] [--server <url>] [--token <sas>]
106
+
107
+ Session verbs (local by default; pass --server <url> to target a remote daemon):
108
+ session list list tracked sessions
109
+ session start <agent> [--name N] [--cwd P] spawn a new agent in tmux
110
+ [--flags "F"] [--env "K=V"] [--resume-from <id>]
111
+ session stop <name> kill + forget the session
112
+ session restart <name> kill + relaunch with persisted config
113
+ session attach <name> open the terminal (tmux locally, WS remotely)
114
+ session prompt <name> "<text>" [--no-enter] send a prompt
115
+ session broadcast <agent> "<text>" send to every session of an agent type (local)
116
+ session resume <name> --conversation <id> | --latest
117
+ rebind to a past agent conversation
118
+ session history <name> list past conversations for the session's cwd
119
+
120
+ Server verbs (always local):
121
+ server start [--port N] [--no-qr] run the HTTP/WS daemon (formerly: llmuxd serve)
122
+
123
+ Token verbs (always local — managing the daemon-host's auth store):
124
+ token create [--name N] [--expiry ISO] [--qr] [--qr-endpoint <label>]
125
+ token list show active tokens
126
+ token revoke <id> revoke a token by id
127
+
128
+ Agent verbs:
129
+ agent list [--all] [--installed] [--json] list agents (default: installed-only)
130
+
131
+ Global flags:
132
+ --server <url> route session/agent verbs to a remote daemon over HTTP
133
+ --token <sas> SAS token for remote auth (LLMUX_TOKEN env fallback)
134
+ --help / -h print this help
135
+ --version / -v print version
136
+
137
+ Environment:
138
+ LLMUX_SERVER default --server URL
139
+ LLMUX_TOKEN default --token value`,
140
+ );
141
+ }
142
+
143
+ function printVerbHelp(noun: string, verb: string | undefined): void {
144
+ // Light help; full help is in the root.
145
+ if (!verb) {
146
+ console.log(`llmux ${noun} — see \`llmux --help\` for verbs under this noun`);
147
+ return;
34
148
  }
35
- lines.push('');
36
- lines.push('Environment:');
37
- lines.push(' LLMUX_SERVER Base URL of llmuxd (e.g. http://host:3000)');
38
- lines.push(' LLMUX_TOKEN SAS token for authenticated requests');
39
- lines.push('');
40
- lines.push('Run `llmux <command> --help` for command-specific options.');
41
- console.log(lines.join('\n'));
149
+ console.log(`llmux ${noun} ${verb} — see \`llmux --help\` for usage`);
42
150
  }
43
151
 
44
- async function main(): Promise<void> {
45
- const argv = process.argv.slice(2);
152
+ // ----------------- dispatchers -----------------
46
153
 
47
- if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
48
- printRootHelp();
154
+ async function dispatchSession(verb: string | undefined, args: string[], env: GlobalEnv): Promise<void> {
155
+ if (!verb) {
156
+ printVerbHelp('session', verb);
49
157
  return;
50
158
  }
51
- if (argv[0] === '--version' || argv[0] === '-v') {
52
- console.log(VERSION);
159
+ // Backward-compat aliases
160
+ const v = verb === 'ls' ? 'list' : verb === 'send' ? 'prompt' : verb === 'spawn' ? 'start' : verb === 'kill' ? 'stop' : verb === 'respawn' ? 'restart' : verb === 'conversations' ? 'history' : verb;
161
+
162
+ if (env.server !== undefined) {
163
+ // Remote — delegate to the existing client command map.
164
+ const cmdMap: Record<string, string> = {
165
+ list: 'ls',
166
+ start: 'spawn',
167
+ stop: 'kill',
168
+ restart: 'restart',
169
+ attach: 'attach',
170
+ prompt: 'send',
171
+ resume: 'resume',
172
+ history: 'conversations',
173
+ };
174
+ const clientCmd = cmdMap[v];
175
+ if (!clientCmd) throw new Error(`session ${v}: no remote equivalent`);
176
+ process.env.LLMUX_SERVER = env.server;
177
+ if (env.token) process.env.LLMUX_TOKEN = env.token;
178
+ const cmd = clientCommands[clientCmd];
179
+ if (!cmd) throw new Error(`internal: client command "${clientCmd}" missing`);
180
+ await cmd.run(args);
53
181
  return;
54
182
  }
55
183
 
56
- const name = argv[0]!;
57
- const rest = argv.slice(1);
58
- const command: ClientCommand | undefined = clientCommands[name];
184
+ // Local call daemon handlers directly.
185
+ const parsed = parseArgs(args, sessionLocalFlags());
186
+ switch (v) {
187
+ case 'list':
188
+ h.handleStatus(parsed);
189
+ return;
190
+ case 'start':
191
+ h.handleSpawn(parsed);
192
+ return;
193
+ case 'stop':
194
+ h.handleKill(parsed);
195
+ return;
196
+ case 'restart':
197
+ h.handleRespawn(parsed);
198
+ return;
199
+ case 'attach':
200
+ h.handleChat(parsed);
201
+ return;
202
+ case 'prompt':
203
+ h.handleSend(parsed);
204
+ return;
205
+ case 'broadcast':
206
+ h.handleBroadcast(parsed);
207
+ return;
208
+ case 'resume': {
209
+ const name = parsed.positional[0];
210
+ if (!name) throw new Error('session resume requires <name>');
211
+ const session = state.get(name);
212
+ if (!session) throw new Error(`no tracked session "${name}"`);
213
+ const agent = DEFAULT_AGENTS[session.agent];
214
+ if (!agent?.history) throw new Error(`agent "${session.agent}" has no history adapter`);
215
+ if (!isAgentInstalled(agent)) throw new Error(`agent "${session.agent}" is not installed`);
216
+ let conversationId = parsed.flags.conversation as string | undefined;
217
+ if (!conversationId) {
218
+ if (!parsed.flags.latest) throw new Error('resume requires --conversation <id> or --latest');
219
+ const convs = agent.history.listConversations(session.cwd);
220
+ if (convs.length === 0) throw new Error(`no past conversations for ${name}`);
221
+ conversationId = convs[0]!.id;
222
+ }
223
+ if (tmux.hasSession(name)) tmux.killSession(name);
224
+ const cmd = `${agent.cmd} ${agent.flags ?? ''} ${agent.history.resumeFlag(conversationId)}`.trim();
225
+ tmux.newSession({
226
+ name,
227
+ command: cmd,
228
+ cwd: session.cwd,
229
+ env: { ...(agent.envDefaults ?? {}), ...(session.env ?? {}), LLMUX_SESSION: name, LLMUX_AGENT: session.agent },
230
+ });
231
+ state.record({ ...session, resumeFrom: conversationId, createdAt: new Date().toISOString() });
232
+ console.log(`${name} resumed from ${conversationId.slice(0, 8)}…`);
233
+ return;
234
+ }
235
+ case 'history': {
236
+ const name = parsed.positional[0];
237
+ if (!name) throw new Error('session history requires <name>');
238
+ const session = state.get(name);
239
+ if (!session) throw new Error(`no tracked session "${name}"`);
240
+ const agent = DEFAULT_AGENTS[session.agent];
241
+ if (!agent?.history) {
242
+ console.log('agent has no history adapter');
243
+ return;
244
+ }
245
+ const convs = agent.history.listConversations(session.cwd);
246
+ if (convs.length === 0) {
247
+ console.log('no past conversations');
248
+ return;
249
+ }
250
+ for (const c of convs) console.log(`${c.id.slice(0, 8)}… ${c.messageCount.toString().padStart(5)} ${c.title.slice(0, 80)}`);
251
+ return;
252
+ }
253
+ default:
254
+ throw new Error(`unknown session verb "${v}"`);
255
+ }
256
+ }
59
257
 
60
- if (!command) {
61
- console.error(`llmux: unknown command "${name}"`);
62
- console.error('Run `llmux --help` to see available commands.');
63
- process.exit(64);
258
+ function sessionLocalFlags() {
259
+ return {
260
+ name: { kind: 'string' as const, description: 'session name' },
261
+ cwd: { kind: 'string' as const, description: 'working directory' },
262
+ flags: { kind: 'string' as const, description: 'launch flags override' },
263
+ env: { kind: 'string' as const, description: 'env vars (KEY=VAL one per line)' },
264
+ prefix: { kind: 'string' as const, description: 'session-name prefix (start only)' },
265
+ cascade: { kind: 'boolean' as const, description: 'cascade kill to children' },
266
+ conversation: { kind: 'string' as const, description: 'conversation id (resume)' },
267
+ latest: { kind: 'boolean' as const, description: 'resume the most recent conversation' },
268
+ 'no-enter': { kind: 'boolean' as const, description: 'do not append Enter to prompt' },
269
+ browser: { kind: 'boolean' as const, description: 'open in web browser (attach)' },
270
+ it: { kind: 'boolean' as const, description: 'interactive (attach)' },
271
+ json: { kind: 'boolean' as const, description: 'emit JSON' },
272
+ };
273
+ }
274
+
275
+ async function dispatchServer(verb: string | undefined, args: string[]): Promise<void> {
276
+ if (!verb) {
277
+ printVerbHelp('server', verb);
278
+ return;
279
+ }
280
+ const parsed = parseArgs(args, {
281
+ config: { kind: 'string', description: 'Path to .llmux.yaml' },
282
+ port: { kind: 'string', description: 'Listen port' },
283
+ 'no-qr': { kind: 'boolean', description: 'Suppress QR codes' },
284
+ });
285
+ switch (verb) {
286
+ case 'start':
287
+ case 'serve':
288
+ await h.handleServe(parsed);
289
+ return;
290
+ default:
291
+ throw new Error(`unknown server verb "${verb}"`);
64
292
  }
293
+ }
65
294
 
66
- if (rest.includes('--help') || rest.includes('-h')) {
67
- console.log(command.help());
295
+ async function dispatchToken(verb: string | undefined, args: string[]): Promise<void> {
296
+ if (!verb) {
297
+ printVerbHelp('token', verb);
68
298
  return;
69
299
  }
300
+ const parsed = parseArgs(args, {
301
+ name: { kind: 'string', description: 'token label' },
302
+ expiry: { kind: 'string', description: 'ISO-8601 expiry' },
303
+ qr: { kind: 'boolean', description: 'render QR for first-tap login' },
304
+ 'qr-endpoint': { kind: 'string', description: 'endpoint label or URL for QR target' },
305
+ json: { kind: 'boolean', description: 'emit JSON' },
306
+ });
307
+ switch (verb) {
308
+ case 'create':
309
+ await h.handleTokenCreate(parsed);
310
+ return;
311
+ case 'list':
312
+ case 'show':
313
+ h.handleTokenShow(parsed);
314
+ return;
315
+ case 'revoke':
316
+ h.handleTokenRevoke(parsed);
317
+ return;
318
+ default:
319
+ throw new Error(`unknown token verb "${verb}"`);
320
+ }
321
+ }
322
+
323
+ async function dispatchAgent(verb: string | undefined, args: string[], env: GlobalEnv): Promise<void> {
324
+ if (!verb) {
325
+ printVerbHelp('agent', verb);
326
+ return;
327
+ }
328
+ // Remote agent verbs route through the client.
329
+ if (env.server !== undefined && verb === 'list') {
330
+ process.env.LLMUX_SERVER = env.server;
331
+ if (env.token) process.env.LLMUX_TOKEN = env.token;
332
+ const cmd = clientCommands['agents'];
333
+ if (!cmd) throw new Error('internal: client agents command missing');
334
+ await cmd.run(args);
335
+ return;
336
+ }
337
+ const parsed = parseArgs(args, {
338
+ all: { kind: 'boolean', description: 'include not-installed agents' },
339
+ installed: { kind: 'boolean', description: 'only installed agents (default)' },
340
+ json: { kind: 'boolean', description: 'emit JSON' },
341
+ });
342
+ switch (verb) {
343
+ case 'list': {
344
+ const showAll = Boolean(parsed.flags.all);
345
+ const rows = Object.values(DEFAULT_AGENTS)
346
+ .filter((d) => showAll || isAgentInstalled(d))
347
+ .map((d) => ({ key: d.key, displayName: d.displayName, cmd: d.cmd, flags: d.flags ?? '', installed: isAgentInstalled(d) }));
348
+ if (parsed.flags.json) {
349
+ console.log(JSON.stringify(rows, null, 2));
350
+ return;
351
+ }
352
+ for (const r of rows) {
353
+ console.log(`${r.key.padEnd(10)} ${r.displayName.padEnd(24)} ${r.installed ? 'installed' : 'not installed'} ${r.flags || '-'}`);
354
+ }
355
+ return;
356
+ }
357
+ default:
358
+ throw new Error(`unknown agent verb "${verb}"`);
359
+ }
360
+ }
361
+
362
+ // ----------------- main -----------------
363
+
364
+ async function main(): Promise<void> {
365
+ const { rest, env, help, version } = stripGlobals(process.argv.slice(2));
366
+
367
+ if (version) {
368
+ console.log(VERSION);
369
+ return;
370
+ }
371
+ if (rest.length === 0 || help) {
372
+ printRootHelp();
373
+ return;
374
+ }
375
+
376
+ const noun = rest[0]!;
377
+ const verb = rest[1];
378
+ const remainder = rest.slice(2);
70
379
 
71
380
  try {
72
- await command.run(rest);
381
+ switch (noun) {
382
+ case 'session':
383
+ await dispatchSession(verb, remainder, env);
384
+ return;
385
+ case 'server':
386
+ await dispatchServer(verb, remainder);
387
+ return;
388
+ case 'token':
389
+ await dispatchToken(verb, remainder);
390
+ return;
391
+ case 'agent':
392
+ await dispatchAgent(verb, remainder, env);
393
+ return;
394
+ // Backward-compat shorthand — some shells will already have `llmuxd serve`
395
+ // wired up. These verbs sit at noun-position so all of rest.slice(1) is
396
+ // their args, not just slice(2).
397
+ case 'serve':
398
+ await dispatchServer('start', rest.slice(1));
399
+ return;
400
+ case 'ls':
401
+ case 'status':
402
+ await dispatchSession('list', rest.slice(1), env);
403
+ return;
404
+ case 'help':
405
+ printRootHelp();
406
+ return;
407
+ default: {
408
+ // Treat anything we don't recognise as a client command (e.g. legacy
409
+ // `llmux send`, `llmux spawn`). The client module knows about them.
410
+ const cmd = clientCommands[noun];
411
+ if (cmd) {
412
+ if (env.server) process.env.LLMUX_SERVER = env.server;
413
+ if (env.token) process.env.LLMUX_TOKEN = env.token;
414
+ await cmd.run([verb!, ...remainder].filter((x) => x !== undefined));
415
+ return;
416
+ }
417
+ console.error(`llmux: unknown command "${noun}"`);
418
+ console.error('Run `llmux --help` to see the noun-prefix surface.');
419
+ process.exit(64);
420
+ }
421
+ }
73
422
  } catch (err) {
74
423
  const msg = err instanceof Error ? err.message : String(err);
75
- console.error(`llmux ${name}: ${msg}`);
424
+ console.error(`llmux: ${msg}`);
76
425
  process.exit(1);
77
426
  }
78
427
  }
File without changes