@cordfuse/llmux 0.12.0 → 0.12.1
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 +155 -75
- package/dist/index.js +17 -17
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/client/client.ts +3 -3
- package/src/daemon/handlers.ts +5 -5
- package/src/daemon/web/server.ts +9 -9
package/README.md
CHANGED
|
@@ -1,119 +1,199 @@
|
|
|
1
1
|
# llmux
|
|
2
2
|
|
|
3
|
-
You have Claude Code in one terminal, Codex in another,
|
|
4
|
-
|
|
3
|
+
You have Claude Code in one terminal, Codex in another, Aider in a third, and
|
|
4
|
+
OpenCode in a fourth. They're all live, all mid-conversation, all costing
|
|
5
5
|
nothing to keep open. But to fire a prompt at one of them you have to find the
|
|
6
|
-
right window, click in, and type. To
|
|
7
|
-
just don't. And if you're on the couch with your phone? Forget it.
|
|
6
|
+
right window, click in, and type. To do anything from your phone? Forget it.
|
|
8
7
|
|
|
9
8
|
llmux turns every agent CLI into a named tmux session you can drive from
|
|
10
|
-
anywhere. Spawn `claude`, `agy`, `
|
|
11
|
-
`
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
anywhere. Spawn `claude`, `codex`, `agy`, `gemini`, `qwen`, `opencode`, `amp`,
|
|
10
|
+
`grok`, `aider`, `continue`, `kiro`, `cursor`, `plandex`, `goose`, or
|
|
11
|
+
`gh copilot` once. Then fire prompts at any of them — by name, from a CLI, from
|
|
12
|
+
a REST call, or from a browser on your phone over Tailscale. Past
|
|
13
|
+
conversations are browsable and resumable. The agents keep running.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
>
|
|
18
|
-
> See [CHANGELOG.md](./CHANGELOG.md).
|
|
15
|
+
> **Status:** v0.12.0 — daemon + CLI client consolidated into one binary
|
|
16
|
+
> (`llmux`). Auth, tokens, mobile picker, conversation resume, Claude Code
|
|
17
|
+
> history adapter shipped. See [CHANGELOG.md](./CHANGELOG.md).
|
|
19
18
|
|
|
20
19
|
## Install
|
|
21
20
|
|
|
22
21
|
```bash
|
|
23
|
-
#
|
|
24
|
-
npm install -g @cordfuse/llmuxd
|
|
25
|
-
|
|
26
|
-
# Anywhere you want to send prompts from (laptop, phone, CI)
|
|
22
|
+
# One package, one binary — installs on the daemon host AND any client machine
|
|
27
23
|
npm install -g @cordfuse/llmux
|
|
28
24
|
```
|
|
29
25
|
|
|
26
|
+
If you used the now-deprecated `@cordfuse/llmuxd` package: uninstall it and
|
|
27
|
+
install `@cordfuse/llmux` instead. The `llmuxd` binary is gone; the `llmux`
|
|
28
|
+
binary covers both daemon and client roles.
|
|
29
|
+
|
|
30
30
|
## 30-second quickstart
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
#
|
|
34
|
-
|
|
33
|
+
# 1. Start the daemon (binds REST + WebSocket + browser picker)
|
|
34
|
+
llmux server start --port 3030
|
|
35
|
+
|
|
36
|
+
# 2. Spawn an agent into a named tmux session
|
|
37
|
+
llmux session start claude --name main --cwd ~/projects/myapp
|
|
35
38
|
|
|
36
|
-
# Fire a prompt
|
|
37
|
-
|
|
39
|
+
# 3. Fire a prompt — fire-and-forget
|
|
40
|
+
llmux session prompt main "what does src/index.ts do?"
|
|
38
41
|
|
|
39
|
-
# Or attach interactively (
|
|
40
|
-
|
|
42
|
+
# 4. Or attach interactively (raw TTY pass-through)
|
|
43
|
+
llmux session attach main
|
|
41
44
|
|
|
42
|
-
# Or
|
|
43
|
-
|
|
45
|
+
# 5. Or open the browser picker (URL is in the server start banner)
|
|
46
|
+
# Pick a session, get a full-screen xterm.js terminal wired over WebSocket.
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
On mobile the picker is a real PWA-style surface — spawn / restart / kill /
|
|
50
|
+
edit / resume past conversations, with a confirmation modal on destructive
|
|
51
|
+
actions. The chat page is a phone-friendly xterm with a custom soft-keyboard
|
|
52
|
+
toolbar that surfaces Esc / Tab / Ctrl / Alt / arrows / shell chars that
|
|
53
|
+
gboard hides.
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
> Until then, bind to `127.0.0.1` (default) or expose only over Tailscale.
|
|
55
|
+
## Remote operation
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
The same binary is the client. Set `--server` (or `LLMUX_SERVER` env) on any
|
|
58
|
+
session/agent verb and it routes over HTTP instead of operating locally:
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
```bash
|
|
61
|
+
export LLMUX_SERVER=http://100.105.221.46:3030
|
|
62
|
+
export LLMUX_TOKEN=sas_… # mint with `llmux token create`
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
llmux session list
|
|
65
|
+
llmux session prompt main "tomorrow's plan?"
|
|
66
|
+
llmux session attach main # raw TTY pass-through over WS
|
|
67
|
+
llmux session resume main --latest # rebind to the most recent claude convo
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Localhost requests bypass auth; remote requests require a Bearer token.
|
|
71
|
+
`--token <sas>` per-command works too.
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
input via `tmux send-keys` and reads output by attaching xterm.js over a
|
|
66
|
-
WebSocket bridge. That keeps the agent CLIs unmodified — Claude Code is still
|
|
67
|
-
running Claude Code; llmuxd just coordinates input and exposes the surface.
|
|
73
|
+
## Noun-prefix surface
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
```
|
|
76
|
+
session list / start / stop / restart / attach / prompt / broadcast
|
|
77
|
+
/ resume / history
|
|
78
|
+
server start
|
|
79
|
+
token create / list / revoke
|
|
80
|
+
agent list [--all] [--installed] [--json]
|
|
81
|
+
```
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
Global flags: `--server <url>`, `--token <sas>`, `--help`, `--version`.
|
|
84
|
+
|
|
85
|
+
Backward-compat shims (kept one release): `llmux serve`, `llmux ls`,
|
|
86
|
+
`llmux status`, and the legacy flat verbs (`llmux send`, `llmux spawn`,
|
|
87
|
+
`llmux kill`, etc.) still work; they fall through to the noun-prefix
|
|
88
|
+
dispatcher.
|
|
89
|
+
|
|
90
|
+
## How it works
|
|
91
|
+
|
|
92
|
+
Each spawned agent is a real tmux session, not a wrapped PTY. The daemon
|
|
93
|
+
dispatches input via `tmux send-keys` and exposes the surface over a REST API
|
|
94
|
+
plus a WebSocket bridge to xterm.js (via node-pty attached to
|
|
95
|
+
`tmux attach -t <name>`). That keeps the agent CLIs unmodified — Claude Code
|
|
96
|
+
is still running Claude Code; llmux just coordinates input and exposes the
|
|
97
|
+
surface.
|
|
98
|
+
|
|
99
|
+
State lives at `~/.local/state/llmuxd/sessions.json` (or
|
|
100
|
+
`$XDG_STATE_HOME/llmuxd/sessions.json`) with `0600` perms and a versioned
|
|
101
|
+
schema. Auth tokens live in the sibling `auth.json`. The state directory keeps
|
|
102
|
+
its `llmuxd/` name across the v0.12.0 package consolidation so existing
|
|
103
|
+
operators don't need to migrate anything.
|
|
104
|
+
|
|
105
|
+
The daemon runs on Node (not Bun) — `node-pty`'s native prebuilds target
|
|
106
|
+
Node, and attaching to tmux through node-pty under Bun caused immediate SIGHUP.
|
|
76
107
|
|
|
77
108
|
## Supported agents
|
|
78
109
|
|
|
79
|
-
|
|
|
80
|
-
|
|
81
|
-
| `claude` | [Claude Code](https://
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`
|
|
110
|
+
| Key | CLI | Danger-mode default |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `claude` | [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) | `--dangerously-skip-permissions` |
|
|
113
|
+
| `codex` | [OpenAI Codex CLI](https://github.com/openai/codex) | `--dangerously-bypass-approvals-and-sandbox` |
|
|
114
|
+
| `agy` | [Antigravity CLI](https://antigravity.google) | `--dangerously-skip-permissions` |
|
|
115
|
+
| `gemini` | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `--yolo` |
|
|
116
|
+
| `qwen` | [Qwen Code](https://github.com/QwenLM/qwen-code) | `--yolo` |
|
|
117
|
+
| `opencode` | [OpenCode](https://opencode.ai) | env: `OPENCODE_YOLO=1` (TUI lacks a flag) |
|
|
118
|
+
| `amp` | [Sourcegraph Amp](https://ampcode.com) | `--dangerously-allow-all` |
|
|
119
|
+
| `grok` | [Grok Build CLI](https://x.ai/cli) | `--always-approve` |
|
|
120
|
+
| `aider` | [Aider](https://aider.chat) | `--yes-always` |
|
|
121
|
+
| `continue` | [Continue CLI](https://docs.continue.dev/guides/cli) (`cn`) | `--auto` |
|
|
122
|
+
| `kiro` | [Kiro CLI](https://kiro.dev/cli/) | `--trust-all-tools` |
|
|
123
|
+
| `cursor` | [Cursor CLI](https://cursor.com/docs/cli/installation) (`cursor-agent`) | (config-based) |
|
|
124
|
+
| `plandex` | [Plandex](https://plandex.ai) | (interactive `set-auto`) |
|
|
125
|
+
| `goose` | [Goose](https://block.github.io/goose) | env: `GOOSE_MODE=auto` |
|
|
126
|
+
| `copilot` | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-in-the-cli) (`gh copilot`) | n/a |
|
|
127
|
+
|
|
128
|
+
Only installed agents appear in `llmux agent list` and the picker dropdown.
|
|
129
|
+
Detection uses a pure-Node PATH walk for most; `copilot` checks the gh-managed
|
|
130
|
+
binary directory.
|
|
131
|
+
|
|
132
|
+
Per-session overrides via `llmux session start <agent>`:
|
|
133
|
+
- `--name <X>` — tmux session name (defaults to the agent key)
|
|
134
|
+
- `--cwd <path>` — working directory (accepts `~/…` shorthand)
|
|
135
|
+
- `--flags "<f>"` — replace the agent's default flags entirely
|
|
136
|
+
- `--env "KEY=VAL"` — extra env vars (newline-separated for multiple)
|
|
137
|
+
|
|
138
|
+
Editing any of these on a running session via the web picker auto-respawns
|
|
139
|
+
the tmux session so changes take effect immediately.
|
|
140
|
+
|
|
141
|
+
## Conversation resume
|
|
142
|
+
|
|
143
|
+
For agents with a history adapter (Claude Code today; codex/gemini/etc.
|
|
144
|
+
coming), the row gets a `☰ N` button. Tap it to see past conversations in the
|
|
145
|
+
session's cwd; pick one to relaunch the agent with its `--resume <id>` flag.
|
|
146
|
+
State preserves the binding across restarts so respawn keeps you on the
|
|
147
|
+
same conversation. Use `llmux session resume <name> --latest` from the CLI
|
|
148
|
+
for the same flow.
|
|
149
|
+
|
|
150
|
+
## Auth
|
|
151
|
+
|
|
152
|
+
`llmux server start` runs without auth until you create a token:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
llmux token create --name phone
|
|
156
|
+
# prints sas_…<43-char-base64url> once; copy it.
|
|
157
|
+
# pass --qr-endpoint tailscale-https for a QR-code deep-link that logs you
|
|
158
|
+
# in on first scan from a phone.
|
|
159
|
+
|
|
160
|
+
llmux token list
|
|
161
|
+
llmux token revoke <8-char-id>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
After the first token exists, all non-localhost HTTP/WS requests require
|
|
165
|
+
either `Authorization: Bearer <sas>` (CLI / curl) or the `llmuxd_token`
|
|
166
|
+
cookie set by the browser gate. Localhost stays open so local CLI use needs
|
|
167
|
+
no token.
|
|
168
|
+
|
|
169
|
+
If `tailscale serve --https=443 http://localhost:<port>` is configured on the
|
|
170
|
+
host, the server-start banner surfaces the HTTPS hostname URL above the
|
|
171
|
+
http endpoints. The browser picker is a clean TLS surface; CLI `attach`
|
|
172
|
+
currently speaks ws:// only.
|
|
93
173
|
|
|
94
174
|
## Config (`.llmux.yaml`)
|
|
95
175
|
|
|
96
|
-
|
|
176
|
+
A YAML config (project-local or global) can override per-agent defaults.
|
|
177
|
+
Discovery order:
|
|
97
178
|
|
|
98
179
|
1. `--config <path>` flag
|
|
99
|
-
2. `./.llmux.yaml` (project-
|
|
180
|
+
2. `./.llmux.yaml` (project-local, auto-discovered in cwd)
|
|
100
181
|
3. `~/.config/llmux/config.yaml` (global default)
|
|
101
182
|
4. `LLMUX_CONFIG=<path>` env var
|
|
102
183
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
184
|
+
llmux runs without any YAML file — all defaults are baked into
|
|
185
|
+
`agents.ts`. The `init` command to generate a starter YAML is not yet
|
|
186
|
+
shipped; create one by hand if you want to override defaults today.
|
|
106
187
|
|
|
107
|
-
##
|
|
188
|
+
## Environment
|
|
108
189
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
- [ ] **Phase 7** — polish + npm publish
|
|
190
|
+
| Variable | Purpose |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `LLMUX_SERVER` | Default `--server` URL for session/agent verbs |
|
|
193
|
+
| `LLMUX_TOKEN` | Default `--token` SAS auth |
|
|
194
|
+
| `LLMUX_PORT` | Default port resolution for QR-endpoint helpers |
|
|
195
|
+
| `XDG_STATE_HOME` | Override for the state directory parent |
|
|
196
|
+
| `OPENCODE_YOLO`, `GOOSE_MODE`, … | Forwarded by `envDefaults` per-agent |
|
|
117
197
|
|
|
118
198
|
## License
|
|
119
199
|
|
package/dist/index.js
CHANGED
|
@@ -753,7 +753,7 @@ function pickerPage() {
|
|
|
753
753
|
</div>
|
|
754
754
|
</div>
|
|
755
755
|
<footer>
|
|
756
|
-
<span>
|
|
756
|
+
<span>llmux v${escapeHtml(DAEMON_VERSION)}</span>
|
|
757
757
|
${authEnabled() ? `<span class="ok">\u2713 auth required \u2014 ${listAuthTokens().length} active token${listAuthTokens().length === 1 ? "" : "s"}</span>` : `<span class="warn">\u26A0 no auth \u2014 anyone on the network can attach</span>`}
|
|
758
758
|
</footer>
|
|
759
759
|
<script>
|
|
@@ -810,7 +810,7 @@ function pickerPage() {
|
|
|
810
810
|
|
|
811
811
|
function render(sessions){
|
|
812
812
|
if (!sessions || sessions.length === 0){
|
|
813
|
-
container.innerHTML = '<div class="empty">no sessions yet \u2014 spawn one from the CLI:<br><br><code>
|
|
813
|
+
container.innerHTML = '<div class="empty">no sessions yet \u2014 spawn one from the CLI:<br><br><code>llmux session start claude --name <em>name</em></code></div>';
|
|
814
814
|
return;
|
|
815
815
|
}
|
|
816
816
|
const rows = sessions.map(rowHtml).join('');
|
|
@@ -1287,7 +1287,7 @@ function pickerPage() {
|
|
|
1287
1287
|
}
|
|
1288
1288
|
function renderSessionTable(sessions) {
|
|
1289
1289
|
if (sessions.length === 0) {
|
|
1290
|
-
return `<div class="empty">no sessions yet \u2014 spawn one from the CLI:<br><br><code>
|
|
1290
|
+
return `<div class="empty">no sessions yet \u2014 spawn one from the CLI:<br><br><code>llmux session start claude --name <em>name</em></code></div>`;
|
|
1291
1291
|
}
|
|
1292
1292
|
const rows = sessions.map((s) => {
|
|
1293
1293
|
const cls = `state-${s.status}`;
|
|
@@ -1386,7 +1386,7 @@ function sessionPage(name) {
|
|
|
1386
1386
|
const jsonVersion = JSON.stringify(DAEMON_VERSION);
|
|
1387
1387
|
return `<!doctype html><html lang="en"><head>
|
|
1388
1388
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
|
|
1389
|
-
<title>${escapedName} \u2014
|
|
1389
|
+
<title>${escapedName} \u2014 llmux</title>
|
|
1390
1390
|
<link rel="icon" href="${FAVICON_DATA_URL}">
|
|
1391
1391
|
<link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
|
|
1392
1392
|
<link rel="stylesheet" href="${XTERM_CSS}">
|
|
@@ -1901,10 +1901,10 @@ function isWsAuthorized(req, urlSearch) {
|
|
|
1901
1901
|
return validateAuthToken(extractWsToken(req, urlSearch));
|
|
1902
1902
|
}
|
|
1903
1903
|
function gatePage(reason) {
|
|
1904
|
-
const message = reason === "invalid" ? "Token rejected. Try again." : "This
|
|
1904
|
+
const message = reason === "invalid" ? "Token rejected. Try again." : "This llmux daemon requires a token.";
|
|
1905
1905
|
return `<!doctype html><html lang="en"><head>
|
|
1906
1906
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1907
|
-
<title>
|
|
1907
|
+
<title>llmux \u2014 auth</title>
|
|
1908
1908
|
<link rel="icon" href="${FAVICON_DATA_URL}">
|
|
1909
1909
|
<style>
|
|
1910
1910
|
:root{color-scheme:dark}
|
|
@@ -1935,7 +1935,7 @@ function gatePage(reason) {
|
|
|
1935
1935
|
<div class="msg" id="msg"></div>
|
|
1936
1936
|
</form>
|
|
1937
1937
|
<div class="hint">
|
|
1938
|
-
Generate a token on the daemon host: <code>
|
|
1938
|
+
Generate a token on the daemon host: <code>llmux token create</code><br>
|
|
1939
1939
|
The token is sent as a cookie after unlock. Localhost bypasses this gate.
|
|
1940
1940
|
</div>
|
|
1941
1941
|
</div>
|
|
@@ -2538,7 +2538,7 @@ function attachSession(ws, sessionName) {
|
|
|
2538
2538
|
});
|
|
2539
2539
|
}
|
|
2540
2540
|
function printBanner(port) {
|
|
2541
|
-
console.log(`
|
|
2541
|
+
console.log(`llmux v${DAEMON_VERSION}
|
|
2542
2542
|
`);
|
|
2543
2543
|
const addrs = getAddresses(port);
|
|
2544
2544
|
const width = Math.max(10, ...addrs.map((a) => a.label.length + 2));
|
|
@@ -2553,7 +2553,7 @@ function printBanner(port) {
|
|
|
2553
2553
|
} else {
|
|
2554
2554
|
console.log(`
|
|
2555
2555
|
\u26A0 running without auth \u2014 anyone on the network can attach.`);
|
|
2556
|
-
console.log(` create a token with \`
|
|
2556
|
+
console.log(` create a token with \`llmux token create\` to enable auth.
|
|
2557
2557
|
`);
|
|
2558
2558
|
}
|
|
2559
2559
|
}
|
|
@@ -2645,7 +2645,7 @@ function handleStatus(args) {
|
|
|
2645
2645
|
return;
|
|
2646
2646
|
}
|
|
2647
2647
|
if (tracked.length === 0) {
|
|
2648
|
-
console.log("no
|
|
2648
|
+
console.log("no llmux sessions");
|
|
2649
2649
|
return;
|
|
2650
2650
|
}
|
|
2651
2651
|
const rows = tracked.map((s) => [
|
|
@@ -2670,7 +2670,7 @@ function handleSend(args) {
|
|
|
2670
2670
|
const prompt = promptParts.join(" ");
|
|
2671
2671
|
const { session } = resolveTarget(target);
|
|
2672
2672
|
if (!hasSession(session.name)) {
|
|
2673
|
-
throw new Error(`session "${session.name}" is in state but not live in tmux (exited?). Try \`
|
|
2673
|
+
throw new Error(`session "${session.name}" is in state but not live in tmux (exited?). Try \`llmux session restart ${session.name}\`.`);
|
|
2674
2674
|
}
|
|
2675
2675
|
sendKeys(session.name, prompt, { enter: true });
|
|
2676
2676
|
console.log(`sent ${prompt.length} bytes \u2192 ${session.name}`);
|
|
@@ -2704,7 +2704,7 @@ function handleChat(args) {
|
|
|
2704
2704
|
const target = args.positional[0];
|
|
2705
2705
|
if (!target) throw new Error("chat requires <session>");
|
|
2706
2706
|
if (args.flags.browser) {
|
|
2707
|
-
throw new Error("--browser requires
|
|
2707
|
+
throw new Error("--browser requires the web server (`llmux server start`). Without --browser, use `llmux session attach` for raw TTY pass-through.");
|
|
2708
2708
|
}
|
|
2709
2709
|
const { session } = resolveTarget(target);
|
|
2710
2710
|
if (!hasSession(session.name)) {
|
|
@@ -2749,7 +2749,7 @@ function resolveQrEndpoint(selector, port) {
|
|
|
2749
2749
|
}
|
|
2750
2750
|
if (matches.length > 1) {
|
|
2751
2751
|
throw new Error(
|
|
2752
|
-
`--qr-endpoint "${selector}" is ambiguous (${matches.length} matches). Use \`
|
|
2752
|
+
`--qr-endpoint "${selector}" is ambiguous (${matches.length} matches). Use \`llmux token create --qr\` without an endpoint to pick interactively.`
|
|
2753
2753
|
);
|
|
2754
2754
|
}
|
|
2755
2755
|
return matches[0];
|
|
@@ -2823,7 +2823,7 @@ function handleTokenShow(args) {
|
|
|
2823
2823
|
return;
|
|
2824
2824
|
}
|
|
2825
2825
|
if (tokens.length === 0) {
|
|
2826
|
-
console.log("no tokens \u2014 auth is disabled. Create one with `
|
|
2826
|
+
console.log("no tokens \u2014 auth is disabled. Create one with `llmux token create`.");
|
|
2827
2827
|
return;
|
|
2828
2828
|
}
|
|
2829
2829
|
const headers = ["ID", "NAME", "CREATED", "EXPIRES"];
|
|
@@ -2913,7 +2913,7 @@ function help(name, summary, usage) {
|
|
|
2913
2913
|
` ${usage}`,
|
|
2914
2914
|
"",
|
|
2915
2915
|
"Environment:",
|
|
2916
|
-
" LLMUX_SERVER base URL of the
|
|
2916
|
+
" LLMUX_SERVER base URL of the llmux daemon (e.g. http://localhost:3030)",
|
|
2917
2917
|
" LLMUX_TOKEN auth token (sas_\u2026); not required for localhost",
|
|
2918
2918
|
""
|
|
2919
2919
|
].join("\n");
|
|
@@ -2921,7 +2921,7 @@ function help(name, summary, usage) {
|
|
|
2921
2921
|
function resolveContext() {
|
|
2922
2922
|
const baseUrl = process.env.LLMUX_SERVER;
|
|
2923
2923
|
if (!baseUrl) {
|
|
2924
|
-
throw new Error("LLMUX_SERVER is not set. Point it at your
|
|
2924
|
+
throw new Error("LLMUX_SERVER is not set. Point it at your llmux daemon (e.g. http://localhost:3030).");
|
|
2925
2925
|
}
|
|
2926
2926
|
return { baseUrl: baseUrl.replace(/\/$/, ""), token: process.env.LLMUX_TOKEN };
|
|
2927
2927
|
}
|
|
@@ -2940,7 +2940,7 @@ async function request(ctx, method, path, body) {
|
|
|
2940
2940
|
}
|
|
2941
2941
|
if (r.status === 401) {
|
|
2942
2942
|
throw new Error(
|
|
2943
|
-
"unauthorized \u2014 set LLMUX_TOKEN (use `
|
|
2943
|
+
"unauthorized \u2014 set LLMUX_TOKEN (use `llmux token create` on the daemon host to mint one)"
|
|
2944
2944
|
);
|
|
2945
2945
|
}
|
|
2946
2946
|
if (r.status === 404) {
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -95,6 +95,6 @@ export function renderFlagHelp(specs: FlagSpecs): string {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export function notImplemented(commandPath: string): never {
|
|
98
|
-
console.error(`
|
|
98
|
+
console.error(`llmux ${commandPath}: not yet implemented (scaffold)`);
|
|
99
99
|
process.exit(70);
|
|
100
100
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -18,7 +18,7 @@ function help(name: string, summary: string, usage: string): () => string {
|
|
|
18
18
|
` ${usage}`,
|
|
19
19
|
'',
|
|
20
20
|
'Environment:',
|
|
21
|
-
' LLMUX_SERVER base URL of the
|
|
21
|
+
' LLMUX_SERVER base URL of the llmux daemon (e.g. http://localhost:3030)',
|
|
22
22
|
' LLMUX_TOKEN auth token (sas_…); not required for localhost',
|
|
23
23
|
'',
|
|
24
24
|
].join('\n');
|
|
@@ -32,7 +32,7 @@ interface ClientContext {
|
|
|
32
32
|
export function resolveContext(): ClientContext {
|
|
33
33
|
const baseUrl = process.env.LLMUX_SERVER;
|
|
34
34
|
if (!baseUrl) {
|
|
35
|
-
throw new Error('LLMUX_SERVER is not set. Point it at your
|
|
35
|
+
throw new Error('LLMUX_SERVER is not set. Point it at your llmux daemon (e.g. http://localhost:3030).');
|
|
36
36
|
}
|
|
37
37
|
return { baseUrl: baseUrl.replace(/\/$/, ''), token: process.env.LLMUX_TOKEN };
|
|
38
38
|
}
|
|
@@ -62,7 +62,7 @@ async function request<T = unknown>(
|
|
|
62
62
|
}
|
|
63
63
|
if (r.status === 401) {
|
|
64
64
|
throw new Error(
|
|
65
|
-
'unauthorized — set LLMUX_TOKEN (use `
|
|
65
|
+
'unauthorized — set LLMUX_TOKEN (use `llmux token create` on the daemon host to mint one)',
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
if (r.status === 404) {
|
package/src/daemon/handlers.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function handleStatus(args: ParsedArgs): void {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
if (tracked.length === 0) {
|
|
122
|
-
console.log('no
|
|
122
|
+
console.log('no llmux sessions');
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -146,7 +146,7 @@ export function handleSend(args: ParsedArgs): void {
|
|
|
146
146
|
const prompt = promptParts.join(' ');
|
|
147
147
|
const { session } = resolveTarget(target);
|
|
148
148
|
if (!tmux.hasSession(session.name)) {
|
|
149
|
-
throw new Error(`session "${session.name}" is in state but not live in tmux (exited?). Try \`
|
|
149
|
+
throw new Error(`session "${session.name}" is in state but not live in tmux (exited?). Try \`llmux session restart ${session.name}\`.`);
|
|
150
150
|
}
|
|
151
151
|
tmux.sendKeys(session.name, prompt, { enter: true });
|
|
152
152
|
console.log(`sent ${prompt.length} bytes → ${session.name}`);
|
|
@@ -182,7 +182,7 @@ export function handleChat(args: ParsedArgs): void {
|
|
|
182
182
|
const target = args.positional[0];
|
|
183
183
|
if (!target) throw new Error('chat requires <session>');
|
|
184
184
|
if (args.flags.browser) {
|
|
185
|
-
throw new Error('--browser requires
|
|
185
|
+
throw new Error('--browser requires the web server (`llmux server start`). Without --browser, use `llmux session attach` for raw TTY pass-through.');
|
|
186
186
|
}
|
|
187
187
|
const { session } = resolveTarget(target);
|
|
188
188
|
if (!tmux.hasSession(session.name)) {
|
|
@@ -236,7 +236,7 @@ function resolveQrEndpoint(selector: string, port: number): { label: string; url
|
|
|
236
236
|
}
|
|
237
237
|
if (matches.length > 1) {
|
|
238
238
|
throw new Error(
|
|
239
|
-
`--qr-endpoint "${selector}" is ambiguous (${matches.length} matches). Use \`
|
|
239
|
+
`--qr-endpoint "${selector}" is ambiguous (${matches.length} matches). Use \`llmux token create --qr\` without an endpoint to pick interactively.`,
|
|
240
240
|
);
|
|
241
241
|
}
|
|
242
242
|
return matches[0]!;
|
|
@@ -320,7 +320,7 @@ export function handleTokenShow(args: ParsedArgs): void {
|
|
|
320
320
|
return;
|
|
321
321
|
}
|
|
322
322
|
if (tokens.length === 0) {
|
|
323
|
-
console.log('no tokens — auth is disabled. Create one with `
|
|
323
|
+
console.log('no tokens — auth is disabled. Create one with `llmux token create`.');
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
326
|
const headers = ['ID', 'NAME', 'CREATED', 'EXPIRES'];
|
package/src/daemon/web/server.ts
CHANGED
|
@@ -314,7 +314,7 @@ function pickerPage(): string {
|
|
|
314
314
|
</div>
|
|
315
315
|
</div>
|
|
316
316
|
<footer>
|
|
317
|
-
<span>
|
|
317
|
+
<span>llmux v${escapeHtml(DAEMON_VERSION)}</span>
|
|
318
318
|
${authStore.authEnabled()
|
|
319
319
|
? `<span class="ok">✓ auth required — ${authStore.listAuthTokens().length} active token${authStore.listAuthTokens().length === 1 ? '' : 's'}</span>`
|
|
320
320
|
: `<span class="warn">⚠ no auth — anyone on the network can attach</span>`}
|
|
@@ -373,7 +373,7 @@ function pickerPage(): string {
|
|
|
373
373
|
|
|
374
374
|
function render(sessions){
|
|
375
375
|
if (!sessions || sessions.length === 0){
|
|
376
|
-
container.innerHTML = '<div class="empty">no sessions yet — spawn one from the CLI:<br><br><code>
|
|
376
|
+
container.innerHTML = '<div class="empty">no sessions yet — spawn one from the CLI:<br><br><code>llmux session start claude --name <em>name</em></code></div>';
|
|
377
377
|
return;
|
|
378
378
|
}
|
|
379
379
|
const rows = sessions.map(rowHtml).join('');
|
|
@@ -851,7 +851,7 @@ function pickerPage(): string {
|
|
|
851
851
|
|
|
852
852
|
function renderSessionTable(sessions: SessionView[]): string {
|
|
853
853
|
if (sessions.length === 0) {
|
|
854
|
-
return `<div class="empty">no sessions yet — spawn one from the CLI:<br><br><code>
|
|
854
|
+
return `<div class="empty">no sessions yet — spawn one from the CLI:<br><br><code>llmux session start claude --name <em>name</em></code></div>`;
|
|
855
855
|
}
|
|
856
856
|
const rows = sessions
|
|
857
857
|
.map((s) => {
|
|
@@ -956,7 +956,7 @@ function sessionPage(name: string): string {
|
|
|
956
956
|
const jsonVersion = JSON.stringify(DAEMON_VERSION);
|
|
957
957
|
return `<!doctype html><html lang="en"><head>
|
|
958
958
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
|
|
959
|
-
<title>${escapedName} —
|
|
959
|
+
<title>${escapedName} — llmux</title>
|
|
960
960
|
<link rel="icon" href="${FAVICON_DATA_URL}">
|
|
961
961
|
<link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
|
|
962
962
|
<link rel="stylesheet" href="${XTERM_CSS}">
|
|
@@ -1481,10 +1481,10 @@ function isWsAuthorized(req: IncomingMessage, urlSearch: URLSearchParams): boole
|
|
|
1481
1481
|
|
|
1482
1482
|
function gatePage(reason: 'missing' | 'invalid'): string {
|
|
1483
1483
|
const message =
|
|
1484
|
-
reason === 'invalid' ? 'Token rejected. Try again.' : 'This
|
|
1484
|
+
reason === 'invalid' ? 'Token rejected. Try again.' : 'This llmux daemon requires a token.';
|
|
1485
1485
|
return `<!doctype html><html lang="en"><head>
|
|
1486
1486
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1487
|
-
<title>
|
|
1487
|
+
<title>llmux — auth</title>
|
|
1488
1488
|
<link rel="icon" href="${FAVICON_DATA_URL}">
|
|
1489
1489
|
<style>
|
|
1490
1490
|
:root{color-scheme:dark}
|
|
@@ -1515,7 +1515,7 @@ function gatePage(reason: 'missing' | 'invalid'): string {
|
|
|
1515
1515
|
<div class="msg" id="msg"></div>
|
|
1516
1516
|
</form>
|
|
1517
1517
|
<div class="hint">
|
|
1518
|
-
Generate a token on the daemon host: <code>
|
|
1518
|
+
Generate a token on the daemon host: <code>llmux token create</code><br>
|
|
1519
1519
|
The token is sent as a cookie after unlock. Localhost bypasses this gate.
|
|
1520
1520
|
</div>
|
|
1521
1521
|
</div>
|
|
@@ -2261,7 +2261,7 @@ function attachSession(ws: WebSocket, sessionName: string): void {
|
|
|
2261
2261
|
}
|
|
2262
2262
|
|
|
2263
2263
|
export function printBanner(port: number): void {
|
|
2264
|
-
console.log(`
|
|
2264
|
+
console.log(`llmux v${DAEMON_VERSION}\n`);
|
|
2265
2265
|
const addrs = getAddresses(port);
|
|
2266
2266
|
const width = Math.max(10, ...addrs.map((a) => a.label.length + 2));
|
|
2267
2267
|
for (const addr of addrs) {
|
|
@@ -2272,6 +2272,6 @@ export function printBanner(port: number): void {
|
|
|
2272
2272
|
console.log(`\n ✓ auth required — ${count} active token${count === 1 ? '' : 's'} (localhost bypasses)\n`);
|
|
2273
2273
|
} else {
|
|
2274
2274
|
console.log(`\n ⚠ running without auth — anyone on the network can attach.`);
|
|
2275
|
-
console.log(` create a token with \`
|
|
2275
|
+
console.log(` create a token with \`llmux token create\` to enable auth.\n`);
|
|
2276
2276
|
}
|
|
2277
2277
|
}
|