@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 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.12.3 — daemon + CLI client consolidated into one binary
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
- A YAML config (project-local or global) can override per-agent defaults.
185
- Discovery order:
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` (project-local, auto-discovered in cwd)
189
- 3. `~/.config/llmux/config.yaml` (global default)
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
- llmux runs without any YAML file — all defaults are baked into
193
- `agents.ts`. The `init` command to generate a starter YAML is not yet
194
- shipped; create one by hand if you want to override defaults today.
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 readFileSync5 } from "fs";
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 existsSync5 } from "fs";
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/state.ts
209
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
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 { dirname, join as join2 } from "path";
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 ? join2(xdg, "llmuxd") : join2(homedir2(), ".local", "state", "llmuxd");
251
+ return xdg ? join3(xdg, "llmuxd") : join3(homedir3(), ".local", "state", "llmuxd");
216
252
  }
217
253
  function statePath() {
218
- return join2(stateDir(), "sessions.json");
254
+ return join3(stateDir(), "sessions.json");
219
255
  }
220
256
  function load() {
221
257
  const path = statePath();
222
- if (!existsSync2(path)) return structuredClone(EMPTY);
258
+ if (!existsSync3(path)) return structuredClone(EMPTY);
223
259
  try {
224
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
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 existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
340
- import { dirname as dirname2, join as join3 } from "path";
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 join3(stateDir(), "auth.json");
391
+ return join4(stateDir(), "auth.json");
356
392
  }
357
393
  function load2() {
358
394
  const p = authPath();
359
- if (!existsSync3(p)) return structuredClone(EMPTY2);
395
+ if (!existsSync4(p)) return structuredClone(EMPTY2);
360
396
  try {
361
- const parsed = JSON.parse(readFileSync3(p, "utf8"));
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 readFileSync4, existsSync as existsSync4 } from "fs";
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(readFileSync4(candidate, "utf8"));
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 (!existsSync4(cwd)) return { ok: false, error: `cwd does not exist: ${cwdRaw}` };
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 && !existsSync4(expandTilde(newCwd))) {
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") return Object.values(DEFAULT_AGENTS).filter(isAgentInstalled);
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 def = DEFAULT_AGENTS[k];
2568
- if (!def) throw new Error(`unknown agent "${k}". Known: ${Object.keys(DEFAULT_AGENTS).join(", ")}`);
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 (!existsSync5(out)) throw new Error(`cwd does not exist: ${out}`);
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 portRaw = args.flags.port ?? process.env.LLMUXD_PORT ?? "3000";
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(readFileSync5(candidate, "utf8"));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/llmux",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "tmux-based AI agent dispatcher — REST/WS daemon + CLI client in one binary",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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') return Object.values(DEFAULT_AGENTS).filter(isAgentInstalled);
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 def = DEFAULT_AGENTS[k];
21
- if (!def) throw new Error(`unknown agent "${k}". Known: ${Object.keys(DEFAULT_AGENTS).join(', ')}`);
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 portRaw = (args.flags.port as string | undefined) ?? process.env.LLMUXD_PORT ?? '3000';
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}`);