@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.
@@ -0,0 +1,2277 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { WebSocketServer, type WebSocket } from 'ws';
6
+ import * as pty from 'node-pty';
7
+ import type { IPty } from 'node-pty';
8
+ import { DEFAULT_AGENTS, isAgentInstalled, type AgentDefinition, type Conversation } from '../agents.ts';
9
+ import * as state from '../state.ts';
10
+ import * as tmux from '../tmux.ts';
11
+ import * as authStore from '../auth-store.ts';
12
+ import { getAddresses } from '../net.ts';
13
+
14
+ function readDaemonVersion(): string {
15
+ // Resolve package.json relative to this source file so the version stays
16
+ // accurate whether running from src/ (bun) or dist/ (npm install -g).
17
+ try {
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ for (const candidate of [
20
+ resolve(here, '../../package.json'),
21
+ resolve(here, '../package.json'),
22
+ resolve(here, './package.json'),
23
+ ]){
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
26
+ if (pkg.name === '@cordfuse/llmux' && typeof pkg.version === 'string') return pkg.version;
27
+ } catch {}
28
+ }
29
+ } catch {}
30
+ return 'unknown';
31
+ }
32
+
33
+ const DAEMON_VERSION = readDaemonVersion();
34
+
35
+ export interface ServeOptions {
36
+ port: number;
37
+ host: string;
38
+ }
39
+
40
+ const XTERM_CSS = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css';
41
+ const XTERM_JS = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js';
42
+ const XTERM_FIT_JS = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js';
43
+
44
+ function escapeHtml(s: string): string {
45
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
46
+ }
47
+
48
+ interface SessionView {
49
+ name: string;
50
+ agent: string;
51
+ cwd: string;
52
+ cwdDisplay: string;
53
+ /** The launch-flags override stored on this session (undefined = inherits agent default). */
54
+ flags?: string;
55
+ /** The agent's default flags — included so the UI can prefill the edit form. */
56
+ defaultFlags: string;
57
+ /** Per-session env override stored on this session (undefined = inherits agent defaults). */
58
+ env?: Record<string, string>;
59
+ /** Agent's default env vars — UI prefills the edit form from this when no override. */
60
+ defaultEnv: Record<string, string>;
61
+ /** Conversation id this session is currently resumed from (if any). */
62
+ resumeFrom?: string;
63
+ /** Whether the agent has a history adapter — UI shows the conversations icon. */
64
+ hasHistory: boolean;
65
+ /** Count of prior conversations for this agent+cwd (0 if no adapter or empty history). */
66
+ conversationCount: number;
67
+ createdAt: string;
68
+ parent: string | null;
69
+ status: 'running' | 'exited';
70
+ }
71
+
72
+ function shortenCwd(cwd: string): string {
73
+ const home = process.env.HOME;
74
+ if (!home) return cwd;
75
+ if (cwd === home) return '~';
76
+ if (cwd.startsWith(home + '/')) return '~' + cwd.slice(home.length);
77
+ return cwd;
78
+ }
79
+
80
+ /** Expand a leading `~` (or `~/`) to $HOME on the daemon host. No-op for absolute paths. */
81
+ function expandTilde(p: string): string {
82
+ const home = process.env.HOME;
83
+ if (!home) return p;
84
+ if (p === '~') return home;
85
+ if (p.startsWith('~/')) return home + p.slice(1);
86
+ return p;
87
+ }
88
+
89
+ function listSessionViews(): SessionView[] {
90
+ const tracked = state.list();
91
+ const live = new Set(tmux.listSessions().map((s) => s.name));
92
+ return tracked
93
+ .map((s) => viewOf(s, live.has(s.name)))
94
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
95
+ }
96
+
97
+ const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0b0c10"/><rect x="5" y="5" width="9" height="9" fill="#7cc4ff"/><rect x="18" y="5" width="9" height="9" fill="#7cc4ff"/><rect x="5" y="18" width="9" height="9" fill="#7cc4ff"/><rect x="18" y="18" width="9" height="9" fill="#7cc4ff"/></svg>`;
98
+ const FAVICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}`;
99
+
100
+ // ---------- pages ----------
101
+
102
+ function pickerPage(): string {
103
+ const sessions = listSessionViews();
104
+ return `<!doctype html><html lang="en"><head>
105
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
106
+ <title>LLMUX: Sessions</title>
107
+ <link rel="icon" href="${FAVICON_DATA_URL}">
108
+ <link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
109
+ <style>
110
+ :root{color-scheme:dark}
111
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px;overflow-x:hidden}
112
+ body{padding:18px 16px 80px;max-width:980px;margin:0 auto;box-sizing:border-box}
113
+ header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
114
+ h1{font-size:18px;margin:0}
115
+ h1 .brand{color:#7cc4ff;letter-spacing:.08em;font-weight:600}
116
+ #meta{color:#7a7f87;font-size:11px;display:flex;gap:10px;align-items:center}
117
+ #refresh-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#7ee787;transition:background .25s;box-shadow:0 0 6px #7ee78766}
118
+ #refresh-dot.stale{background:#9aa0a6;box-shadow:none}
119
+ #refresh-dot.error{background:#f85149;box-shadow:0 0 6px #f8514966}
120
+ table{border-collapse:collapse;width:100%}
121
+ thead{display:table-header-group}
122
+ th,td{text-align:left;padding:9px 10px;border-bottom:1px solid #1f2329;vertical-align:middle}
123
+ th{font-weight:500;color:#9aa0a6;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
124
+ a.session-link{color:#7cc4ff;text-decoration:none}
125
+ a.session-link:hover{text-decoration:underline}
126
+ .name{font-weight:600}
127
+ .started{color:#7a7f87;font-size:11px;margin-top:2px;display:block}
128
+ .state-running{color:#7ee787}
129
+ .state-exited{color:#7a7f87}
130
+ .cwd{color:#c9d1d9;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
131
+ .cwd code{unicode-bidi:embed;direction:ltr}
132
+ .cwd-col{max-width:0}
133
+ .actions{text-align:right;white-space:nowrap}
134
+ .actions button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:5px 9px;font:12px ui-monospace,monospace;cursor:pointer;margin-left:4px;display:inline-flex;align-items:center;gap:4px;transition:background 150ms ease,border-color 150ms ease,transform 100ms ease}
135
+ .actions button:hover{background:#252b34;border-color:#3a414b}
136
+ .actions button:active{transform:scale(.94)}
137
+ .actions button.respawn{color:#7cc4ff;border-color:#2d4a66}
138
+ .actions button.edit{color:#d29922;border-color:#574122}
139
+ .actions button.kill{color:#f85149;border-color:#4a2329}
140
+ .actions button.resume-btn{color:#a371f7;border-color:#3c2a59}
141
+ .actions button .icon{font-size:13px;line-height:1;display:inline-block;vertical-align:middle}
142
+ .actions button:disabled{opacity:.5;cursor:wait}
143
+ .empty{color:#7a7f87;padding:18px;text-align:center;border:1px dashed #1f2329;border-radius:8px}
144
+ .empty code{color:#c9d1d9;background:#11141a;padding:2px 6px;border-radius:4px}
145
+ tbody tr{transition:background 150ms ease}
146
+ tbody tr:hover{background:#0e1116}
147
+ #new-btn{background:#1c2128;color:#7cc4ff;border:1px solid #2d4a66;border-radius:6px;padding:6px 10px;font:12px ui-monospace,monospace;cursor:pointer}
148
+ #new-btn:hover{background:#252b34}
149
+ #new-form{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:14px;margin-bottom:14px;max-height:0;opacity:0;overflow:hidden;padding-top:0;padding-bottom:0;border-width:0;margin-bottom:0;transition:max-height 220ms ease,opacity 180ms ease,padding-top 220ms ease,padding-bottom 220ms ease,border-width 220ms ease,margin-bottom 220ms ease}
150
+ #new-form.open{max-height:900px;opacity:1;padding:14px;border-width:1px;margin-bottom:14px}
151
+ #new-form .form-title{margin:0 0 12px;font-size:13px;color:#c9d1d9;font-weight:600}
152
+ #new-form select:disabled{opacity:.6;cursor:not-allowed}
153
+ #new-form .field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
154
+ #new-form label{font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em}
155
+ #new-form select,#new-form input,#new-form textarea{background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 10px;font:13px ui-monospace,monospace;outline:none;width:100%;box-sizing:border-box;resize:vertical}
156
+ #new-form select:focus,#new-form input:focus,#new-form textarea:focus{border-color:#2d4a66}
157
+ #new-form .actions{display:flex;gap:8px;justify-content:flex-end}
158
+ #new-form button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:12px ui-monospace,monospace;cursor:pointer}
159
+ #new-form button.primary{color:#7cc4ff;border-color:#2d4a66}
160
+ #new-form button:hover{background:#252b34}
161
+ #new-form button:disabled{opacity:.5;cursor:wait}
162
+ #new-form .hint{font-size:11px;color:#7a7f87;margin-top:-4px;margin-bottom:10px}
163
+ footer{position:fixed;bottom:0;left:0;right:0;background:#0b0c10;border-top:1px solid #1f2329;padding:10px 16px;font-size:11px;color:#7a7f87;display:flex;justify-content:space-between;gap:10px}
164
+ footer .warn{color:#d29922}
165
+ footer .ok{color:#7ee787}
166
+ #toast{position:fixed;bottom:50px;left:50%;transform:translateX(-50%);background:#11141a;border:1px solid #1f2329;color:#e6e8eb;padding:8px 14px;border-radius:6px;font-size:12px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:30}
167
+ #toast.show{opacity:1}
168
+ #toast.error{border-color:#4a2329;color:#f85149}
169
+ #confirm-modal{position:fixed;inset:0;background:rgba(11,12,16,.85);display:flex;align-items:center;justify-content:center;z-index:60;padding:20px;opacity:0;visibility:hidden;transition:opacity 160ms ease,visibility 0s 160ms}
170
+ #confirm-modal.open{opacity:1;visibility:visible;transition:opacity 160ms ease}
171
+ #confirm-modal .panel{transform:translateY(8px) scale(.97);transition:transform 200ms ease}
172
+ #confirm-modal.open .panel{transform:translateY(0) scale(1)}
173
+ #confirm-modal .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:20px;max-width:360px;width:100%}
174
+ #confirm-modal h3{margin:0 0 8px;font-size:15px;color:#e6e8eb}
175
+ #confirm-modal p{margin:0 0 16px;font-size:13px;color:#c9d1d9;line-height:1.5}
176
+ #confirm-modal p code{color:#7cc4ff;background:#0b0c10;padding:2px 5px;border-radius:3px}
177
+ #confirm-modal .actions{display:flex;gap:8px;justify-content:flex-end}
178
+ #confirm-modal button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer}
179
+ #confirm-modal button.danger{color:#f85149;border-color:#4a2329}
180
+ #confirm-modal button.danger:hover{background:#2a1c1f}
181
+ #confirm-modal button:disabled{opacity:.5;cursor:wait}
182
+ .help-btn{background:#1c2128;color:#7cc4ff;border:1px solid #2d4a66;border-radius:50%;width:18px;height:18px;font:11px ui-monospace,monospace;cursor:pointer;padding:0;margin-left:4px;display:inline-flex;align-items:center;justify-content:center;vertical-align:middle}
183
+ .help-btn:hover{background:#252b34}
184
+ #agents-modal{position:fixed;inset:0;background:rgba(11,12,16,.85);display:flex;align-items:center;justify-content:center;z-index:40;padding:20px;opacity:0;visibility:hidden;transition:opacity 160ms ease,visibility 0s 160ms}
185
+ #agents-modal.open{opacity:1;visibility:visible;transition:opacity 160ms ease}
186
+ #agents-modal .panel{transform:translateY(8px) scale(.97);transition:transform 200ms ease}
187
+ #agents-modal.open .panel{transform:translateY(0) scale(1)}
188
+ #agents-modal .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:18px;max-width:520px;width:100%;max-height:80vh;display:flex;flex-direction:column}
189
+ #agents-modal h3{margin:0 0 4px;font-size:15px;color:#e6e8eb}
190
+ #agents-modal .sub{margin:0 0 14px;font-size:11px;color:#7a7f87}
191
+ #agents-list{flex:1 1 auto;overflow-y:auto;margin-bottom:12px;min-height:0}
192
+ #agents-list .agent{padding:10px 0;border-bottom:1px solid #1f2329}
193
+ #agents-list .agent:last-child{border-bottom:none}
194
+ #agents-list .agent-head{display:flex;align-items:center;gap:8px;margin-bottom:4px}
195
+ #agents-list .agent-name{font-weight:600;color:#e6e8eb;font-size:13px}
196
+ #agents-list .agent-status{font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid}
197
+ #agents-list .agent-status.ok{color:#7ee787;border-color:#235828;background:#0d1f10}
198
+ #agents-list .agent-status.miss{color:#7a7f87;border-color:#262c34;background:#0e1116}
199
+ #agents-list .agent-install{font:11px ui-monospace,monospace;color:#c9d1d9;background:#0b0c10;border:1px solid #1f2329;border-radius:4px;padding:6px 8px;margin-top:4px;word-break:break-all}
200
+ #agents-list .agent-docs{font-size:11px;color:#7cc4ff;text-decoration:none;margin-top:4px;display:inline-block}
201
+ #agents-list .agent-docs:hover{text-decoration:underline}
202
+ #agents-modal .actions{display:flex;justify-content:flex-end}
203
+ #agents-modal button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer}
204
+ #agents-modal button:hover{background:#252b34}
205
+ #convs-modal{position:fixed;inset:0;background:rgba(11,12,16,.85);display:flex;align-items:center;justify-content:center;z-index:40;padding:20px;opacity:0;visibility:hidden;transition:opacity 160ms ease,visibility 0s 160ms}
206
+ #convs-modal.open{opacity:1;visibility:visible;transition:opacity 160ms ease}
207
+ #convs-modal .panel{transform:translateY(8px) scale(.97);transition:transform 200ms ease}
208
+ #convs-modal.open .panel{transform:translateY(0) scale(1)}
209
+ #convs-list .conv{transition:background 120ms ease}
210
+ #convs-modal .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:18px;max-width:560px;width:100%;max-height:80vh;display:flex;flex-direction:column}
211
+ #convs-modal h3{margin:0 0 4px;font-size:15px;color:#e6e8eb}
212
+ #convs-modal .sub{margin:0 0 14px;font-size:11px;color:#7a7f87}
213
+ #convs-list{flex:1 1 auto;overflow-y:auto;margin-bottom:12px;min-height:0}
214
+ #convs-list .conv{padding:10px 0;border-bottom:1px solid #1f2329;cursor:pointer;display:block;width:100%;text-align:left;background:transparent;border-left:none;border-right:none;border-top:none;color:inherit;font-family:inherit;font-size:inherit}
215
+ #convs-list .conv:last-child{border-bottom:none}
216
+ #convs-list .conv:hover{background:#1a1d23}
217
+ #convs-list .conv-title{font-size:13px;color:#e6e8eb;font-weight:500;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
218
+ #convs-list .conv-meta{font-size:11px;color:#7a7f87;margin-top:2px;display:flex;gap:8px}
219
+ #convs-list .conv-meta .when{color:#9aa0a6}
220
+ #convs-list .conv-meta .count{color:#7a7f87}
221
+ #convs-list .conv-current{color:#a371f7;font-weight:600}
222
+ #convs-modal .actions{display:flex;justify-content:flex-end}
223
+ #convs-modal button.close-btn{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:13px ui-monospace,monospace;cursor:pointer}
224
+ #convs-modal button.close-btn:hover{background:#252b34}
225
+ /* Mobile: hide cwd column, show under name */
226
+ @media (max-width: 600px){
227
+ body{padding:14px 8px 72px}
228
+ th.cwd-col,td.cwd-col{display:none}
229
+ .name-block .cwd{display:block;margin-top:3px;max-width:100%}
230
+ th,td{padding:8px 4px;font-size:13px}
231
+ .name-block{max-width:42vw}
232
+ td.actions{white-space:nowrap;text-align:right;padding-right:0}
233
+ /* Buttons collapse to icon-only — long-press surfaces title= for label. */
234
+ .actions button .label{display:none}
235
+ .actions button{padding:5px 6px;min-width:28px;justify-content:center;margin-left:2px}
236
+ }
237
+ @media (min-width: 601px){
238
+ .name-block .cwd{display:none}
239
+ }
240
+ </style></head>
241
+ <body>
242
+ <header>
243
+ <h1><span class="brand">LLMUX</span>: Sessions</h1>
244
+ <div id="meta">
245
+ <button id="new-btn" type="button">+ new session</button>
246
+ <span id="refresh-dot" title="updates every 3s"></span>
247
+ <span id="refresh-label">live</span>
248
+ <span>·</span>
249
+ <span>v${escapeHtml(DAEMON_VERSION)}</span>
250
+ </div>
251
+ </header>
252
+ <div id="new-form" aria-hidden="true">
253
+ <h3 id="new-title" class="form-title">new session</h3>
254
+ <form id="new-session-form">
255
+ <div class="field">
256
+ <label for="new-agent">agent <button type="button" id="agent-help-btn" class="help-btn" title="Show all supported agents">?</button></label>
257
+ <select id="new-agent" required></select>
258
+ </div>
259
+ <div class="field">
260
+ <label for="new-name">name</label>
261
+ <input id="new-name" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="(defaults to agent key)" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]*">
262
+ </div>
263
+ <div class="field">
264
+ <label for="new-cwd">cwd</label>
265
+ <input id="new-cwd" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="(defaults to \$HOME on the daemon host)">
266
+ </div>
267
+ <div id="new-cwd-hint" class="hint" hidden>cwd changes apply immediately — if the session is running, it'll be killed and respawned in the new directory</div>
268
+ <div class="field">
269
+ <label for="new-flags">flags</label>
270
+ <input id="new-flags" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
271
+ </div>
272
+ <div id="new-flags-hint" class="hint" hidden></div>
273
+ <div class="field">
274
+ <label for="new-env">env vars</label>
275
+ <textarea id="new-env" rows="3" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="KEY=VALUE one per line"></textarea>
276
+ </div>
277
+ <div id="new-env-hint" class="hint" hidden></div>
278
+ <div class="actions">
279
+ <button type="button" id="new-cancel">cancel</button>
280
+ <button type="submit" class="primary" id="new-submit">spawn</button>
281
+ </div>
282
+ </form>
283
+ </div>
284
+ <div id="list-container">${renderSessionTable(sessions)}</div>
285
+ <div id="toast"></div>
286
+ <div id="confirm-modal" aria-hidden="true">
287
+ <div class="panel">
288
+ <h3 id="confirm-title">Kill session?</h3>
289
+ <p id="confirm-body"></p>
290
+ <div class="actions">
291
+ <button type="button" id="confirm-cancel">cancel</button>
292
+ <button type="button" class="danger" id="confirm-ok">kill</button>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ <div id="agents-modal" aria-hidden="true">
297
+ <div class="panel">
298
+ <h3>Supported agents</h3>
299
+ <p class="sub">Only installed agents appear in the spawn dropdown. Install the others on the daemon host to enable them.</p>
300
+ <div id="agents-list">loading…</div>
301
+ <div class="actions">
302
+ <button type="button" id="agents-close">close</button>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ <div id="convs-modal" aria-hidden="true">
307
+ <div class="panel">
308
+ <h3 id="convs-title">Past conversations</h3>
309
+ <p class="sub" id="convs-sub">Pick one to resume. The current session will be killed and respawned with the agent's resume flag.</p>
310
+ <div id="convs-list">loading…</div>
311
+ <div class="actions">
312
+ <button type="button" id="convs-close">cancel</button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ <footer>
317
+ <span>llmuxd v${escapeHtml(DAEMON_VERSION)}</span>
318
+ ${authStore.authEnabled()
319
+ ? `<span class="ok">✓ auth required — ${authStore.listAuthTokens().length} active token${authStore.listAuthTokens().length === 1 ? '' : 's'}</span>`
320
+ : `<span class="warn">⚠ no auth — anyone on the network can attach</span>`}
321
+ </footer>
322
+ <script>
323
+ (function(){
324
+ const container = document.getElementById('list-container');
325
+ const dot = document.getElementById('refresh-dot');
326
+ const label = document.getElementById('refresh-label');
327
+ const toast = document.getElementById('toast');
328
+ let pollTimer = null;
329
+ let lastFetch = 0;
330
+
331
+ function showToast(msg, isError){
332
+ toast.textContent = msg;
333
+ toast.classList.toggle('error', !!isError);
334
+ toast.classList.add('show');
335
+ setTimeout(function(){ toast.classList.remove('show'); }, 2200);
336
+ }
337
+
338
+ function escapeHtml(s){
339
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
340
+ }
341
+
342
+ function relativeTime(iso){
343
+ const ms = Date.now() - new Date(iso).getTime();
344
+ if (isNaN(ms) || ms < 0) return '';
345
+ if (ms < 60000) return 'just now';
346
+ const m = Math.floor(ms/60000);
347
+ if (m < 60) return m + 'm ago';
348
+ const h = Math.floor(m/60);
349
+ if (h < 24) return h + 'h ago';
350
+ const d = Math.floor(h/24);
351
+ return d + 'd ago';
352
+ }
353
+ function rowHtml(s){
354
+ const cls = 'state-' + s.status;
355
+ const linkOpen = s.status === 'running' ? '<a class="session-link" href="/session/' + encodeURIComponent(s.name) + '">' : '<a class="session-link" href="/session/' + encodeURIComponent(s.name) + '" title="session is not running — click to respawn">';
356
+ const respawnText = s.status === 'running' ? 'restart' : 'respawn';
357
+ const respawnTitle = s.status === 'running' ? 'kill + relaunch with the persisted config (use after edit)' : 'launch the agent again with the persisted config';
358
+ const respawnBtn = '<button class="respawn" data-action="respawn" data-name="' + escapeHtml(s.name) + '" title="' + respawnTitle + '" aria-label="' + respawnText + '"><span class="icon">↻</span><span class="label">' + respawnText + '</span></button>';
359
+ const editBtn = '<button class="edit" data-action="edit" data-name="' + escapeHtml(s.name) + '" data-cwd="' + escapeHtml(s.cwd) + '" data-agent="' + escapeHtml(s.agent) + '" data-flags="' + escapeHtml(s.flags || '') + '" data-env="' + escapeHtml(JSON.stringify(s.env || {})) + '" title="edit name, cwd, flags, or env" aria-label="edit"><span class="icon">✎</span><span class="label">edit</span></button>';
360
+ const resumeBtn = (s.hasHistory && s.conversationCount > 0)
361
+ ? '<button class="resume-btn" data-action="resume" data-name="' + escapeHtml(s.name) + '" title="resume a past conversation for this agent + cwd" aria-label="resume"><span class="icon">☰</span><span class="label">' + s.conversationCount + '</span></button>'
362
+ : '';
363
+ const when = relativeTime(s.createdAt);
364
+ const cwdShort = s.cwdDisplay || s.cwd;
365
+ return '<tr data-name="' + escapeHtml(s.name) + '">' +
366
+ '<td class="name-block"><span class="name">' + linkOpen + escapeHtml(s.name) + '</a></span>' + (when ? '<span class="started">started ' + when + '</span>' : '') + '<span class="cwd" title="' + escapeHtml(s.cwd) + '"><code>' + escapeHtml(cwdShort) + '</code></span></td>' +
367
+ '<td>' + escapeHtml(s.agent) + '</td>' +
368
+ '<td class="' + cls + '">' + s.status + '</td>' +
369
+ '<td class="cwd cwd-col" title="' + escapeHtml(s.cwd) + '"><code>' + escapeHtml(cwdShort) + '</code></td>' +
370
+ '<td class="actions">' + resumeBtn + respawnBtn + editBtn + '<button class="kill" data-action="kill" data-name="' + escapeHtml(s.name) + '" data-status="' + s.status + '" title="' + (s.status === 'running' ? 'kill the tmux session + remove the record' : 'remove the record') + '" aria-label="' + (s.status === 'running' ? 'kill' : 'remove') + '"><span class="icon">✕</span><span class="label">' + (s.status === 'running' ? 'kill' : 'remove') + '</span></button></td>' +
371
+ '</tr>';
372
+ }
373
+
374
+ function render(sessions){
375
+ if (!sessions || sessions.length === 0){
376
+ container.innerHTML = '<div class="empty">no sessions yet — spawn one from the CLI:<br><br><code>llmuxd spawn claude --name <em>name</em></code></div>';
377
+ return;
378
+ }
379
+ const rows = sessions.map(rowHtml).join('');
380
+ container.innerHTML = '<table><thead><tr><th>name</th><th>agent</th><th>state</th><th class="cwd-col">cwd</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
381
+ }
382
+
383
+ async function poll(){
384
+ if (document.hidden) return;
385
+ try {
386
+ const r = await fetch('/api/sessions', { cache: 'no-store' });
387
+ if (!r.ok) throw new Error('http ' + r.status);
388
+ const data = await r.json();
389
+ render(data);
390
+ dot.classList.remove('stale','error');
391
+ label.textContent = 'live';
392
+ lastFetch = Date.now();
393
+ } catch(e){
394
+ dot.classList.add('error');
395
+ dot.classList.remove('stale');
396
+ label.textContent = 'offline';
397
+ }
398
+ }
399
+
400
+ function staleCheck(){
401
+ if (lastFetch && Date.now() - lastFetch > 8000 && !dot.classList.contains('error')){
402
+ dot.classList.add('stale');
403
+ label.textContent = 'stale';
404
+ }
405
+ }
406
+
407
+ async function action(name, kind, btn){
408
+ btn.disabled = true;
409
+ const original = btn.textContent;
410
+ btn.textContent = kind === 'respawn' ? '…' : '…';
411
+ try {
412
+ const r = await fetch('/api/sessions/' + encodeURIComponent(name) + '/' + kind, { method: 'POST' });
413
+ const body = await r.json().catch(function(){ return {}; });
414
+ if (!r.ok || body.ok === false) throw new Error(body.error || 'request failed');
415
+ showToast(kind === 'respawn' ? 'respawned ' + name : (body.status === 'running' ? 'killed ' + name : 'removed ' + name));
416
+ poll();
417
+ } catch(e){
418
+ showToast(kind + ' failed: ' + (e.message || e), true);
419
+ btn.disabled = false;
420
+ btn.textContent = original;
421
+ }
422
+ }
423
+
424
+ // ---- Agents help modal ----
425
+ const agentsModal = document.getElementById('agents-modal');
426
+ const agentsList = document.getElementById('agents-list');
427
+ const agentsClose = document.getElementById('agents-close');
428
+ const agentHelpBtn = document.getElementById('agent-help-btn');
429
+ let agentsAllLoaded = false;
430
+
431
+ async function loadAgentsAll(){
432
+ if (agentsAllLoaded) return;
433
+ try {
434
+ const r = await fetch('/api/agents/all', { cache: 'no-store' });
435
+ if (!r.ok) throw new Error('http ' + r.status);
436
+ const list = await r.json();
437
+ agentsList.innerHTML = list.map(function(a){
438
+ const status = a.installed
439
+ ? '<span class="agent-status ok">installed</span>'
440
+ : '<span class="agent-status miss">not installed</span>';
441
+ const install = a.installHint
442
+ ? '<div class="agent-install">' + escapeHtml(a.installHint) + '</div>'
443
+ : '';
444
+ const docs = a.docsUrl
445
+ ? '<a class="agent-docs" href="' + escapeHtml(a.docsUrl) + '" target="_blank" rel="noopener">docs ↗</a>'
446
+ : '';
447
+ return '<div class="agent">' +
448
+ '<div class="agent-head"><span class="agent-name">' + escapeHtml(a.displayName) + '</span>' + status + '</div>' +
449
+ install + docs +
450
+ '</div>';
451
+ }).join('');
452
+ agentsAllLoaded = true;
453
+ } catch(e){
454
+ agentsList.innerHTML = '<div class="agent">failed to load agents: ' + escapeHtml(e.message || String(e)) + '</div>';
455
+ }
456
+ }
457
+
458
+ agentHelpBtn.addEventListener('click', async function(e){
459
+ e.preventDefault();
460
+ e.stopPropagation();
461
+ agentsModal.classList.add('open');
462
+ agentsModal.setAttribute('aria-hidden', 'false');
463
+ await loadAgentsAll();
464
+ });
465
+ agentsClose.addEventListener('click', function(){
466
+ agentsModal.classList.remove('open');
467
+ agentsModal.setAttribute('aria-hidden', 'true');
468
+ });
469
+ agentsModal.addEventListener('click', function(e){
470
+ if (e.target === agentsModal){
471
+ agentsModal.classList.remove('open');
472
+ agentsModal.setAttribute('aria-hidden', 'true');
473
+ }
474
+ });
475
+
476
+ // ---- Conversations modal ----
477
+ const convsModal = document.getElementById('convs-modal');
478
+ const convsTitle = document.getElementById('convs-title');
479
+ const convsList = document.getElementById('convs-list');
480
+ const convsClose = document.getElementById('convs-close');
481
+ let convsForSession = null;
482
+ let convsCurrentResumeFrom = null;
483
+
484
+ function relTime(iso){
485
+ const ms = Date.now() - new Date(iso).getTime();
486
+ if (isNaN(ms) || ms < 0) return iso;
487
+ if (ms < 60000) return 'just now';
488
+ const m = Math.floor(ms/60000);
489
+ if (m < 60) return m + 'm ago';
490
+ const h = Math.floor(m/60);
491
+ if (h < 24) return h + 'h ago';
492
+ const d = Math.floor(h/24);
493
+ return d + 'd ago';
494
+ }
495
+
496
+ async function openConvsModal(sessionName){
497
+ convsForSession = sessionName;
498
+ convsTitle.textContent = 'Past conversations · ' + sessionName;
499
+ convsList.innerHTML = 'loading…';
500
+ convsModal.classList.add('open');
501
+ convsModal.setAttribute('aria-hidden', 'false');
502
+ // Track this row's current resumeFrom so we can flag the active conversation
503
+ convsCurrentResumeFrom = null;
504
+ try {
505
+ const sres = await fetch('/api/sessions', { cache: 'no-store' });
506
+ if (sres.ok){
507
+ const list = await sres.json();
508
+ const row = list.find(function(s){ return s.name === sessionName; });
509
+ if (row) convsCurrentResumeFrom = row.resumeFrom || null;
510
+ }
511
+ } catch(_){}
512
+ try {
513
+ const r = await fetch('/api/sessions/' + encodeURIComponent(sessionName) + '/conversations', { cache: 'no-store' });
514
+ if (!r.ok) throw new Error('http ' + r.status);
515
+ const list = await r.json();
516
+ if (!Array.isArray(list) || list.length === 0){
517
+ convsList.innerHTML = '<div class="conv">no past conversations for this agent + cwd</div>';
518
+ return;
519
+ }
520
+ convsList.innerHTML = list.map(function(c){
521
+ const isCurrent = c.id === convsCurrentResumeFrom;
522
+ const titleCls = isCurrent ? 'conv-title conv-current' : 'conv-title';
523
+ return '<button class="conv" data-conv-id="' + escapeHtml(c.id) + '" data-conv-title="' + escapeHtml(c.title) + '">' +
524
+ '<span class="' + titleCls + '">' + (isCurrent ? '↻ ' : '') + escapeHtml(c.title) + '</span>' +
525
+ '<span class="conv-meta"><span class="when">' + escapeHtml(relTime(c.lastMessageAt)) + '</span><span class="count">' + c.messageCount + ' msgs</span></span>' +
526
+ '</button>';
527
+ }).join('');
528
+ } catch(e){
529
+ convsList.innerHTML = '<div class="conv">failed to load conversations: ' + escapeHtml(e.message || String(e)) + '</div>';
530
+ }
531
+ }
532
+
533
+ function closeConvsModal(){
534
+ convsModal.classList.remove('open');
535
+ convsModal.setAttribute('aria-hidden', 'true');
536
+ convsForSession = null;
537
+ }
538
+ convsClose.addEventListener('click', closeConvsModal);
539
+ convsModal.addEventListener('click', function(e){
540
+ if (e.target === convsModal) closeConvsModal();
541
+ });
542
+
543
+ convsList.addEventListener('click', async function(e){
544
+ const btn = e.target.closest('button[data-conv-id]');
545
+ if (!btn || !convsForSession) return;
546
+ const convId = btn.dataset.convId;
547
+ const convTitle = btn.dataset.convTitle || '(conversation)';
548
+ const sessionName = convsForSession;
549
+ // Dismiss the conversations modal immediately so the confirm dialog
550
+ // doesn't stack underneath it (was a real bug — same z-index meant the
551
+ // confirm rendered behind the picker and tapping looked like nothing
552
+ // happened).
553
+ closeConvsModal();
554
+ const ok = await askConfirm({
555
+ title: 'Resume conversation?',
556
+ body: 'Kill <code>' + escapeHtmlSafe(sessionName) + '</code> and relaunch the agent with <code>--resume ' + escapeHtmlSafe(convId.slice(0, 8)) + '…</code>. The current in-process state is lost; conversation history (on the agent\\'s side) is intact.<br><br><em>' + escapeHtmlSafe(convTitle) + '</em>',
557
+ okLabel: 'resume',
558
+ destructive: true,
559
+ });
560
+ if (!ok) return;
561
+ try {
562
+ const r = await fetch('/api/sessions/' + encodeURIComponent(sessionName) + '/resume', {
563
+ method: 'POST',
564
+ headers: { 'content-type': 'application/json' },
565
+ body: JSON.stringify({ conversationId: convId }),
566
+ });
567
+ const data = await r.json().catch(function(){ return {}; });
568
+ if (!r.ok || data.ok === false) throw new Error(data.error || 'resume failed');
569
+ showToast('resumed ' + sessionName);
570
+ poll();
571
+ } catch(err){
572
+ showToast('resume failed: ' + (err.message || err), true);
573
+ }
574
+ });
575
+
576
+ // ---- Confirm modal ----
577
+ const confirmModal = document.getElementById('confirm-modal');
578
+ const confirmTitle = document.getElementById('confirm-title');
579
+ const confirmBody = document.getElementById('confirm-body');
580
+ const confirmCancel = document.getElementById('confirm-cancel');
581
+ const confirmOk = document.getElementById('confirm-ok');
582
+ let confirmResolve = null;
583
+
584
+ function askConfirm(opts){
585
+ confirmTitle.textContent = opts.title;
586
+ confirmBody.innerHTML = opts.body;
587
+ confirmOk.textContent = opts.okLabel || 'confirm';
588
+ confirmOk.className = opts.destructive ? 'danger' : '';
589
+ confirmModal.classList.add('open');
590
+ confirmModal.setAttribute('aria-hidden', 'false');
591
+ return new Promise(function(resolve){ confirmResolve = resolve; });
592
+ }
593
+ function closeConfirm(answer){
594
+ confirmModal.classList.remove('open');
595
+ confirmModal.setAttribute('aria-hidden', 'true');
596
+ const r = confirmResolve;
597
+ confirmResolve = null;
598
+ if (r) r(answer);
599
+ }
600
+ confirmCancel.addEventListener('click', function(){ closeConfirm(false); });
601
+ confirmOk.addEventListener('click', function(){ closeConfirm(true); });
602
+ // Tapping the dim background = cancel
603
+ confirmModal.addEventListener('click', function(e){
604
+ if (e.target === confirmModal) closeConfirm(false);
605
+ });
606
+
607
+ function escapeHtmlSafe(s){
608
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
609
+ }
610
+
611
+ container.addEventListener('click', async function(e){
612
+ const btn = e.target.closest('button[data-action]');
613
+ if (!btn) return;
614
+ e.preventDefault();
615
+ const name = btn.dataset.name;
616
+ const kind = btn.dataset.action;
617
+ if (kind === 'edit'){
618
+ let env = {};
619
+ try { env = JSON.parse(btn.dataset.env || '{}'); } catch(_){}
620
+ openEditForm({ name: name, agent: btn.dataset.agent, cwd: btn.dataset.cwd, flags: btn.dataset.flags, env: env });
621
+ return;
622
+ }
623
+ if (kind === 'resume'){
624
+ openConvsModal(name);
625
+ return;
626
+ }
627
+ if (kind === 'kill'){
628
+ const running = btn.dataset.status === 'running';
629
+ const ok = await askConfirm({
630
+ title: running ? 'Kill session?' : 'Remove session record?',
631
+ body: running
632
+ ? 'Terminate the tmux session <code>' + escapeHtmlSafe(name) + '</code> and remove its state record. The agent process inside will be killed. This cannot be undone.'
633
+ : 'Remove the state record for <code>' + escapeHtmlSafe(name) + '</code>. The tmux session is already exited; this just cleans up the row.',
634
+ okLabel: running ? 'kill' : 'remove',
635
+ destructive: true,
636
+ });
637
+ if (!ok) return;
638
+ }
639
+ action(name, kind, btn);
640
+ });
641
+
642
+ // ---- New / Edit session form ----
643
+ const newBtn = document.getElementById('new-btn');
644
+ const newForm = document.getElementById('new-form');
645
+ const newTitle = document.getElementById('new-title');
646
+ const newSessionForm = document.getElementById('new-session-form');
647
+ const newAgent = document.getElementById('new-agent');
648
+ const newName = document.getElementById('new-name');
649
+ const newCwd = document.getElementById('new-cwd');
650
+ const newFlags = document.getElementById('new-flags');
651
+ const newEnv = document.getElementById('new-env');
652
+ const newCwdHint = document.getElementById('new-cwd-hint');
653
+ const newFlagsHint = document.getElementById('new-flags-hint');
654
+ const newEnvHint = document.getElementById('new-env-hint');
655
+ const newCancel = document.getElementById('new-cancel');
656
+ const newSubmit = document.getElementById('new-submit');
657
+ let agentsLoaded = false;
658
+ let agentList = [];
659
+ // mode: null (closed) | 'new' | { edit: <original-name> }
660
+ let formMode = null;
661
+
662
+ function agentDefaultFlags(key){
663
+ const a = agentList.find(function(x){ return x.key === key; });
664
+ return (a && a.flags) || '';
665
+ }
666
+ function agentDefaultEnv(key){
667
+ const a = agentList.find(function(x){ return x.key === key; });
668
+ return (a && a.envDefaults) || {};
669
+ }
670
+ function envToText(envObj){
671
+ if (!envObj) return '';
672
+ return Object.keys(envObj).sort().map(function(k){ return k + '=' + envObj[k]; }).join('\\n');
673
+ }
674
+
675
+ async function loadAgents(){
676
+ if (agentsLoaded) return;
677
+ try {
678
+ const r = await fetch('/api/agents', { cache: 'no-store' });
679
+ if (!r.ok) throw new Error('http ' + r.status);
680
+ const list = await r.json();
681
+ if (!Array.isArray(list) || list.length === 0){
682
+ newAgent.innerHTML = '<option value="" disabled selected>no installed agents</option>';
683
+ agentList = [];
684
+ } else {
685
+ agentList = list;
686
+ newAgent.innerHTML = list.map(function(a){
687
+ const label = a.displayName || a.key;
688
+ return '<option value="' + escapeHtml(a.key) + '">' + escapeHtml(label) + '</option>';
689
+ }).join('');
690
+ }
691
+ agentsLoaded = true;
692
+ } catch(e){
693
+ showToast('couldn\\'t load agents: ' + (e.message || e), true);
694
+ }
695
+ }
696
+
697
+ function closeForm(){
698
+ newForm.classList.remove('open');
699
+ newForm.setAttribute('aria-hidden', 'true');
700
+ formMode = null;
701
+ newAgent.disabled = false;
702
+ }
703
+
704
+ function syncFlagsHint(agentKey){
705
+ const def = agentDefaultFlags(agentKey);
706
+ newFlagsHint.textContent = def
707
+ ? 'agent default: ' + def + '. Clear the input to spawn with no flags. Takes effect on next respawn.'
708
+ : 'this agent has no default flags. Takes effect on next respawn.';
709
+ }
710
+ function syncEnvHint(agentKey){
711
+ const def = agentDefaultEnv(agentKey);
712
+ const keys = Object.keys(def);
713
+ newEnvHint.textContent = keys.length > 0
714
+ ? 'agent defaults: ' + keys.join(', ') + '. KEY=VALUE one per line. Stored on the daemon host (auth-gated) — keep secrets out if you prefer to inject from a shell profile.'
715
+ : 'no defaults for this agent. KEY=VALUE one per line. Stored on the daemon host (auth-gated) — keep secrets out if you prefer to inject from a shell profile.';
716
+ }
717
+
718
+ async function openNewForm(){
719
+ formMode = 'new';
720
+ newTitle.textContent = 'new session';
721
+ newSubmit.textContent = 'spawn';
722
+ newName.value = '';
723
+ newCwd.value = '';
724
+ newAgent.disabled = false;
725
+ newCwdHint.hidden = true;
726
+ newFlagsHint.hidden = false;
727
+ newEnvHint.hidden = false;
728
+ newForm.classList.add('open');
729
+ newForm.setAttribute('aria-hidden', 'false');
730
+ await loadAgents();
731
+ // Pre-fill flags + env with the selected agent's defaults so the operator
732
+ // can edit/clear from there. Empty = spawn with no flags / no env override.
733
+ newFlags.value = agentDefaultFlags(newAgent.value);
734
+ newEnv.value = envToText(agentDefaultEnv(newAgent.value));
735
+ syncFlagsHint(newAgent.value);
736
+ syncEnvHint(newAgent.value);
737
+ newAgent.focus();
738
+ }
739
+
740
+ async function openEditForm(row){
741
+ formMode = { edit: row.name };
742
+ newTitle.textContent = 'edit "' + row.name + '"';
743
+ newSubmit.textContent = 'save';
744
+ newName.value = row.name;
745
+ newCwd.value = row.cwd || '';
746
+ newCwdHint.hidden = false;
747
+ newFlagsHint.hidden = false;
748
+ newEnvHint.hidden = false;
749
+ newForm.classList.add('open');
750
+ newForm.setAttribute('aria-hidden', 'false');
751
+ await loadAgents();
752
+ // Agent of an existing session can't be changed without kill+respawn;
753
+ // surface it as read-only so the user sees what they have.
754
+ if (row.agent) newAgent.value = row.agent;
755
+ newAgent.disabled = true;
756
+ // Pre-fill with the persisted override if present, else the agent default.
757
+ newFlags.value = row.flags !== undefined && row.flags !== ''
758
+ ? row.flags
759
+ : agentDefaultFlags(newAgent.value);
760
+ newEnv.value = row.env && Object.keys(row.env).length > 0
761
+ ? envToText(row.env)
762
+ : envToText(agentDefaultEnv(newAgent.value));
763
+ syncFlagsHint(newAgent.value);
764
+ syncEnvHint(newAgent.value);
765
+ newName.focus();
766
+ newName.select();
767
+ }
768
+
769
+ newAgent.addEventListener('change', function(){
770
+ if (formMode === 'new'){
771
+ // Reset flags + env to the new agent's defaults so fields reflect intent.
772
+ newFlags.value = agentDefaultFlags(newAgent.value);
773
+ newEnv.value = envToText(agentDefaultEnv(newAgent.value));
774
+ syncFlagsHint(newAgent.value);
775
+ syncEnvHint(newAgent.value);
776
+ }
777
+ });
778
+
779
+ newBtn.addEventListener('click', function(){
780
+ if (newForm.classList.contains('open') && formMode === 'new'){ closeForm(); return; }
781
+ openNewForm();
782
+ });
783
+
784
+ newCancel.addEventListener('click', function(){ closeForm(); });
785
+
786
+ newSessionForm.addEventListener('submit', async function(e){
787
+ e.preventDefault();
788
+ const name = newName.value.trim();
789
+ const cwd = newCwd.value.trim();
790
+ const flags = newFlags.value;
791
+ const env = newEnv.value;
792
+ newSubmit.disabled = true;
793
+ const originalLabel = newSubmit.textContent;
794
+ try {
795
+ if (formMode && formMode.edit){
796
+ newSubmit.textContent = 'saving…';
797
+ // For edit, always send flags + env so input values are canonical.
798
+ // name/cwd still only sent if user typed (so blank = no change).
799
+ const body = { flags: flags, env: env };
800
+ if (name) body.name = name;
801
+ if (cwd) body.cwd = cwd;
802
+ const r = await fetch('/api/sessions/' + encodeURIComponent(formMode.edit), {
803
+ method: 'PATCH',
804
+ headers: { 'content-type': 'application/json' },
805
+ body: JSON.stringify(body),
806
+ });
807
+ const data = await r.json().catch(function(){ return {}; });
808
+ if (!r.ok || data.ok === false) throw new Error(data.error || 'edit failed');
809
+ showToast('updated ' + data.session.name);
810
+ } else {
811
+ const agent = newAgent.value;
812
+ if (!agent){ showToast('pick an agent', true); return; }
813
+ newSubmit.textContent = 'spawning…';
814
+ const body = { agent };
815
+ if (name) body.name = name;
816
+ if (cwd) body.cwd = cwd;
817
+ // Always send flags + env as the inputs are pre-filled with agent defaults;
818
+ // empty values = explicit "no flags" / "no env override".
819
+ body.flags = flags;
820
+ body.env = env;
821
+ const r = await fetch('/api/sessions', {
822
+ method: 'POST',
823
+ headers: { 'content-type': 'application/json' },
824
+ body: JSON.stringify(body),
825
+ });
826
+ const data = await r.json().catch(function(){ return {}; });
827
+ if (!r.ok || data.ok === false) throw new Error(data.error || 'spawn failed');
828
+ showToast('spawned ' + data.session.name);
829
+ }
830
+ closeForm();
831
+ poll();
832
+ } catch(e){
833
+ showToast((formMode && formMode.edit ? 'edit' : 'spawn') + ' failed: ' + (e.message || e), true);
834
+ } finally {
835
+ newSubmit.disabled = false;
836
+ newSubmit.textContent = originalLabel;
837
+ }
838
+ });
839
+
840
+ document.addEventListener('visibilitychange', function(){
841
+ if (!document.hidden) poll();
842
+ });
843
+
844
+ poll();
845
+ pollTimer = setInterval(poll, 3000);
846
+ setInterval(staleCheck, 1000);
847
+ })();
848
+ </script>
849
+ </body></html>`;
850
+ }
851
+
852
+ function renderSessionTable(sessions: SessionView[]): string {
853
+ if (sessions.length === 0) {
854
+ return `<div class="empty">no sessions yet — spawn one from the CLI:<br><br><code>llmuxd spawn claude --name <em>name</em></code></div>`;
855
+ }
856
+ const rows = sessions
857
+ .map((s) => {
858
+ const cls = `state-${s.status}`;
859
+ const linkOpen = `<a class="session-link" href="/session/${encodeURIComponent(s.name)}">`;
860
+ const respawnText = s.status === 'running' ? 'restart' : 'respawn';
861
+ const respawnBtn = `<button class="respawn" data-action="respawn" data-name="${escapeHtml(s.name)}" aria-label="${respawnText}"><span class="icon">↻</span><span class="label">${respawnText}</span></button>`;
862
+ const editBtn = `<button class="edit" data-action="edit" data-name="${escapeHtml(s.name)}" data-cwd="${escapeHtml(s.cwd)}" data-agent="${escapeHtml(s.agent)}" data-flags="${escapeHtml(s.flags || '')}" data-env="${escapeHtml(JSON.stringify(s.env || {}))}" aria-label="edit"><span class="icon">✎</span><span class="label">edit</span></button>`;
863
+ const resumeBtn = (s.hasHistory && s.conversationCount > 0)
864
+ ? `<button class="resume-btn" data-action="resume" data-name="${escapeHtml(s.name)}" aria-label="resume"><span class="icon">☰</span><span class="label">${s.conversationCount}</span></button>`
865
+ : '';
866
+ const killText = s.status === 'running' ? 'kill' : 'remove';
867
+ const killBtn = `<button class="kill" data-action="kill" data-name="${escapeHtml(s.name)}" data-status="${s.status}" aria-label="${killText}"><span class="icon">✕</span><span class="label">${killText}</span></button>`;
868
+ const cwdShort = s.cwdDisplay || s.cwd;
869
+ return `<tr data-name="${escapeHtml(s.name)}">
870
+ <td class="name-block"><span class="name">${linkOpen}${escapeHtml(s.name)}</a></span><span class="cwd" title="${escapeHtml(s.cwd)}"><code>${escapeHtml(cwdShort)}</code></span></td>
871
+ <td>${escapeHtml(s.agent)}</td>
872
+ <td class="${cls}">${s.status}</td>
873
+ <td class="cwd cwd-col" title="${escapeHtml(s.cwd)}"><code>${escapeHtml(cwdShort)}</code></td>
874
+ <td class="actions">${resumeBtn}${respawnBtn}${editBtn}${killBtn}</td>
875
+ </tr>`;
876
+ })
877
+ .join('\n');
878
+ return `<table><thead><tr><th>name</th><th>agent</th><th>state</th><th class="cwd-col">cwd</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
879
+ }
880
+
881
+ function deadSessionPage(s: SessionView): string {
882
+ return `<!doctype html><html lang="en"><head>
883
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
884
+ <title>${escapeHtml(s.name)} — exited</title>
885
+ <style>
886
+ :root{color-scheme:dark}
887
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px}
888
+ body{padding:24px;max-width:560px;margin:0 auto}
889
+ h1{font-size:18px;margin:0 0 4px}
890
+ .sub{color:#7a7f87;font-size:12px;margin-bottom:18px}
891
+ .card{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:18px}
892
+ dl{margin:0;display:grid;grid-template-columns:80px 1fr;gap:6px 12px;font-size:13px}
893
+ dt{color:#7a7f87}
894
+ dd{margin:0;color:#c9d1d9;word-break:break-all}
895
+ .row{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
896
+ button{flex:1 1 auto;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:10px 14px;font:13px ui-monospace,monospace;cursor:pointer;min-width:120px}
897
+ button:hover{background:#252b34}
898
+ button.primary{color:#7cc4ff;border-color:#2d4a66}
899
+ button.danger{color:#f85149;border-color:#4a2329}
900
+ button.ghost{color:#9aa0a6}
901
+ button:disabled{opacity:.5;cursor:wait}
902
+ #status{margin-top:14px;font-size:12px;color:#9aa0a6;min-height:18px}
903
+ #status.error{color:#f85149}
904
+ </style></head>
905
+ <body>
906
+ <h1>${escapeHtml(s.name)}</h1>
907
+ <div class="sub">session is not running</div>
908
+ <div class="card">
909
+ <dl>
910
+ <dt>agent</dt><dd>${escapeHtml(s.agent)}</dd>
911
+ <dt>cwd</dt><dd>${escapeHtml(s.cwd)}</dd>
912
+ <dt>created</dt><dd>${escapeHtml(s.createdAt)}</dd>
913
+ ${s.parent ? `<dt>parent</dt><dd>${escapeHtml(s.parent)}</dd>` : ''}
914
+ </dl>
915
+ <div class="row">
916
+ <button class="primary" id="btn-respawn">↻ respawn</button>
917
+ <button class="danger" id="btn-remove">× remove</button>
918
+ <button class="ghost" id="btn-back">← sessions</button>
919
+ </div>
920
+ <div id="status"></div>
921
+ </div>
922
+ <script>
923
+ (function(){
924
+ const name = ${JSON.stringify(s.name)};
925
+ const status = document.getElementById('status');
926
+ function setStatus(msg, isError){
927
+ status.textContent = msg;
928
+ status.classList.toggle('error', !!isError);
929
+ }
930
+ async function call(kind){
931
+ const btns = document.querySelectorAll('button');
932
+ btns.forEach(function(b){ b.disabled = true; });
933
+ setStatus(kind === 'respawn' ? 'respawning…' : 'removing…');
934
+ try {
935
+ const r = await fetch('/api/sessions/' + encodeURIComponent(name) + '/' + kind, { method: 'POST' });
936
+ const body = await r.json().catch(function(){ return {}; });
937
+ if (!r.ok || body.ok === false) throw new Error(body.error || 'request failed');
938
+ if (kind === 'respawn') location.href = '/session/' + encodeURIComponent(name);
939
+ else location.href = '/';
940
+ } catch(e){
941
+ setStatus(kind + ' failed: ' + (e.message || e), true);
942
+ btns.forEach(function(b){ b.disabled = false; });
943
+ }
944
+ }
945
+ document.getElementById('btn-respawn').addEventListener('click', function(){ call('respawn'); });
946
+ document.getElementById('btn-remove').addEventListener('click', function(){ call('kill'); });
947
+ document.getElementById('btn-back').addEventListener('click', function(){ location.href = '/'; });
948
+ })();
949
+ </script>
950
+ </body></html>`;
951
+ }
952
+
953
+ function sessionPage(name: string): string {
954
+ const escapedName = escapeHtml(name);
955
+ const jsonName = JSON.stringify(name);
956
+ const jsonVersion = JSON.stringify(DAEMON_VERSION);
957
+ return `<!doctype html><html lang="en"><head>
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} — llmuxd</title>
960
+ <link rel="icon" href="${FAVICON_DATA_URL}">
961
+ <link rel="apple-touch-icon" href="${FAVICON_DATA_URL}">
962
+ <link rel="stylesheet" href="${XTERM_CSS}">
963
+ <style>
964
+ :root{--topbar-h:38px;--bar-h:92px;--allkeys-h:0px;color-scheme:dark}
965
+ html,body{margin:0;background:#0b0c10;color:#eee;font-family:ui-monospace,monospace;overscroll-behavior:none}
966
+ html{height:100dvh}
967
+ body{height:100dvh;min-height:100dvh}
968
+ #topbar{position:fixed;top:0;left:0;right:0;height:var(--topbar-h);background:#11141a;border-bottom:1px solid #1f2329;display:flex;align-items:center;gap:8px;padding:0 10px;z-index:21;box-sizing:border-box}
969
+ #topbar #back{flex:0 0 auto;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;height:26px;width:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font-family:system-ui,sans-serif;font-size:16px;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none}
970
+ #topbar #back:active{background:#252b34;border-color:#3a414b}
971
+ #title-block{flex:1 1 auto;display:flex;align-items:center;gap:8px;color:#c9d1d9;font-size:12px;min-width:0}
972
+ #title-dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#9aa0a6;transition:background .2s,box-shadow .2s;cursor:pointer}
973
+ #title-dot[data-state="live"]{background:#7ee787;box-shadow:0 0 6px #7ee78766}
974
+ #title-dot[data-state="error"],#title-dot[data-state="closed"],#title-dot[data-state="reconnecting"]{background:#f85149}
975
+ #title-dot[data-state="reconnecting"]{animation:pulse 1s ease-in-out infinite}
976
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
977
+ #title-name{flex:0 1 auto;font-weight:600;color:#e6e8eb;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
978
+ #title-brand{flex:0 0 auto;color:#7cc4ff;font-size:11px;font-weight:600;letter-spacing:.08em;margin-left:auto;padding-left:8px}
979
+ #title-version{flex:0 0 auto;color:#7a7f87;font-size:10px;padding-left:6px}
980
+ #bar{position:fixed;bottom:0;left:0;right:0;height:var(--bar-h);background:#11141a;border-top:1px solid #1f2329;display:flex;flex-direction:column;gap:8px;padding:6px 0 14px;z-index:20;box-sizing:border-box}
981
+ #bar .row{display:flex;align-items:center;gap:6px;padding:0 6px;flex:0 0 auto;height:32px}
982
+ #bar .row.arrows{justify-content:center}
983
+ #bar .row.keys{justify-content:flex-start}
984
+ #bar #more{flex:0 0 auto;margin-left:auto}
985
+ #bar button{flex:0 0 auto;min-width:40px;height:30px;padding:0 10px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:13px ui-monospace,monospace;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none;transition:background .15s,border-color .15s}
986
+ #bar button:active{background:#252b34;border-color:#3a414b}
987
+ #bar button[aria-pressed="true"]{background:#1e3a52;border-color:#2d5a85;color:#7cc4ff}
988
+ #bar button[aria-pressed="locked"]{background:#2d5a85;border-color:#4a7fae;color:#fff}
989
+ #bar button.fail{background:#4a2329;border-color:#f85149;color:#f85149}
990
+ #all-keys{position:fixed;bottom:var(--bar-h);left:0;right:0;background:#0e1116;border-top:1px solid #1f2329;display:none;padding:8px;z-index:19;max-height:40vh;overflow-y:auto;box-sizing:border-box}
991
+ #all-keys.open{display:block}
992
+ #all-keys h4{margin:14px 4px 6px;font:500 10px/1 ui-monospace,monospace;color:#7a7f87;text-transform:uppercase;letter-spacing:.06em}
993
+ #all-keys h4:first-child{margin-top:4px}
994
+ #all-keys .row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
995
+ #all-keys button{flex:0 0 auto;min-width:36px;height:30px;padding:0 8px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:12px ui-monospace,monospace;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none}
996
+ #all-keys button:active{background:#252b34;border-color:#3a414b}
997
+ #term{position:fixed;top:var(--topbar-h);left:0;right:0;bottom:var(--bar-h)}
998
+ body.allkeys-open #term{bottom:calc(var(--bar-h) + var(--allkeys-h))}
999
+ #overlay{position:fixed;inset:0;background:rgba(11,12,16,.92);display:none;align-items:center;justify-content:center;z-index:30;padding:20px}
1000
+ #overlay.show{display:flex}
1001
+ #overlay .panel{background:#11141a;border:1px solid #1f2329;border-radius:10px;padding:20px;max-width:340px;width:100%;text-align:center}
1002
+ #overlay h3{margin:0 0 6px;font-size:15px;color:#f85149}
1003
+ #overlay p{margin:0 0 14px;font-size:13px;color:#c9d1d9;line-height:1.5}
1004
+ #overlay .actions{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
1005
+ #overlay button{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:8px 14px;font:12px ui-monospace,monospace;cursor:pointer}
1006
+ #overlay button.primary{color:#7cc4ff;border-color:#2d4a66}
1007
+ @media (orientation: landscape) and (max-height: 500px){
1008
+ :root{--topbar-h:28px;--bar-h:64px}
1009
+ #topbar{padding:0 6px;gap:6px}
1010
+ #topbar #back{height:20px;width:30px;font-size:13px}
1011
+ #title-block{font-size:11px}
1012
+ #title-brand{font-size:10px;padding-left:6px}
1013
+ #title-version{font-size:9px;padding-left:4px}
1014
+ #bar button{height:22px;min-width:36px;padding:0 8px;font-size:11px}
1015
+ #bar{padding:4px 0 10px;gap:4px}
1016
+ #bar .row{gap:4px;height:24px}
1017
+ #all-keys{max-height:60vh}
1018
+ #all-keys button{height:24px;min-width:30px;padding:0 7px;font-size:11px}
1019
+ }
1020
+ </style></head>
1021
+ <body>
1022
+ <div id="topbar">
1023
+ <button id="back" title="Back to sessions">⌂</button>
1024
+ <span id="title-block"><span id="title-dot" data-state="connecting" title="connecting…"></span><span id="title-name">${escapedName}</span></span>
1025
+ <span id="title-brand">LLMUX</span>
1026
+ <span id="title-version">v${escapeHtml(DAEMON_VERSION)}</span>
1027
+ </div>
1028
+ <div id="bar">
1029
+ <div class="row arrows">
1030
+ <button data-mod="shift" title="Shift (next char uppercase; double-tap to lock)">Shift</button>
1031
+ <button data-key="home" title="Home">Home</button>
1032
+ <button data-key="up" title="Up">▲</button>
1033
+ <button data-key="down" title="Down">▼</button>
1034
+ <button data-key="left" title="Left">◀</button>
1035
+ <button data-key="right" title="Right">▶</button>
1036
+ <button data-key="end" title="End">End</button>
1037
+ </div>
1038
+ <div class="row keys">
1039
+ <button data-key="esc" title="Escape">Esc</button>
1040
+ <button data-key="tab" title="Tab">Tab</button>
1041
+ <button data-mod="ctrl" title="Ctrl (tap then key, double-tap to lock)">Ctrl</button>
1042
+ <button data-mod="alt" title="Alt (tap then key, double-tap to lock)">Alt</button>
1043
+ <button id="more" title="All keys">⋯</button>
1044
+ </div>
1045
+ </div>
1046
+ <div id="all-keys" aria-hidden="true">
1047
+ <h4>shell</h4>
1048
+ <div class="row">
1049
+ <button data-char="~" title="tilde">~</button>
1050
+ <button data-char="\`" title="backtick">\`</button>
1051
+ <button data-char="/" title="slash">/</button>
1052
+ <button data-char="\\\\" title="backslash">\\</button>
1053
+ <button data-char="|" title="pipe">|</button>
1054
+ <button data-char="-" title="dash">-</button>
1055
+ <button data-char="_" title="underscore">_</button>
1056
+ </div>
1057
+ <h4>numbers</h4>
1058
+ <div class="row">
1059
+ <button data-char="0">0</button><button data-char="1">1</button><button data-char="2">2</button>
1060
+ <button data-char="3">3</button><button data-char="4">4</button><button data-char="5">5</button>
1061
+ <button data-char="6">6</button><button data-char="7">7</button><button data-char="8">8</button>
1062
+ <button data-char="9">9</button>
1063
+ </div>
1064
+ <h4>brackets &amp; quotes</h4>
1065
+ <div class="row">
1066
+ <button data-char="(">(</button><button data-char=")">)</button>
1067
+ <button data-char="[">[</button><button data-char="]">]</button>
1068
+ <button data-char="{">{</button><button data-char="}">}</button>
1069
+ <button data-char="&lt;">&lt;</button><button data-char="&gt;">&gt;</button>
1070
+ <button data-char="'">'</button><button data-char="&quot;">&quot;</button>
1071
+ </div>
1072
+ <h4>operators</h4>
1073
+ <div class="row">
1074
+ <button data-char="=">=</button><button data-char="+">+</button>
1075
+ <button data-char="*">*</button><button data-char="&amp;">&amp;</button>
1076
+ <button data-char="^">^</button><button data-char="%">%</button>
1077
+ <button data-char="$">$</button><button data-char="#">#</button>
1078
+ <button data-char="@">@</button><button data-char="!">!</button>
1079
+ <button data-char="?">?</button>
1080
+ </div>
1081
+ <h4>punctuation</h4>
1082
+ <div class="row">
1083
+ <button data-char=":">:</button><button data-char=";">;</button>
1084
+ <button data-char=",">,</button><button data-char=".">.</button>
1085
+ </div>
1086
+ <h4>navigation &amp; edit</h4>
1087
+ <div class="row">
1088
+ <button data-key="home">Home</button><button data-key="end">End</button>
1089
+ <button data-key="pgup">PgUp</button><button data-key="pgdn">PgDn</button>
1090
+ <button data-key="del">Del</button><button data-key="ins">Ins</button>
1091
+ <button data-key="bsp">⌫ Bsp</button><button data-key="enter">↵ Enter</button>
1092
+ </div>
1093
+ <h4>function keys</h4>
1094
+ <div class="row">
1095
+ <button data-key="f1">F1</button><button data-key="f2">F2</button>
1096
+ <button data-key="f3">F3</button><button data-key="f4">F4</button>
1097
+ <button data-key="f5">F5</button><button data-key="f6">F6</button>
1098
+ <button data-key="f7">F7</button><button data-key="f8">F8</button>
1099
+ <button data-key="f9">F9</button><button data-key="f10">F10</button>
1100
+ <button data-key="f11">F11</button><button data-key="f12">F12</button>
1101
+ </div>
1102
+ <h4>actions</h4>
1103
+ <div class="row">
1104
+ <button id="reset-term" title="Clear xterm buffer and send Ctrl-L to redraw">Reset terminal</button>
1105
+ </div>
1106
+ </div>
1107
+ <div id="term"></div>
1108
+ <div id="overlay" aria-hidden="true">
1109
+ <div class="panel">
1110
+ <h3 id="overlay-title">session ended</h3>
1111
+ <p id="overlay-body">The tmux session exited. You can respawn it from the picker.</p>
1112
+ <div class="actions">
1113
+ <button class="primary" id="overlay-respawn">↻ respawn</button>
1114
+ <button id="overlay-back">← sessions</button>
1115
+ </div>
1116
+ </div>
1117
+ </div>
1118
+ <script src="${XTERM_JS}"></script>
1119
+ <script src="${XTERM_FIT_JS}"></script>
1120
+ <script>
1121
+ (function(){
1122
+ const name = ${jsonName};
1123
+ const version = ${jsonVersion};
1124
+ const dot = document.getElementById('title-dot');
1125
+ const titleName = document.getElementById('title-name');
1126
+ const termEl = document.getElementById('term');
1127
+ const overlay = document.getElementById('overlay');
1128
+ const overlayTitle = document.getElementById('overlay-title');
1129
+ const overlayBody = document.getElementById('overlay-body');
1130
+
1131
+ function setStatus(state, label){
1132
+ dot.dataset.state = state;
1133
+ dot.title = name + ' — ' + label;
1134
+ }
1135
+
1136
+ function showOverlay(title, body, kind){
1137
+ overlayTitle.textContent = title;
1138
+ overlayBody.textContent = body;
1139
+ overlay.classList.add('show');
1140
+ overlay.setAttribute('aria-hidden', 'false');
1141
+ overlay.dataset.kind = kind || '';
1142
+ }
1143
+ function hideOverlay(){
1144
+ overlay.classList.remove('show');
1145
+ overlay.setAttribute('aria-hidden', 'true');
1146
+ }
1147
+
1148
+ document.getElementById('overlay-back').addEventListener('click', function(){ location.href = '/'; });
1149
+ document.getElementById('overlay-respawn').addEventListener('click', async function(){
1150
+ const btn = this;
1151
+ btn.disabled = true;
1152
+ overlayBody.textContent = 'respawning…';
1153
+ try {
1154
+ const r = await fetch('/api/sessions/' + encodeURIComponent(name) + '/respawn', { method: 'POST' });
1155
+ const body = await r.json().catch(function(){ return {}; });
1156
+ if (!r.ok || body.ok === false) throw new Error(body.error || 'request failed');
1157
+ location.reload();
1158
+ } catch(e){
1159
+ overlayBody.textContent = 'respawn failed: ' + (e.message || e);
1160
+ btn.disabled = false;
1161
+ }
1162
+ });
1163
+
1164
+ const term = new Terminal({fontSize:14,fontFamily:'ui-monospace,monospace',theme:{background:'#0b0c10'},cursorBlink:true,scrollback:5000});
1165
+ const fit = new FitAddon.FitAddon();
1166
+ term.loadAddon(fit);
1167
+ term.open(termEl);
1168
+
1169
+ // ---- WebSocket with exponential backoff ----
1170
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1171
+ const wsUrl = proto + '://' + location.host + '/ws/' + encodeURIComponent(name);
1172
+ let ws = null;
1173
+ let dataPiped = false;
1174
+ let reconnectTimer = null;
1175
+ let backoffMs = 1000;
1176
+ const BACKOFF_CAP = 30000;
1177
+ let everConnected = false;
1178
+ let intentionallyClosed = false;
1179
+
1180
+ function safeSend(data){
1181
+ if (!ws || ws.readyState !== WebSocket.OPEN){
1182
+ return false;
1183
+ }
1184
+ try { ws.send(data); return true; }
1185
+ catch(e){ return false; }
1186
+ }
1187
+
1188
+ function clearReconnect(){
1189
+ if (reconnectTimer){
1190
+ clearTimeout(reconnectTimer);
1191
+ reconnectTimer = null;
1192
+ }
1193
+ }
1194
+
1195
+ function scheduleReconnect(){
1196
+ if (intentionallyClosed) return;
1197
+ clearReconnect();
1198
+ setStatus('reconnecting', 'reconnecting in ' + Math.round(backoffMs/1000) + 's…');
1199
+ reconnectTimer = setTimeout(function(){
1200
+ reconnectTimer = null;
1201
+ connect();
1202
+ }, backoffMs);
1203
+ backoffMs = Math.min(BACKOFF_CAP, backoffMs * 2);
1204
+ }
1205
+
1206
+ function ensureConnected(){
1207
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
1208
+ clearReconnect();
1209
+ backoffMs = 1000;
1210
+ connect();
1211
+ }
1212
+
1213
+ function connect(){
1214
+ setStatus('connecting', 'connecting…');
1215
+ try {
1216
+ ws = new WebSocket(wsUrl);
1217
+ } catch(e){
1218
+ scheduleReconnect();
1219
+ return;
1220
+ }
1221
+ ws.binaryType = 'arraybuffer';
1222
+ ws.onopen = function(){
1223
+ setStatus('live', 'live');
1224
+ backoffMs = 1000;
1225
+ everConnected = true;
1226
+ hideOverlay();
1227
+ if (!dataPiped){
1228
+ // term.onData must only be wired once — repeat calls would
1229
+ // double-deliver every keystroke.
1230
+ term.onData(function(d){
1231
+ if (!safeSend(consumeMods(d))) flashDot();
1232
+ });
1233
+ dataPiped = true;
1234
+ }
1235
+ scheduleResize();
1236
+ term.focus();
1237
+ };
1238
+ ws.onmessage = function(ev){
1239
+ if (typeof ev.data === 'string') term.write(ev.data);
1240
+ else term.write(new Uint8Array(ev.data));
1241
+ };
1242
+ ws.onclose = function(ev){
1243
+ // Close code 1011/4040 from the server means the tmux session is gone —
1244
+ // surface a session-ended overlay instead of reconnect-looping.
1245
+ if (ev && (ev.code === 4040 || /pty exited/.test(ev.reason || ''))){
1246
+ intentionallyClosed = true;
1247
+ setStatus('closed', 'session ended');
1248
+ showOverlay('session ended', 'The tmux session is no longer running.', 'ended');
1249
+ return;
1250
+ }
1251
+ if (everConnected) setStatus('closed', 'disconnected — reconnecting');
1252
+ scheduleReconnect();
1253
+ };
1254
+ ws.onerror = function(){
1255
+ setStatus('error', 'connection error');
1256
+ };
1257
+ }
1258
+
1259
+ let flashTimer = null;
1260
+ function flashDot(){
1261
+ dot.style.boxShadow = '0 0 8px #f85149';
1262
+ clearTimeout(flashTimer);
1263
+ flashTimer = setTimeout(function(){ dot.style.boxShadow = ''; }, 250);
1264
+ }
1265
+ function flashBtnFail(btn){
1266
+ btn.classList.add('fail');
1267
+ setTimeout(function(){ btn.classList.remove('fail'); }, 250);
1268
+ }
1269
+
1270
+ connect();
1271
+
1272
+ // ---- Key sequence table ----
1273
+ const KEYS = {
1274
+ esc: '\\x1b', tab: '\\t', enter: '\\r', bsp: '\\x7f',
1275
+ up: '\\x1b[A', down: '\\x1b[B', right: '\\x1b[C', left: '\\x1b[D',
1276
+ home: '\\x1b[H', end: '\\x1b[F',
1277
+ pgup: '\\x1b[5~', pgdn: '\\x1b[6~',
1278
+ del: '\\x1b[3~', ins: '\\x1b[2~',
1279
+ f1:'\\x1bOP', f2:'\\x1bOQ', f3:'\\x1bOR', f4:'\\x1bOS',
1280
+ f5:'\\x1b[15~', f6:'\\x1b[17~', f7:'\\x1b[18~', f8:'\\x1b[19~',
1281
+ f9:'\\x1b[20~', f10:'\\x1b[21~', f11:'\\x1b[23~', f12:'\\x1b[24~'
1282
+ };
1283
+ Object.keys(KEYS).forEach(function(k){ KEYS[k] = KEYS[k].replace(/\\\\x([0-9a-f]{2})/gi, function(_,h){ return String.fromCharCode(parseInt(h,16)); }); });
1284
+
1285
+ // ---- Modifier state: 'off' | 'pending' | 'locked' ----
1286
+ const mods = { ctrl: 'off', alt: 'off', shift: 'off' };
1287
+ function setMod(mod, val){
1288
+ mods[mod] = val;
1289
+ const btn = document.querySelector('[data-mod="'+mod+'"]');
1290
+ if (btn){
1291
+ if (val === 'off') btn.removeAttribute('aria-pressed');
1292
+ else btn.setAttribute('aria-pressed', val === 'locked' ? 'locked' : 'true');
1293
+ }
1294
+ }
1295
+ function consumeMods(d){
1296
+ let out = d;
1297
+ if (mods.shift !== 'off' && d.length === 1){
1298
+ out = d.toUpperCase();
1299
+ if (mods.shift === 'pending') setMod('shift', 'off');
1300
+ }
1301
+ if (mods.ctrl !== 'off' && out.length === 1){
1302
+ const c = out.charCodeAt(0);
1303
+ if (c >= 0x40 && c <= 0x7f) out = String.fromCharCode(c & 0x1f);
1304
+ else if (c === 0x20) out = '\\x00';
1305
+ if (mods.ctrl === 'pending') setMod('ctrl', 'off');
1306
+ }
1307
+ if (mods.alt !== 'off'){
1308
+ out = '\\x1b' + out;
1309
+ if (mods.alt === 'pending') setMod('alt', 'off');
1310
+ }
1311
+ return out.replace(/\\\\x([0-9a-f]{2})/gi, function(_,h){ return String.fromCharCode(parseInt(h,16)); });
1312
+ }
1313
+
1314
+ // ---- Layout / resize ----
1315
+ let resizeTimer = null;
1316
+ const allKeysEl = document.getElementById('all-keys');
1317
+ function getAllKeysH(){
1318
+ if (!allKeysEl.classList.contains('open')) return 0;
1319
+ return Math.min(allKeysEl.scrollHeight, Math.floor((window.visualViewport ? window.visualViewport.height : window.innerHeight) * 0.4));
1320
+ }
1321
+ function applyLayout(){
1322
+ const allKeysH = getAllKeysH();
1323
+ document.documentElement.style.setProperty('--allkeys-h', allKeysH + 'px');
1324
+ const cs = getComputedStyle(document.documentElement);
1325
+ const barH = parseInt(cs.getPropertyValue('--bar-h'),10) || 42;
1326
+ const topbarH = parseInt(cs.getPropertyValue('--topbar-h'),10) || 0;
1327
+ const vv = window.visualViewport;
1328
+ const visibleH = vv ? vv.height : window.innerHeight;
1329
+ termEl.style.top = topbarH + 'px';
1330
+ termEl.style.bottom = (barH + allKeysH) + 'px';
1331
+ termEl.style.height = Math.max(60, visibleH - topbarH - barH - allKeysH) + 'px';
1332
+ }
1333
+ function scheduleResize(){
1334
+ clearTimeout(resizeTimer);
1335
+ resizeTimer = setTimeout(function(){
1336
+ applyLayout();
1337
+ try { fit.fit(); } catch(e){}
1338
+ safeSend(JSON.stringify({type:'resize', cols:term.cols, rows:term.rows}));
1339
+ }, 60);
1340
+ }
1341
+
1342
+ applyLayout();
1343
+ try { fit.fit(); } catch(e){}
1344
+
1345
+ // ---- Wire toolbar ----
1346
+ document.querySelectorAll('#topbar button, #bar button, #all-keys button').forEach(function(b){ b.tabIndex = -1; });
1347
+
1348
+ document.getElementById('back').addEventListener('click', function(e){ e.preventDefault(); location.href = '/'; });
1349
+
1350
+ document.getElementById('reset-term').addEventListener('click', function(e){
1351
+ e.preventDefault();
1352
+ try { term.reset(); } catch(err){}
1353
+ safeSend('\\x0c');
1354
+ term.focus();
1355
+ });
1356
+
1357
+ document.getElementById('more').addEventListener('click', function(e){
1358
+ e.preventDefault();
1359
+ const open = allKeysEl.classList.toggle('open');
1360
+ allKeysEl.setAttribute('aria-hidden', open ? 'false' : 'true');
1361
+ document.body.classList.toggle('allkeys-open', open);
1362
+ scheduleResize();
1363
+ term.focus();
1364
+ });
1365
+
1366
+ document.querySelectorAll('[data-key]').forEach(function(btn){
1367
+ btn.addEventListener('pointerdown', function(e){ e.preventDefault(); });
1368
+ btn.addEventListener('click', function(e){
1369
+ e.preventDefault();
1370
+ const seq = KEYS[btn.dataset.key];
1371
+ if (seq != null && !safeSend(consumeMods(seq))) flashBtnFail(btn);
1372
+ term.focus();
1373
+ });
1374
+ });
1375
+
1376
+ document.querySelectorAll('[data-char]').forEach(function(btn){
1377
+ btn.addEventListener('pointerdown', function(e){ e.preventDefault(); });
1378
+ btn.addEventListener('click', function(e){
1379
+ e.preventDefault();
1380
+ if (!safeSend(consumeMods(btn.dataset.char))) flashBtnFail(btn);
1381
+ term.focus();
1382
+ });
1383
+ });
1384
+
1385
+ document.querySelectorAll('[data-mod]').forEach(function(btn){
1386
+ let lastTap = 0;
1387
+ btn.addEventListener('pointerdown', function(e){ e.preventDefault(); });
1388
+ btn.addEventListener('click', function(e){
1389
+ e.preventDefault();
1390
+ const mod = btn.dataset.mod;
1391
+ const now = Date.now();
1392
+ const fast = now - lastTap < 400;
1393
+ lastTap = now;
1394
+ if (mods[mod] === 'locked') setMod(mod, 'off');
1395
+ else if (fast && mods[mod] === 'pending') setMod(mod, 'locked');
1396
+ else if (mods[mod] === 'off') setMod(mod, 'pending');
1397
+ else setMod(mod, 'off');
1398
+ term.focus();
1399
+ });
1400
+ });
1401
+
1402
+ // ---- Resize triggers ----
1403
+ addEventListener('resize', function(){ scheduleResize(); });
1404
+ addEventListener('orientationchange', function(){ scheduleResize(); });
1405
+ if (window.visualViewport){
1406
+ window.visualViewport.addEventListener('resize', function(){ scheduleResize(); });
1407
+ window.visualViewport.addEventListener('scroll', function(){ scheduleResize(); });
1408
+ }
1409
+ let pendingRefocus = false;
1410
+ function armRefocus(){
1411
+ if (pendingRefocus) return;
1412
+ pendingRefocus = true;
1413
+ function onUserTouch(){
1414
+ pendingRefocus = false;
1415
+ try { term.focus(); } catch(e){}
1416
+ document.removeEventListener('touchstart', onUserTouch, true);
1417
+ document.removeEventListener('mousedown', onUserTouch, true);
1418
+ }
1419
+ document.addEventListener('touchstart', onUserTouch, true);
1420
+ document.addEventListener('mousedown', onUserTouch, true);
1421
+ }
1422
+ document.addEventListener('visibilitychange', function(){
1423
+ if (!document.hidden){
1424
+ ensureConnected();
1425
+ scheduleResize();
1426
+ try { term.focus(); } catch(e){}
1427
+ armRefocus();
1428
+ }
1429
+ });
1430
+ addEventListener('pageshow', function(){
1431
+ ensureConnected();
1432
+ scheduleResize();
1433
+ try { term.focus(); } catch(e){}
1434
+ armRefocus();
1435
+ });
1436
+ })();
1437
+ </script>
1438
+ </body></html>`;
1439
+ }
1440
+
1441
+ // ---------- auth ----------
1442
+
1443
+ const COOKIE_NAME = 'llmuxd_token';
1444
+ const COOKIE_RE = new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`);
1445
+
1446
+ function isLocalhost(req: IncomingMessage): boolean {
1447
+ const ra = req.socket.remoteAddress;
1448
+ return ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1';
1449
+ }
1450
+
1451
+ function extractToken(req: IncomingMessage): string | undefined {
1452
+ const auth = req.headers['authorization'];
1453
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
1454
+ return auth.slice('Bearer '.length).trim();
1455
+ }
1456
+ const cookie = req.headers['cookie'];
1457
+ if (typeof cookie === 'string') {
1458
+ const m = COOKIE_RE.exec(cookie);
1459
+ if (m) return decodeURIComponent(m[1] ?? '');
1460
+ }
1461
+ return undefined;
1462
+ }
1463
+
1464
+ function extractWsToken(req: IncomingMessage, urlSearch: URLSearchParams): string | undefined {
1465
+ const fromQuery = urlSearch.get('token');
1466
+ if (fromQuery) return fromQuery;
1467
+ return extractToken(req);
1468
+ }
1469
+
1470
+ function isAuthorized(req: IncomingMessage): boolean {
1471
+ if (isLocalhost(req)) return true;
1472
+ if (!authStore.authEnabled()) return true;
1473
+ return authStore.validateAuthToken(extractToken(req));
1474
+ }
1475
+
1476
+ function isWsAuthorized(req: IncomingMessage, urlSearch: URLSearchParams): boolean {
1477
+ if (isLocalhost(req)) return true;
1478
+ if (!authStore.authEnabled()) return true;
1479
+ return authStore.validateAuthToken(extractWsToken(req, urlSearch));
1480
+ }
1481
+
1482
+ function gatePage(reason: 'missing' | 'invalid'): string {
1483
+ const message =
1484
+ reason === 'invalid' ? 'Token rejected. Try again.' : 'This llmuxd instance requires a token.';
1485
+ return `<!doctype html><html lang="en"><head>
1486
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1487
+ <title>llmuxd — auth</title>
1488
+ <link rel="icon" href="${FAVICON_DATA_URL}">
1489
+ <style>
1490
+ :root{color-scheme:dark}
1491
+ html,body{margin:0;background:#0b0c10;color:#e6e8eb;font-family:ui-monospace,monospace;font-size:14px}
1492
+ body{padding:24px;max-width:520px;margin:0 auto;min-height:100dvh;box-sizing:border-box;display:flex;flex-direction:column;justify-content:center}
1493
+ h1{font-size:18px;margin:0 0 4px;display:flex;align-items:center;gap:8px}
1494
+ h1 .brand{color:#7cc4ff;letter-spacing:.08em}
1495
+ .sub{color:#7a7f87;font-size:12px;margin-bottom:18px}
1496
+ .card{background:#11141a;border:1px solid #1f2329;border-radius:8px;padding:20px}
1497
+ label{display:block;font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
1498
+ input{width:100%;box-sizing:border-box;background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:10px;font:13px ui-monospace,monospace;outline:none}
1499
+ input:focus{border-color:#2d4a66}
1500
+ button{margin-top:14px;width:100%;background:#1c2128;color:#7cc4ff;border:1px solid #2d4a66;border-radius:6px;padding:10px 14px;font:13px ui-monospace,monospace;cursor:pointer}
1501
+ button:hover{background:#252b34}
1502
+ button:disabled{opacity:.5;cursor:wait}
1503
+ .msg{margin-top:12px;font-size:12px;color:#f85149;min-height:18px}
1504
+ .hint{margin-top:18px;font-size:11px;color:#7a7f87;line-height:1.5}
1505
+ .hint code{color:#c9d1d9;background:#0b0c10;padding:2px 5px;border-radius:3px}
1506
+ </style></head>
1507
+ <body>
1508
+ <h1><span class="brand">LLMUX</span> — auth required</h1>
1509
+ <div class="sub">${escapeHtml(message)}</div>
1510
+ <div class="card">
1511
+ <form id="auth-form">
1512
+ <label for="token">access token</label>
1513
+ <input id="token" type="password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" placeholder="sas_…" required>
1514
+ <button type="submit">unlock</button>
1515
+ <div class="msg" id="msg"></div>
1516
+ </form>
1517
+ <div class="hint">
1518
+ Generate a token on the daemon host: <code>llmuxd token create</code><br>
1519
+ The token is sent as a cookie after unlock. Localhost bypasses this gate.
1520
+ </div>
1521
+ </div>
1522
+ <script>
1523
+ (function(){
1524
+ const form = document.getElementById('auth-form');
1525
+ const input = document.getElementById('token');
1526
+ const msg = document.getElementById('msg');
1527
+ form.addEventListener('submit', async function(e){
1528
+ e.preventDefault();
1529
+ const token = input.value.trim();
1530
+ if (!token) return;
1531
+ msg.textContent = '';
1532
+ const btn = form.querySelector('button');
1533
+ btn.disabled = true;
1534
+ try {
1535
+ const r = await fetch('/api/auth', {
1536
+ method: 'POST',
1537
+ headers: { 'content-type': 'application/json' },
1538
+ body: JSON.stringify({ token })
1539
+ });
1540
+ if (!r.ok) {
1541
+ const body = await r.json().catch(function(){ return {}; });
1542
+ msg.textContent = body.error || 'token rejected';
1543
+ btn.disabled = false;
1544
+ input.focus();
1545
+ input.select();
1546
+ return;
1547
+ }
1548
+ // Cookie set by server; reload the originally requested URL so the
1549
+ // user lands where they wanted, not at /. Strip any stale ?token= from
1550
+ // the URL — if we left it, the canonical-url rule on the next request
1551
+ // would invalidate the cookie we just set (infinite gate loop).
1552
+ const params = new URLSearchParams(location.search);
1553
+ params.delete('token');
1554
+ const query = params.toString();
1555
+ location.href = location.pathname + (query ? '?' + query : '');
1556
+ } catch(err){
1557
+ msg.textContent = 'request failed: ' + (err.message || err);
1558
+ btn.disabled = false;
1559
+ }
1560
+ });
1561
+ input.focus();
1562
+ })();
1563
+ </script>
1564
+ </body></html>`;
1565
+ }
1566
+
1567
+ function sendGate(res: ServerResponse, reason: 'missing' | 'invalid' = 'missing'): void {
1568
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8' });
1569
+ res.end(gatePage(reason));
1570
+ }
1571
+
1572
+ function buildCookie(token: string): string {
1573
+ // Session cookie — clears on browser exit. HttpOnly so JS can't lift it.
1574
+ // SameSite=Lax so the cookie travels on normal navigations.
1575
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`;
1576
+ }
1577
+
1578
+ async function readJsonBody(req: IncomingMessage, limit = 64 * 1024): Promise<unknown> {
1579
+ return new Promise((resolve, reject) => {
1580
+ let size = 0;
1581
+ const chunks: Buffer[] = [];
1582
+ req.on('data', (chunk: Buffer) => {
1583
+ size += chunk.length;
1584
+ if (size > limit) {
1585
+ req.destroy();
1586
+ reject(new Error('body too large'));
1587
+ return;
1588
+ }
1589
+ chunks.push(chunk);
1590
+ });
1591
+ req.on('end', () => {
1592
+ try {
1593
+ const text = Buffer.concat(chunks).toString('utf8');
1594
+ resolve(text ? JSON.parse(text) : {});
1595
+ } catch (e) {
1596
+ reject(e);
1597
+ }
1598
+ });
1599
+ req.on('error', reject);
1600
+ });
1601
+ }
1602
+
1603
+ // ---------- helpers ----------
1604
+
1605
+ function sendHtml(res: ServerResponse, body: string, status = 200): void {
1606
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
1607
+ res.end(body);
1608
+ }
1609
+
1610
+ function sendText(res: ServerResponse, body: string, status = 200): void {
1611
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
1612
+ res.end(body);
1613
+ }
1614
+
1615
+ function sendJson(res: ServerResponse, body: unknown, status = 200): void {
1616
+ res.writeHead(status, { 'content-type': 'application/json' });
1617
+ res.end(JSON.stringify(body));
1618
+ }
1619
+
1620
+ // ---------- API actions ----------
1621
+
1622
+ function buildAgentCommand(
1623
+ agent: AgentDefinition,
1624
+ flagsOverride?: string,
1625
+ resumeFrom?: string,
1626
+ ): string {
1627
+ const flags = flagsOverride !== undefined ? flagsOverride : (agent.flags ?? '');
1628
+ const resumeFragment = resumeFrom && agent.history ? agent.history.resumeFlag(resumeFrom) : '';
1629
+ const tail = [flags, resumeFragment].filter((s) => s.length > 0).join(' ');
1630
+ return tail ? `${agent.cmd} ${tail}` : agent.cmd;
1631
+ }
1632
+
1633
+ function viewOf(s: state.SessionState, live: boolean): SessionView {
1634
+ const agentDef = DEFAULT_AGENTS[s.agent];
1635
+ let conversationCount = 0;
1636
+ if (agentDef?.history) {
1637
+ try {
1638
+ conversationCount = agentDef.history.listConversations(s.cwd).length;
1639
+ } catch {
1640
+ conversationCount = 0;
1641
+ }
1642
+ }
1643
+ return {
1644
+ name: s.name,
1645
+ agent: s.agent,
1646
+ cwd: s.cwd,
1647
+ cwdDisplay: shortenCwd(s.cwd),
1648
+ ...(s.flags !== undefined ? { flags: s.flags } : {}),
1649
+ defaultFlags: agentDef?.flags ?? '',
1650
+ ...(s.env !== undefined ? { env: s.env } : {}),
1651
+ defaultEnv: agentDef?.envDefaults ?? {},
1652
+ ...(s.resumeFrom !== undefined ? { resumeFrom: s.resumeFrom } : {}),
1653
+ hasHistory: Boolean(agentDef?.history),
1654
+ conversationCount,
1655
+ createdAt: s.createdAt,
1656
+ parent: s.parent,
1657
+ status: live ? 'running' : 'exited',
1658
+ };
1659
+ }
1660
+
1661
+ /**
1662
+ * Parse a multi-line "KEY=VALUE" text blob into Record<string, string>.
1663
+ * Skips blank lines and comments (lines starting with #). Trims whitespace
1664
+ * around the key. Value is kept verbatim after the first `=`.
1665
+ */
1666
+ function parseEnvText(text: string): Record<string, string> {
1667
+ const out: Record<string, string> = {};
1668
+ for (const raw of text.split(/\r?\n/)) {
1669
+ const line = raw.trim();
1670
+ if (!line || line.startsWith('#')) continue;
1671
+ const eq = line.indexOf('=');
1672
+ if (eq <= 0) continue;
1673
+ const key = line.slice(0, eq).trim();
1674
+ if (!key) continue;
1675
+ out[key] = line.slice(eq + 1);
1676
+ }
1677
+ return out;
1678
+ }
1679
+
1680
+ /** Serialize Record<string,string> back to a KEY=VALUE\n blob (stable key order). */
1681
+ function serializeEnv(env: Record<string, string>): string {
1682
+ return Object.keys(env)
1683
+ .sort()
1684
+ .map((k) => `${k}=${env[k]}`)
1685
+ .join('\n');
1686
+ }
1687
+
1688
+ /** Merge order: agent defaults < session override < the LLMUX_* internals. */
1689
+ function mergeSpawnEnv(agent: AgentDefinition, sessionEnv: Record<string, string> | undefined, llmuxEnv: Record<string, string>): Record<string, string> {
1690
+ return { ...(agent.envDefaults ?? {}), ...(sessionEnv ?? {}), ...llmuxEnv };
1691
+ }
1692
+
1693
+ const SESSION_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
1694
+
1695
+ function createSession(input: { agent: string; name?: string; cwd?: string; flags?: string; env?: string; resumeFrom?: string }):
1696
+ | { ok: true; session: SessionView }
1697
+ | { ok: false; error: string } {
1698
+ if (!input.agent) return { ok: false, error: 'agent is required' };
1699
+ const agentDef = DEFAULT_AGENTS[input.agent];
1700
+ if (!agentDef) return { ok: false, error: `unknown agent "${input.agent}"` };
1701
+ if (!isAgentInstalled(agentDef)) return { ok: false, error: `agent "${input.agent}" is not installed on the daemon host` };
1702
+
1703
+ const name = (input.name && input.name.trim()) || agentDef.key;
1704
+ if (!SESSION_NAME_RE.test(name)) {
1705
+ return { ok: false, error: 'name must start alphanumeric and contain only letters, numbers, _ or -' };
1706
+ }
1707
+ if (state.get(name) || tmux.hasSession(name)) {
1708
+ return { ok: false, error: `session "${name}" already exists` };
1709
+ }
1710
+
1711
+ const cwdRaw = (input.cwd && input.cwd.trim()) || process.env.HOME || process.cwd();
1712
+ const cwd = expandTilde(cwdRaw);
1713
+ if (!existsSync(cwd)) return { ok: false, error: `cwd does not exist: ${cwdRaw}` };
1714
+
1715
+ // flags semantics:
1716
+ // input.flags === undefined → no override; use agent default at spawn, don't persist
1717
+ // input.flags === string → explicit override, including empty string ("no flags")
1718
+ const flagsOverride: string | undefined =
1719
+ input.flags !== undefined ? input.flags.trim() : undefined;
1720
+
1721
+ // env semantics: same model — undefined = no override; string = parse + persist.
1722
+ const envOverride: Record<string, string> | undefined =
1723
+ input.env !== undefined ? parseEnvText(input.env) : undefined;
1724
+
1725
+ // resumeFrom: optional conversation id. Only valid if the agent has a
1726
+ // history adapter; otherwise we silently drop it (don't fail).
1727
+ const resumeFrom = input.resumeFrom && agentDef.history ? input.resumeFrom : undefined;
1728
+
1729
+ try {
1730
+ tmux.newSession({
1731
+ name,
1732
+ command: buildAgentCommand(agentDef, flagsOverride, resumeFrom),
1733
+ cwd,
1734
+ env: mergeSpawnEnv(agentDef, envOverride, { LLMUX_SESSION: name, LLMUX_AGENT: agentDef.key }),
1735
+ });
1736
+ } catch (err) {
1737
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1738
+ }
1739
+
1740
+ const session: state.SessionState = {
1741
+ name,
1742
+ agent: agentDef.key,
1743
+ cwd,
1744
+ ...(flagsOverride !== undefined ? { flags: flagsOverride } : {}),
1745
+ ...(envOverride !== undefined ? { env: envOverride } : {}),
1746
+ ...(resumeFrom !== undefined ? { resumeFrom } : {}),
1747
+ createdAt: new Date().toISOString(),
1748
+ parent: null,
1749
+ restart: 'on-failure',
1750
+ };
1751
+ state.record(session);
1752
+
1753
+ return { ok: true, session: viewOf(session, true) };
1754
+ }
1755
+
1756
+ function respawnSession(name: string): { ok: true; session: SessionView } | { ok: false; error: string } {
1757
+ const session = state.get(name);
1758
+ if (!session) return { ok: false, error: `no tracked session "${name}"` };
1759
+ const agent = DEFAULT_AGENTS[session.agent];
1760
+ if (!agent) return { ok: false, error: `unknown agent "${session.agent}"` };
1761
+ if (!isAgentInstalled(agent)) return { ok: false, error: `agent "${session.agent}" is not installed` };
1762
+ // If still running, kill first so the new spawn picks up any name/cwd/flags
1763
+ // edits the operator has made since the last spawn.
1764
+ if (tmux.hasSession(name)) {
1765
+ try {
1766
+ tmux.killSession(name);
1767
+ } catch (err) {
1768
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1769
+ }
1770
+ }
1771
+ try {
1772
+ tmux.newSession({
1773
+ name: session.name,
1774
+ command: buildAgentCommand(agent, session.flags, session.resumeFrom),
1775
+ cwd: session.cwd,
1776
+ env: mergeSpawnEnv(agent, session.env, { LLMUX_SESSION: session.name, LLMUX_AGENT: session.agent }),
1777
+ });
1778
+ } catch (err) {
1779
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1780
+ }
1781
+ const refreshed: state.SessionState = { ...session, createdAt: new Date().toISOString() };
1782
+ state.record(refreshed);
1783
+ return { ok: true, session: viewOf(refreshed, true) };
1784
+ }
1785
+
1786
+ function resumeConversation(
1787
+ name: string,
1788
+ conversationId: string,
1789
+ ): { ok: true; session: SessionView } | { ok: false; error: string } {
1790
+ const session = state.get(name);
1791
+ if (!session) return { ok: false, error: `no tracked session "${name}"` };
1792
+ const agent = DEFAULT_AGENTS[session.agent];
1793
+ if (!agent) return { ok: false, error: `unknown agent "${session.agent}"` };
1794
+ if (!agent.history) return { ok: false, error: `agent "${session.agent}" has no history adapter` };
1795
+ if (!isAgentInstalled(agent)) return { ok: false, error: `agent "${session.agent}" is not installed` };
1796
+
1797
+ // Kill the live session if any — switching conversations is destructive to
1798
+ // the current in-process agent state by definition. State (name, cwd,
1799
+ // flags, env) is preserved across the respawn.
1800
+ if (tmux.hasSession(name)) {
1801
+ try {
1802
+ tmux.killSession(name);
1803
+ } catch (err) {
1804
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1805
+ }
1806
+ }
1807
+
1808
+ try {
1809
+ tmux.newSession({
1810
+ name: session.name,
1811
+ command: buildAgentCommand(agent, session.flags, conversationId),
1812
+ cwd: session.cwd,
1813
+ env: mergeSpawnEnv(agent, session.env, { LLMUX_SESSION: session.name, LLMUX_AGENT: session.agent }),
1814
+ });
1815
+ } catch (err) {
1816
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1817
+ }
1818
+
1819
+ const refreshed: state.SessionState = {
1820
+ ...session,
1821
+ resumeFrom: conversationId,
1822
+ createdAt: new Date().toISOString(),
1823
+ };
1824
+ state.record(refreshed);
1825
+ return { ok: true, session: viewOf(refreshed, true) };
1826
+ }
1827
+
1828
+ function editSession(
1829
+ oldName: string,
1830
+ patch: { name?: string; cwd?: string; flags?: string; env?: string },
1831
+ ): { ok: true; session: SessionView } | { ok: false; error: string } {
1832
+ const session = state.get(oldName);
1833
+ if (!session) return { ok: false, error: `no tracked session "${oldName}"` };
1834
+
1835
+ // Build the new record. Validate first, mutate last.
1836
+ const newName = patch.name?.trim();
1837
+ const newCwd = patch.cwd?.trim();
1838
+
1839
+ if (newName !== undefined && newName !== oldName) {
1840
+ if (!SESSION_NAME_RE.test(newName)) {
1841
+ return { ok: false, error: 'name must start alphanumeric and contain only letters, numbers, _ or -' };
1842
+ }
1843
+ if (state.get(newName) || tmux.hasSession(newName)) {
1844
+ return { ok: false, error: `session "${newName}" already exists` };
1845
+ }
1846
+ }
1847
+
1848
+ if (newCwd !== undefined && newCwd.length > 0 && !existsSync(expandTilde(newCwd))) {
1849
+ return { ok: false, error: `cwd does not exist: ${newCwd}` };
1850
+ }
1851
+
1852
+ const renaming = newName !== undefined && newName !== oldName && newName.length > 0;
1853
+
1854
+ if (renaming) {
1855
+ try {
1856
+ tmux.renameSession(oldName, newName!);
1857
+ } catch (err) {
1858
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1859
+ }
1860
+ }
1861
+
1862
+ // flags semantics on patch:
1863
+ // patch.flags === undefined → no change (preserve existing)
1864
+ // patch.flags === string → set as override (explicit, including '')
1865
+ // The form always sends flags so users can save "no flags" by clearing the
1866
+ // input. To revert to agent default, retype the default value.
1867
+ const nextFlags = patch.flags !== undefined ? patch.flags.trim() : session.flags;
1868
+
1869
+ // env semantics: undefined → no change; string → parse + set as override
1870
+ // (including empty → empty override = "no extra env vars beyond the LLMUX_*").
1871
+ const nextEnv = patch.env !== undefined ? parseEnvText(patch.env) : session.env;
1872
+
1873
+ const updated: state.SessionState = {
1874
+ name: renaming ? newName! : oldName,
1875
+ agent: session.agent,
1876
+ cwd: newCwd !== undefined && newCwd.length > 0 ? expandTilde(newCwd) : session.cwd,
1877
+ ...(nextFlags !== undefined ? { flags: nextFlags } : {}),
1878
+ ...(nextEnv !== undefined ? { env: nextEnv } : {}),
1879
+ ...(session.resumeFrom !== undefined ? { resumeFrom: session.resumeFrom } : {}),
1880
+ createdAt: session.createdAt,
1881
+ parent: session.parent,
1882
+ restart: session.restart,
1883
+ };
1884
+
1885
+ if (renaming) state.forget(oldName);
1886
+ state.record(updated);
1887
+
1888
+ // cwd is set at tmux fork time and can't be changed on a live session — the
1889
+ // operator's edit would otherwise be invisible until they manually respawn.
1890
+ // Auto kill+respawn when cwd actually changed and the session is running.
1891
+ const cwdChanged =
1892
+ newCwd !== undefined && newCwd.length > 0 && updated.cwd !== session.cwd;
1893
+ if (cwdChanged && tmux.hasSession(updated.name)) {
1894
+ const agent = DEFAULT_AGENTS[updated.agent];
1895
+ if (agent && isAgentInstalled(agent)) {
1896
+ try {
1897
+ tmux.killSession(updated.name);
1898
+ tmux.newSession({
1899
+ name: updated.name,
1900
+ command: buildAgentCommand(agent, updated.flags, updated.resumeFrom),
1901
+ cwd: updated.cwd,
1902
+ env: mergeSpawnEnv(agent, updated.env, { LLMUX_SESSION: updated.name, LLMUX_AGENT: updated.agent }),
1903
+ });
1904
+ // Refresh createdAt so the picker's "started Xm ago" reflects the
1905
+ // actual moment the agent process began running with the new cwd.
1906
+ const refreshed: state.SessionState = { ...updated, createdAt: new Date().toISOString() };
1907
+ state.record(refreshed);
1908
+ return { ok: true, session: viewOf(refreshed, true) };
1909
+ } catch (err) {
1910
+ return { ok: false, error: `cwd updated but restart failed: ${err instanceof Error ? err.message : String(err)}` };
1911
+ }
1912
+ }
1913
+ }
1914
+
1915
+ const live = tmux.listSessions().some((s) => s.name === updated.name);
1916
+ return { ok: true, session: viewOf(updated, live) };
1917
+ }
1918
+
1919
+ function killSession(name: string): { ok: true; status: 'running' | 'exited' } | { ok: false; error: string } {
1920
+ const session = state.get(name);
1921
+ if (!session) return { ok: false, error: `no tracked session "${name}"` };
1922
+ const wasRunning = tmux.hasSession(name);
1923
+ try {
1924
+ tmux.killSession(name);
1925
+ } catch (err) {
1926
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1927
+ }
1928
+ state.forget(name);
1929
+ return { ok: true, status: wasRunning ? 'running' : 'exited' };
1930
+ }
1931
+
1932
+ // ---------- server ----------
1933
+
1934
+ export interface ServerHandle {
1935
+ port: number;
1936
+ stop: () => Promise<void>;
1937
+ }
1938
+
1939
+ const RESPAWN_RE = /^\/api\/sessions\/([^/]+)\/respawn$/;
1940
+ const KILL_RE = /^\/api\/sessions\/([^/]+)\/kill$/;
1941
+ const RESUME_RE = /^\/api\/sessions\/([^/]+)\/resume$/;
1942
+ const SEND_RE = /^\/api\/sessions\/([^/]+)\/send$/;
1943
+ const CONVERSATIONS_RE = /^\/api\/sessions\/([^/]+)\/conversations$/;
1944
+ const EDIT_RE = /^\/api\/sessions\/([^/]+)$/;
1945
+
1946
+ export function startServer(opts: ServeOptions): ServerHandle {
1947
+ const http = createServer(async (req: IncomingMessage, res: ServerResponse) => {
1948
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1949
+ const method = req.method ?? 'GET';
1950
+
1951
+ // ---- Deep-link auth: ?token=<sas> on any path ----
1952
+ // When ?token= is present, the URL is canonical — it overrides any existing
1953
+ // cookie. Valid → 302 + set cookie + clean redirect. Invalid → clear the
1954
+ // cookie (so a stale prior session doesn't mask the rejection) + serve the
1955
+ // gate so the test is visible.
1956
+ const queryToken = url.searchParams.get('token');
1957
+ if (queryToken) {
1958
+ if (authStore.validateAuthToken(queryToken)) {
1959
+ url.searchParams.delete('token');
1960
+ const cleanPath = url.pathname + (url.searchParams.toString() ? '?' + url.searchParams.toString() : '');
1961
+ res.writeHead(302, {
1962
+ location: cleanPath,
1963
+ 'set-cookie': buildCookie(queryToken),
1964
+ });
1965
+ return res.end();
1966
+ }
1967
+ res.writeHead(401, {
1968
+ 'content-type': 'text/html; charset=utf-8',
1969
+ 'set-cookie': `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
1970
+ });
1971
+ return res.end(gatePage('invalid'));
1972
+ }
1973
+
1974
+ // ---- Always-open endpoints (no auth required) ----
1975
+ if (url.pathname === '/health') {
1976
+ return sendJson(res, {
1977
+ ok: true,
1978
+ version: DAEMON_VERSION,
1979
+ sessions: state.list().length,
1980
+ authEnabled: authStore.authEnabled(),
1981
+ });
1982
+ }
1983
+ if (url.pathname === '/api/version' && method === 'GET') {
1984
+ return sendJson(res, { version: DAEMON_VERSION });
1985
+ }
1986
+
1987
+ // ---- Auth gate (POST /api/auth, no prior auth required) ----
1988
+ if (url.pathname === '/api/auth' && method === 'POST') {
1989
+ try {
1990
+ const body = (await readJsonBody(req)) as { token?: unknown };
1991
+ const candidate = typeof body.token === 'string' ? body.token : '';
1992
+ if (!authStore.validateAuthToken(candidate)) {
1993
+ return sendJson(res, { ok: false, error: 'invalid token' }, 401);
1994
+ }
1995
+ res.writeHead(200, {
1996
+ 'content-type': 'application/json',
1997
+ 'set-cookie': buildCookie(candidate),
1998
+ });
1999
+ return res.end(JSON.stringify({ ok: true }));
2000
+ } catch (err) {
2001
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : 'bad request' }, 400);
2002
+ }
2003
+ }
2004
+
2005
+ // ---- Auth check for everything else ----
2006
+ if (!isAuthorized(req)) {
2007
+ // HTML routes get the gate page; API routes get 401 JSON.
2008
+ const isApi = url.pathname.startsWith('/api/');
2009
+ if (isApi) {
2010
+ return sendJson(res, { ok: false, error: 'unauthorized' }, 401);
2011
+ }
2012
+ const hasInvalidToken = Boolean(extractToken(req));
2013
+ return sendGate(res, hasInvalidToken ? 'invalid' : 'missing');
2014
+ }
2015
+
2016
+ // ---- API ----
2017
+ if (url.pathname === '/api/sessions' && method === 'GET') {
2018
+ return sendJson(res, listSessionViews());
2019
+ }
2020
+ if (url.pathname === '/api/agents' && method === 'GET') {
2021
+ const installed = Object.entries(DEFAULT_AGENTS)
2022
+ .filter(([, def]) => isAgentInstalled(def))
2023
+ .map(([key, def]) => ({
2024
+ key,
2025
+ displayName: def.displayName,
2026
+ cmd: def.cmd,
2027
+ flags: def.flags ?? '',
2028
+ envDefaults: def.envDefaults ?? {},
2029
+ }));
2030
+ return sendJson(res, installed);
2031
+ }
2032
+ if (url.pathname === '/api/agents/all' && method === 'GET') {
2033
+ const all = Object.entries(DEFAULT_AGENTS).map(([key, def]) => ({
2034
+ key,
2035
+ displayName: def.displayName,
2036
+ cmd: def.cmd,
2037
+ installed: isAgentInstalled(def),
2038
+ installHint: def.installHint ?? '',
2039
+ docsUrl: def.docsUrl ?? '',
2040
+ }));
2041
+ return sendJson(res, all);
2042
+ }
2043
+ if (url.pathname === '/api/sessions' && method === 'POST') {
2044
+ try {
2045
+ const body = (await readJsonBody(req)) as { agent?: unknown; name?: unknown; cwd?: unknown; flags?: unknown; env?: unknown; resumeFrom?: unknown };
2046
+ const result = createSession({
2047
+ agent: typeof body.agent === 'string' ? body.agent : '',
2048
+ ...(typeof body.name === 'string' ? { name: body.name } : {}),
2049
+ ...(typeof body.cwd === 'string' ? { cwd: body.cwd } : {}),
2050
+ ...(typeof body.flags === 'string' ? { flags: body.flags } : {}),
2051
+ ...(typeof body.env === 'string' ? { env: body.env } : {}),
2052
+ ...(typeof body.resumeFrom === 'string' ? { resumeFrom: body.resumeFrom } : {}),
2053
+ });
2054
+ return sendJson(res, result, result.ok ? 200 : 400);
2055
+ } catch (err) {
2056
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : 'bad request' }, 400);
2057
+ }
2058
+ }
2059
+ if (method === 'POST') {
2060
+ const mRespawn = url.pathname.match(RESPAWN_RE);
2061
+ if (mRespawn) {
2062
+ const name = decodeURIComponent(mRespawn[1]!);
2063
+ const result = respawnSession(name);
2064
+ return sendJson(res, result, result.ok ? 200 : 400);
2065
+ }
2066
+ const mKill = url.pathname.match(KILL_RE);
2067
+ if (mKill) {
2068
+ const name = decodeURIComponent(mKill[1]!);
2069
+ const result = killSession(name);
2070
+ return sendJson(res, result, result.ok ? 200 : 400);
2071
+ }
2072
+ const mSend = url.pathname.match(SEND_RE);
2073
+ if (mSend) {
2074
+ const name = decodeURIComponent(mSend[1]!);
2075
+ try {
2076
+ const body = (await readJsonBody(req)) as { prompt?: unknown; enter?: unknown };
2077
+ if (typeof body.prompt !== 'string' || body.prompt.length === 0) {
2078
+ return sendJson(res, { ok: false, error: 'prompt required' }, 400);
2079
+ }
2080
+ if (!state.get(name)) return sendJson(res, { ok: false, error: `no tracked session "${name}"` }, 404);
2081
+ if (!tmux.hasSession(name)) return sendJson(res, { ok: false, error: `session "${name}" is not running` }, 409);
2082
+ const enter = body.enter !== false; // default true; explicitly false to suppress
2083
+ try {
2084
+ tmux.sendKeys(name, body.prompt, { enter });
2085
+ } catch (err) {
2086
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500);
2087
+ }
2088
+ return sendJson(res, { ok: true });
2089
+ } catch (err) {
2090
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : 'bad request' }, 400);
2091
+ }
2092
+ }
2093
+ const mResume = url.pathname.match(RESUME_RE);
2094
+ if (mResume) {
2095
+ const name = decodeURIComponent(mResume[1]!);
2096
+ try {
2097
+ const body = (await readJsonBody(req)) as { conversationId?: unknown };
2098
+ if (typeof body.conversationId !== 'string' || body.conversationId.length === 0) {
2099
+ return sendJson(res, { ok: false, error: 'conversationId required' }, 400);
2100
+ }
2101
+ const result = resumeConversation(name, body.conversationId);
2102
+ return sendJson(res, result, result.ok ? 200 : 400);
2103
+ } catch (err) {
2104
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : 'bad request' }, 400);
2105
+ }
2106
+ }
2107
+ }
2108
+ if (method === 'GET') {
2109
+ const mConvs = url.pathname.match(CONVERSATIONS_RE);
2110
+ if (mConvs) {
2111
+ const name = decodeURIComponent(mConvs[1]!);
2112
+ const session = state.get(name);
2113
+ if (!session) return sendJson(res, { ok: false, error: 'session not found' }, 404);
2114
+ const agent = DEFAULT_AGENTS[session.agent];
2115
+ if (!agent?.history) return sendJson(res, []);
2116
+ try {
2117
+ const convs: Conversation[] = agent.history.listConversations(session.cwd);
2118
+ return sendJson(res, convs);
2119
+ } catch (err) {
2120
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : 'history read failed' }, 500);
2121
+ }
2122
+ }
2123
+ }
2124
+ if (method === 'PATCH') {
2125
+ const mEdit = url.pathname.match(EDIT_RE);
2126
+ if (mEdit) {
2127
+ const name = decodeURIComponent(mEdit[1]!);
2128
+ try {
2129
+ const body = (await readJsonBody(req)) as { name?: unknown; cwd?: unknown; flags?: unknown; env?: unknown };
2130
+ const result = editSession(name, {
2131
+ ...(typeof body.name === 'string' ? { name: body.name } : {}),
2132
+ ...(typeof body.cwd === 'string' ? { cwd: body.cwd } : {}),
2133
+ ...(typeof body.flags === 'string' ? { flags: body.flags } : {}),
2134
+ ...(typeof body.env === 'string' ? { env: body.env } : {}),
2135
+ });
2136
+ return sendJson(res, result, result.ok ? 200 : 400);
2137
+ } catch (err) {
2138
+ return sendJson(res, { ok: false, error: err instanceof Error ? err.message : 'bad request' }, 400);
2139
+ }
2140
+ }
2141
+ }
2142
+
2143
+ // ---- Pages ----
2144
+ if (url.pathname === '/') {
2145
+ return sendHtml(res, pickerPage());
2146
+ }
2147
+ if (url.pathname.startsWith('/session/')) {
2148
+ const name = decodeURIComponent(url.pathname.slice('/session/'.length));
2149
+ const session = state.get(name);
2150
+ if (!session) return sendText(res, 'session not found', 404);
2151
+ // If tmux doesn't have it, serve the dead-session page instead of the chat
2152
+ // (which would immediately disconnect when pty.spawn('tmux attach …') fails).
2153
+ if (!tmux.hasSession(name)) {
2154
+ return sendHtml(res, deadSessionPage(viewOf(session, false)));
2155
+ }
2156
+ return sendHtml(res, sessionPage(name));
2157
+ }
2158
+ return sendText(res, 'not found', 404);
2159
+ });
2160
+
2161
+ const wss = new WebSocketServer({ noServer: true });
2162
+
2163
+ http.on('upgrade', (req, socket, head) => {
2164
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
2165
+ if (!url.pathname.startsWith('/ws/')) {
2166
+ socket.destroy();
2167
+ return;
2168
+ }
2169
+ if (!isWsAuthorized(req, url.searchParams)) {
2170
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
2171
+ socket.destroy();
2172
+ return;
2173
+ }
2174
+ const name = decodeURIComponent(url.pathname.slice('/ws/'.length));
2175
+ if (!state.get(name)) {
2176
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2177
+ socket.destroy();
2178
+ return;
2179
+ }
2180
+ if (!tmux.hasSession(name)) {
2181
+ socket.write('HTTP/1.1 409 Conflict\r\n\r\n');
2182
+ socket.destroy();
2183
+ return;
2184
+ }
2185
+ wss.handleUpgrade(req, socket, head, (ws) => attachSession(ws, name));
2186
+ });
2187
+
2188
+ http.listen(opts.port, opts.host);
2189
+
2190
+ return {
2191
+ port: opts.port,
2192
+ stop: () =>
2193
+ new Promise<void>((resolve) => {
2194
+ wss.close(() => http.close(() => resolve()));
2195
+ }),
2196
+ };
2197
+ }
2198
+
2199
+ function attachSession(ws: WebSocket, sessionName: string): void {
2200
+ const env: Record<string, string> = {};
2201
+ for (const [k, v] of Object.entries(process.env)) {
2202
+ if (v === undefined) continue;
2203
+ if (k === 'TMUX' || k === 'TMUX_PANE') continue;
2204
+ env[k] = v;
2205
+ }
2206
+ env.TERM = 'xterm-256color';
2207
+
2208
+ let term: IPty | null = null;
2209
+ try {
2210
+ term = pty.spawn('tmux', ['attach', '-t', sessionName], {
2211
+ name: 'xterm-256color',
2212
+ cols: 80,
2213
+ rows: 24,
2214
+ cwd: process.env.HOME ?? process.cwd(),
2215
+ env,
2216
+ });
2217
+ } catch (err) {
2218
+ ws.close(4040, `spawn failed: ${err instanceof Error ? err.message : String(err)}`);
2219
+ return;
2220
+ }
2221
+
2222
+ term.onData((d) => {
2223
+ try {
2224
+ ws.send(d);
2225
+ } catch {
2226
+ term?.kill();
2227
+ }
2228
+ });
2229
+
2230
+ term.onExit(({ exitCode, signal }) => {
2231
+ try {
2232
+ // 4040 is our app-level "session ended" signal (4xxx is application range)
2233
+ // so the client distinguishes it from a transient network drop.
2234
+ ws.close(4040, `pty exited code=${exitCode} signal=${signal ?? 'none'}`);
2235
+ } catch {
2236
+ // already closed
2237
+ }
2238
+ });
2239
+
2240
+ ws.on('message', (raw: Buffer | ArrayBuffer | string, isBinary: boolean) => {
2241
+ if (!term) return;
2242
+ const text = typeof raw === 'string' ? raw : Buffer.isBuffer(raw) ? raw.toString('utf8') : Buffer.from(raw as ArrayBuffer).toString('utf8');
2243
+ if (!isBinary && text.startsWith('{')) {
2244
+ try {
2245
+ const parsed = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
2246
+ if (parsed.type === 'resize' && typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
2247
+ term.resize(parsed.cols, parsed.rows);
2248
+ return;
2249
+ }
2250
+ } catch {
2251
+ // fall through
2252
+ }
2253
+ }
2254
+ term.write(text);
2255
+ });
2256
+
2257
+ ws.on('close', () => {
2258
+ term?.kill();
2259
+ term = null;
2260
+ });
2261
+ }
2262
+
2263
+ export function printBanner(port: number): void {
2264
+ console.log(`llmuxd v${DAEMON_VERSION}\n`);
2265
+ const addrs = getAddresses(port);
2266
+ const width = Math.max(10, ...addrs.map((a) => a.label.length + 2));
2267
+ for (const addr of addrs) {
2268
+ console.log(` ▸ ${addr.label.padEnd(width)}${addr.url}`);
2269
+ }
2270
+ if (authStore.authEnabled()) {
2271
+ const count = authStore.listAuthTokens().length;
2272
+ console.log(`\n ✓ auth required — ${count} active token${count === 1 ? '' : 's'} (localhost bypasses)\n`);
2273
+ } else {
2274
+ console.log(`\n ⚠ running without auth — anyone on the network can attach.`);
2275
+ console.log(` create a token with \`llmuxd token create\` to enable auth.\n`);
2276
+ }
2277
+ }