@cordfuse/llmux 0.12.4 → 0.13.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/README.md +65 -8
- package/dist/index.js +74 -24
- package/package.json +1 -1
- package/src/daemon/handlers.ts +30 -4
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Each agent runs unmodified — llmux just brokers I/O. Sessions survive
|
|
|
8
8
|
restarts, attach is raw-TTY, and Claude Code conversations are resumable
|
|
9
9
|
from any client.
|
|
10
10
|
|
|
11
|
-
> **Status:** v0.
|
|
11
|
+
> **Status:** v0.13.0 — daemon + CLI client consolidated into one binary
|
|
12
12
|
> (`llmux`). Auth, tokens, mobile picker, conversation resume, Claude Code
|
|
13
13
|
> history adapter shipped. See [CHANGELOG.md](./CHANGELOG.md).
|
|
14
14
|
|
|
@@ -181,17 +181,74 @@ currently speaks ws:// only.
|
|
|
181
181
|
|
|
182
182
|
## Config (`.llmux.yaml`)
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
Optional YAML config file. llmux runs without it — defaults are baked into
|
|
185
|
+
`agents.ts`. Use the YAML to override per-agent launch behavior or change
|
|
186
|
+
the daemon's default port without baking a flag into every shell alias.
|
|
187
|
+
|
|
188
|
+
### Discovery order (first hit wins)
|
|
186
189
|
|
|
187
190
|
1. `--config <path>` flag
|
|
188
|
-
2. `./.llmux.yaml`
|
|
189
|
-
3. `~/.config/llmux/config.yaml`
|
|
191
|
+
2. `./.llmux.yaml` — auto-discovered in the cwd you invoke from
|
|
192
|
+
3. `~/.config/llmux/config.yaml` — global default
|
|
190
193
|
4. `LLMUX_CONFIG=<path>` env var
|
|
191
194
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
### Schema
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
# Server defaults — used when `llmux server start` runs with no overriding
|
|
199
|
+
# flag / env. Precedence: --port flag > LLMUXD_PORT env > server.port here.
|
|
200
|
+
server:
|
|
201
|
+
port: 3030 # daemon listen port (default 3000 when key omitted)
|
|
202
|
+
|
|
203
|
+
# Per-agent overrides. Key matches the agent's `key` in the catalog
|
|
204
|
+
# (claude, codex, agy, gemini, qwen, opencode, amp, grok, aider, continue,
|
|
205
|
+
# kiro, cursor, plandex, goose, copilot). Only the keys you list override;
|
|
206
|
+
# everything else falls through to the catalog default.
|
|
207
|
+
agents:
|
|
208
|
+
claude:
|
|
209
|
+
cmd: claude # binary path or PATH-lookup name (default: agent's catalog cmd)
|
|
210
|
+
flags: "" # launch flags appended after cmd (default: catalog default,
|
|
211
|
+
# e.g. "--dangerously-skip-permissions" for claude).
|
|
212
|
+
# Empty string disables the default flags entirely.
|
|
213
|
+
codex:
|
|
214
|
+
flags: "--model gpt-5" # keep `codex` as the binary, override flags
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Worked examples
|
|
218
|
+
|
|
219
|
+
**Strip danger-mode flags from claude on a shared machine:**
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
agents:
|
|
223
|
+
claude:
|
|
224
|
+
flags: "" # claude launches with no flags — full permission prompts
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Point gemini at a wrapper script (logging, rate-limiting, whatever):**
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
agents:
|
|
231
|
+
gemini:
|
|
232
|
+
cmd: /usr/local/bin/gemini-wrapped
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Run the daemon on a non-default port project-wide:**
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
server:
|
|
239
|
+
port: 8080
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
A bare `llmux server start` from any cwd containing this file binds to
|
|
243
|
+
`:8080`. `--port 3030` still wins per-invocation.
|
|
244
|
+
|
|
245
|
+
### What this YAML does NOT do today
|
|
246
|
+
|
|
247
|
+
The schema includes `agents.<key>.readyPrompt`, `server.token`,
|
|
248
|
+
`server.tokenExpiry`, `server.noQr`, and `sessions[]` (auto-spawn list).
|
|
249
|
+
These are reserved for future wiring — setting them has no effect in
|
|
250
|
+
v0.13.x. If you need any of these surfaces, file an issue and they can be
|
|
251
|
+
prioritised.
|
|
195
252
|
|
|
196
253
|
## Environment
|
|
197
254
|
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
5
5
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
6
|
import { dirname as dirname4, resolve as resolve3 } from "path";
|
|
7
7
|
|
|
@@ -70,7 +70,7 @@ function parseArgs(argv, specs) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// src/daemon/handlers.ts
|
|
73
|
-
import { existsSync as
|
|
73
|
+
import { existsSync as existsSync6 } from "fs";
|
|
74
74
|
import { resolve as resolve2 } from "path";
|
|
75
75
|
import { createInterface } from "readline";
|
|
76
76
|
import qrcodeTerminal from "qrcode-terminal";
|
|
@@ -205,23 +205,59 @@ function isAgentInstalled(agent) {
|
|
|
205
205
|
return which(head);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
// src/daemon/
|
|
209
|
-
import { existsSync as existsSync2,
|
|
208
|
+
// src/daemon/config.ts
|
|
209
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
210
210
|
import { homedir as homedir2 } from "os";
|
|
211
|
-
import {
|
|
211
|
+
import { join as join2 } from "path";
|
|
212
|
+
import { parse as parseYaml } from "yaml";
|
|
213
|
+
var DEFAULT_CONFIG = {
|
|
214
|
+
server: { port: 3e3, noQr: false },
|
|
215
|
+
agents: {},
|
|
216
|
+
sessions: []
|
|
217
|
+
};
|
|
218
|
+
function discoverConfigPath(opts = {}) {
|
|
219
|
+
if (opts.explicit) return opts.explicit;
|
|
220
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
221
|
+
const projectLocal = join2(cwd, ".llmux.yaml");
|
|
222
|
+
if (existsSync2(projectLocal)) return projectLocal;
|
|
223
|
+
const home = opts.home ?? homedir2();
|
|
224
|
+
const globalDefault = join2(home, ".config", "llmux", "config.yaml");
|
|
225
|
+
if (existsSync2(globalDefault)) return globalDefault;
|
|
226
|
+
const env = opts.envVar ?? process.env.LLMUX_CONFIG;
|
|
227
|
+
if (env && existsSync2(env)) return env;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function loadConfig(opts = {}) {
|
|
231
|
+
const path = discoverConfigPath(opts);
|
|
232
|
+
if (!path) return DEFAULT_CONFIG;
|
|
233
|
+
const raw = readFileSync2(path, "utf8");
|
|
234
|
+
const parsed = parseYaml(raw);
|
|
235
|
+
const merged = {
|
|
236
|
+
server: { ...DEFAULT_CONFIG.server, ...parsed?.server ?? {} },
|
|
237
|
+
agents: { ...DEFAULT_CONFIG.agents, ...parsed?.agents ?? {} },
|
|
238
|
+
sessions: parsed?.sessions ?? [],
|
|
239
|
+
sourcePath: path
|
|
240
|
+
};
|
|
241
|
+
return merged;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/daemon/state.ts
|
|
245
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
246
|
+
import { homedir as homedir3 } from "os";
|
|
247
|
+
import { dirname, join as join3 } from "path";
|
|
212
248
|
var EMPTY = { version: 1, sessions: {} };
|
|
213
249
|
function stateDir() {
|
|
214
250
|
const xdg = process.env.XDG_STATE_HOME;
|
|
215
|
-
return xdg ?
|
|
251
|
+
return xdg ? join3(xdg, "llmuxd") : join3(homedir3(), ".local", "state", "llmuxd");
|
|
216
252
|
}
|
|
217
253
|
function statePath() {
|
|
218
|
-
return
|
|
254
|
+
return join3(stateDir(), "sessions.json");
|
|
219
255
|
}
|
|
220
256
|
function load() {
|
|
221
257
|
const path = statePath();
|
|
222
|
-
if (!
|
|
258
|
+
if (!existsSync3(path)) return structuredClone(EMPTY);
|
|
223
259
|
try {
|
|
224
|
-
const parsed = JSON.parse(
|
|
260
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
225
261
|
if (parsed.version !== 1 || typeof parsed.sessions !== "object" || parsed.sessions === null) {
|
|
226
262
|
return structuredClone(EMPTY);
|
|
227
263
|
}
|
|
@@ -336,8 +372,8 @@ function attachOrSwitch(name) {
|
|
|
336
372
|
}
|
|
337
373
|
|
|
338
374
|
// src/daemon/auth-store.ts
|
|
339
|
-
import { existsSync as
|
|
340
|
-
import { dirname as dirname2, join as
|
|
375
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
376
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
341
377
|
|
|
342
378
|
// src/daemon/token.ts
|
|
343
379
|
import { randomBytes } from "crypto";
|
|
@@ -352,13 +388,13 @@ function tokenId(token) {
|
|
|
352
388
|
// src/daemon/auth-store.ts
|
|
353
389
|
var EMPTY2 = { version: 1, tokens: [] };
|
|
354
390
|
function authPath() {
|
|
355
|
-
return
|
|
391
|
+
return join4(stateDir(), "auth.json");
|
|
356
392
|
}
|
|
357
393
|
function load2() {
|
|
358
394
|
const p = authPath();
|
|
359
|
-
if (!
|
|
395
|
+
if (!existsSync4(p)) return structuredClone(EMPTY2);
|
|
360
396
|
try {
|
|
361
|
-
const parsed = JSON.parse(
|
|
397
|
+
const parsed = JSON.parse(readFileSync4(p, "utf8"));
|
|
362
398
|
if (parsed.version === 1 && Array.isArray(parsed.tokens)) {
|
|
363
399
|
return { version: 1, tokens: parsed.tokens };
|
|
364
400
|
}
|
|
@@ -411,7 +447,7 @@ function authEnabled() {
|
|
|
411
447
|
|
|
412
448
|
// src/daemon/web/server.ts
|
|
413
449
|
import { createServer } from "http";
|
|
414
|
-
import { readFileSync as
|
|
450
|
+
import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
|
|
415
451
|
import { fileURLToPath } from "url";
|
|
416
452
|
import { dirname as dirname3, resolve } from "path";
|
|
417
453
|
import { WebSocketServer } from "ws";
|
|
@@ -501,7 +537,7 @@ function readDaemonVersion() {
|
|
|
501
537
|
resolve(here, "./package.json")
|
|
502
538
|
]) {
|
|
503
539
|
try {
|
|
504
|
-
const pkg = JSON.parse(
|
|
540
|
+
const pkg = JSON.parse(readFileSync5(candidate, "utf8"));
|
|
505
541
|
if (pkg.name === "@cordfuse/llmux" && typeof pkg.version === "string") return pkg.version;
|
|
506
542
|
} catch {
|
|
507
543
|
}
|
|
@@ -2090,7 +2126,7 @@ function createSession(input) {
|
|
|
2090
2126
|
}
|
|
2091
2127
|
const cwdRaw = input.cwd && input.cwd.trim() || process.env.HOME || process.cwd();
|
|
2092
2128
|
const cwd = expandTilde(cwdRaw);
|
|
2093
|
-
if (!
|
|
2129
|
+
if (!existsSync5(cwd)) return { ok: false, error: `cwd does not exist: ${cwdRaw}` };
|
|
2094
2130
|
const flagsOverride = input.flags !== void 0 ? input.flags.trim() : void 0;
|
|
2095
2131
|
const envOverride = input.env !== void 0 ? parseEnvText(input.env) : void 0;
|
|
2096
2132
|
const resumeFrom = input.resumeFrom && agentDef.history ? input.resumeFrom : void 0;
|
|
@@ -2190,7 +2226,7 @@ function editSession(oldName, patch) {
|
|
|
2190
2226
|
return { ok: false, error: `session "${newName}" already exists` };
|
|
2191
2227
|
}
|
|
2192
2228
|
}
|
|
2193
|
-
if (newCwd !== void 0 && newCwd.length > 0 && !
|
|
2229
|
+
if (newCwd !== void 0 && newCwd.length > 0 && !existsSync5(expandTilde(newCwd))) {
|
|
2194
2230
|
return { ok: false, error: `cwd does not exist: ${newCwd}` };
|
|
2195
2231
|
}
|
|
2196
2232
|
const renaming = newName !== void 0 && newName !== oldName && newName.length > 0;
|
|
@@ -2559,13 +2595,26 @@ function printBanner(port) {
|
|
|
2559
2595
|
}
|
|
2560
2596
|
|
|
2561
2597
|
// src/daemon/handlers.ts
|
|
2598
|
+
function applyAgentOverrides(base) {
|
|
2599
|
+
const cfg = loadConfig();
|
|
2600
|
+
const o = cfg.agents[base.key];
|
|
2601
|
+
if (!o) return base;
|
|
2602
|
+
return {
|
|
2603
|
+
...base,
|
|
2604
|
+
...o.cmd !== void 0 ? { cmd: o.cmd } : {},
|
|
2605
|
+
...o.flags !== void 0 ? { flags: o.flags } : {}
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2562
2608
|
function expandAgentList(spec) {
|
|
2563
|
-
if (spec === "all")
|
|
2609
|
+
if (spec === "all") {
|
|
2610
|
+
return Object.values(DEFAULT_AGENTS).map(applyAgentOverrides).filter(isAgentInstalled);
|
|
2611
|
+
}
|
|
2564
2612
|
const keys = spec.split(",").map((k) => k.trim()).filter(Boolean);
|
|
2565
2613
|
const out = [];
|
|
2566
2614
|
for (const k of keys) {
|
|
2567
|
-
const
|
|
2568
|
-
if (!
|
|
2615
|
+
const base = DEFAULT_AGENTS[k];
|
|
2616
|
+
if (!base) throw new Error(`unknown agent "${k}". Known: ${Object.keys(DEFAULT_AGENTS).join(", ")}`);
|
|
2617
|
+
const def = applyAgentOverrides(base);
|
|
2569
2618
|
if (!isAgentInstalled(def)) throw new Error(`agent "${k}" is not installed (looked for: ${def.cmd})`);
|
|
2570
2619
|
out.push(def);
|
|
2571
2620
|
}
|
|
@@ -2577,7 +2626,7 @@ function buildCommand(agent) {
|
|
|
2577
2626
|
function resolveCwd(input) {
|
|
2578
2627
|
if (!input) return process.cwd();
|
|
2579
2628
|
const out = resolve2(input);
|
|
2580
|
-
if (!
|
|
2629
|
+
if (!existsSync6(out)) throw new Error(`cwd does not exist: ${out}`);
|
|
2581
2630
|
return out;
|
|
2582
2631
|
}
|
|
2583
2632
|
function resolveTarget(target) {
|
|
@@ -2714,7 +2763,8 @@ function handleChat(args) {
|
|
|
2714
2763
|
}
|
|
2715
2764
|
async function handleServe(args) {
|
|
2716
2765
|
requireTmux();
|
|
2717
|
-
const
|
|
2766
|
+
const cfg = loadConfig();
|
|
2767
|
+
const portRaw = args.flags.port ?? process.env.LLMUXD_PORT ?? String(cfg.server.port);
|
|
2718
2768
|
const port = Number(portRaw);
|
|
2719
2769
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
2720
2770
|
throw new Error(`invalid port: ${portRaw}`);
|
|
@@ -3448,7 +3498,7 @@ function readVersion() {
|
|
|
3448
3498
|
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
3449
3499
|
for (const candidate of [resolve3(here, "../package.json"), resolve3(here, "../../package.json")]) {
|
|
3450
3500
|
try {
|
|
3451
|
-
const pkg = JSON.parse(
|
|
3501
|
+
const pkg = JSON.parse(readFileSync6(candidate, "utf8"));
|
|
3452
3502
|
if (pkg.name === "@cordfuse/llmux" && typeof pkg.version === "string") return pkg.version;
|
|
3453
3503
|
} catch {
|
|
3454
3504
|
}
|
package/package.json
CHANGED
package/src/daemon/handlers.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
4
|
import qrcodeTerminal from 'qrcode-terminal';
|
|
5
5
|
import { DEFAULT_AGENTS, isAgentInstalled, type AgentDefinition } from './agents.ts';
|
|
6
|
+
import { loadConfig } from './config.ts';
|
|
6
7
|
import * as state from './state.ts';
|
|
7
8
|
import * as tmux from './tmux.ts';
|
|
8
9
|
import * as authStore from './auth-store.ts';
|
|
@@ -12,13 +13,32 @@ import type { ParsedArgs } from '../cli.ts';
|
|
|
12
13
|
|
|
13
14
|
// ---------- helpers ----------
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Merge YAML `agents.<key>.{cmd,flags}` overrides over the catalog default.
|
|
18
|
+
* Discovery uses process.cwd() so a project-local `.llmux.yaml` takes effect
|
|
19
|
+
* when the operator invokes from that project.
|
|
20
|
+
*/
|
|
21
|
+
function applyAgentOverrides(base: AgentDefinition): AgentDefinition {
|
|
22
|
+
const cfg = loadConfig();
|
|
23
|
+
const o = cfg.agents[base.key];
|
|
24
|
+
if (!o) return base;
|
|
25
|
+
return {
|
|
26
|
+
...base,
|
|
27
|
+
...(o.cmd !== undefined ? { cmd: o.cmd } : {}),
|
|
28
|
+
...(o.flags !== undefined ? { flags: o.flags } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
function expandAgentList(spec: string): AgentDefinition[] {
|
|
16
|
-
if (spec === 'all')
|
|
33
|
+
if (spec === 'all') {
|
|
34
|
+
return Object.values(DEFAULT_AGENTS).map(applyAgentOverrides).filter(isAgentInstalled);
|
|
35
|
+
}
|
|
17
36
|
const keys = spec.split(',').map((k) => k.trim()).filter(Boolean);
|
|
18
37
|
const out: AgentDefinition[] = [];
|
|
19
38
|
for (const k of keys) {
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
39
|
+
const base = DEFAULT_AGENTS[k];
|
|
40
|
+
if (!base) throw new Error(`unknown agent "${k}". Known: ${Object.keys(DEFAULT_AGENTS).join(', ')}`);
|
|
41
|
+
const def = applyAgentOverrides(base);
|
|
22
42
|
if (!isAgentInstalled(def)) throw new Error(`agent "${k}" is not installed (looked for: ${def.cmd})`);
|
|
23
43
|
out.push(def);
|
|
24
44
|
}
|
|
@@ -193,7 +213,13 @@ export function handleChat(args: ParsedArgs): void {
|
|
|
193
213
|
|
|
194
214
|
export async function handleServe(args: ParsedArgs): Promise<void> {
|
|
195
215
|
tmux.requireTmux();
|
|
196
|
-
const
|
|
216
|
+
const cfg = loadConfig();
|
|
217
|
+
// Precedence: CLI --port > LLMUXD_PORT env > config.server.port (schema
|
|
218
|
+
// default already 3000 when no YAML present).
|
|
219
|
+
const portRaw =
|
|
220
|
+
(args.flags.port as string | undefined) ??
|
|
221
|
+
process.env.LLMUXD_PORT ??
|
|
222
|
+
String(cfg.server.port);
|
|
197
223
|
const port = Number(portRaw);
|
|
198
224
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
199
225
|
throw new Error(`invalid port: ${portRaw}`);
|