@idl3/claude-control 0.1.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/bin/cli.js +68 -0
  4. package/bin/install-service.sh +107 -0
  5. package/bin/self-update.sh +43 -0
  6. package/bin/uninstall-service.sh +22 -0
  7. package/lib/answer.js +64 -0
  8. package/lib/auth.js +81 -0
  9. package/lib/config.js +118 -0
  10. package/lib/push.js +153 -0
  11. package/lib/resources.js +137 -0
  12. package/lib/sessions.js +529 -0
  13. package/lib/terminal.js +278 -0
  14. package/lib/tmux.js +462 -0
  15. package/lib/transcript.js +451 -0
  16. package/lib/tui.js +50 -0
  17. package/lib/uploads.js +42 -0
  18. package/lib/version.js +73 -0
  19. package/package.json +49 -0
  20. package/public/app.js +756 -0
  21. package/public/index.html +120 -0
  22. package/public/styles.css +848 -0
  23. package/server.js +910 -0
  24. package/web/README.md +66 -0
  25. package/web/dist/apple-touch-icon.png +0 -0
  26. package/web/dist/assets/bash-I8pq0VWm.js +1 -0
  27. package/web/dist/assets/core-BYJcZW10.js +3 -0
  28. package/web/dist/assets/css-DazXZka4.js +1 -0
  29. package/web/dist/assets/diff-DiTmLxSS.js +1 -0
  30. package/web/dist/assets/index-Bb7gXgl-.css +1 -0
  31. package/web/dist/assets/index-wrjqfzbL.js +77 -0
  32. package/web/dist/assets/javascript-BKRaQes9.js +1 -0
  33. package/web/dist/assets/json-DIYVocXf.js +1 -0
  34. package/web/dist/assets/markdown-BrP960CR.js +1 -0
  35. package/web/dist/assets/python-sE43i1Pi.js +1 -0
  36. package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
  37. package/web/dist/assets/xml-BXBhIUeX.js +1 -0
  38. package/web/dist/icon-192.png +0 -0
  39. package/web/dist/icon-512.png +0 -0
  40. package/web/dist/index.html +25 -0
  41. package/web/dist/manifest.webmanifest +25 -0
  42. package/web/dist/sw.js +57 -0
package/server.js ADDED
@@ -0,0 +1,910 @@
1
+ #!/usr/bin/env node
2
+ // claude-control — HTTP + WebSocket integrator.
3
+ // Wires tmux discovery, transcript tailing, AskUserQuestion answering, and resource
4
+ // monitoring into a localhost web UI. Bind 127.0.0.1 only; never shell out with user text.
5
+
6
+ import http from 'node:http';
7
+ import net from 'node:net';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import os from 'node:os';
12
+ import { spawn } from 'node:child_process';
13
+ import { WebSocketServer } from 'ws';
14
+
15
+ import * as tmux from './lib/tmux.js';
16
+ import * as terminal from './lib/terminal.js';
17
+ import { TranscriptTailer } from './lib/transcript.js';
18
+ import { SessionRegistry } from './lib/sessions.js';
19
+ import { ResourceMonitor } from './lib/resources.js';
20
+ import { buildAnswerProgram } from './lib/answer.js';
21
+ import { sweepUploads } from './lib/uploads.js';
22
+ import { getVersionInfo, currentVersion } from './lib/version.js';
23
+ import * as push from './lib/push.js';
24
+ import { readConfig, writeConfig } from './lib/config.js';
25
+ // Note: the client offers [WS_PROTOCOL, token] as subprotocols; the `ws`
26
+ // library auto-selects the FIRST offered one (the non-secret WS_PROTOCOL label)
27
+ // and echoes it, so we never reflect the raw token back and need no custom
28
+ // handleProtocols here. checkWsToken just verifies the token is among the offers.
29
+ import { checkToken as authCheckToken, checkWsToken } from './lib/auth.js';
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ // Prefer the built assistant-ui app (web/dist) when present; otherwise fall back
33
+ // to the zero-build vanilla UI in public/.
34
+ const DIST_DIR = path.join(__dirname, 'web', 'dist');
35
+ const PUBLIC_DIR = fs.existsSync(path.join(DIST_DIR, 'index.html'))
36
+ ? DIST_DIR
37
+ : path.join(__dirname, 'public');
38
+
39
+ // Env lookup: prefer CLAUDE_CONTROL_<X>, fall back to the legacy COCKPIT_<X>
40
+ // (kept so existing launchers keep working after the claude-control rename).
41
+ const env = (name) =>
42
+ process.env[`CLAUDE_CONTROL_${name}`] ?? process.env[`COCKPIT_${name}`];
43
+
44
+ // Durable token: when no token env var is set, read the persisted one at
45
+ // ~/.claude-control/token (written by bin/install-service.sh / first run). This
46
+ // keeps the same token — and therefore the same phone URL — across restarts and
47
+ // /tmp wipes, without relying on a launcher to inject the env var.
48
+ function readPersistedToken() {
49
+ try {
50
+ const t = fs.readFileSync(path.join(os.homedir(), '.claude-control', 'token'), 'utf8').trim();
51
+ return t || null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ const CONFIG = {
58
+ port: Number(env('PORT')) || 4317,
59
+ host: env('HOST') || '127.0.0.1',
60
+ projectsRoot:
61
+ env('PROJECTS') || path.join(os.homedir(), '.claude', 'projects'),
62
+ // 768MB: a long-running Node server (WS + transcript tailing + the bundled
63
+ // web app) baselines ~300-450MB of V8 heap + RSS, so the old 350MB budget
64
+ // tripped "over limit" permanently. Override with CLAUDE_CONTROL_RSS_LIMIT_MB.
65
+ rssLimitMB: Number(env('RSS_LIMIT_MB')) || 768,
66
+ token: env('TOKEN') || readPersistedToken() || null,
67
+ maxBuffer: Number(env('MAX_BUFFER')) || 500,
68
+ maxUploadMB: Number(env('MAX_UPLOAD_MB')) || 25,
69
+ uploadsDir:
70
+ env('UPLOADS') || path.join(os.homedir(), '.claude-control', 'uploads'),
71
+ uploadTtlHours: Number(env('UPLOAD_TTL_HOURS')) || 24,
72
+ };
73
+
74
+ const MIME = {
75
+ '.html': 'text/html; charset=utf-8',
76
+ '.css': 'text/css; charset=utf-8',
77
+ '.js': 'text/javascript; charset=utf-8',
78
+ '.svg': 'image/svg+xml',
79
+ '.ico': 'image/x-icon',
80
+ '.json': 'application/json; charset=utf-8',
81
+ '.png': 'image/png',
82
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
83
+ };
84
+
85
+ // Image MIME types served from the uploads route (extensions → content-type).
86
+ const IMAGE_MIME = {
87
+ '.jpg': 'image/jpeg',
88
+ '.jpeg': 'image/jpeg',
89
+ '.png': 'image/png',
90
+ '.gif': 'image/gif',
91
+ '.webp': 'image/webp',
92
+ '.avif': 'image/avif',
93
+ '.heic': 'image/heic',
94
+ '.heif': 'image/heif',
95
+ '.svg': 'image/svg+xml',
96
+ };
97
+
98
+ // --- shared state -----------------------------------------------------------
99
+ const registry = new SessionRegistry({ projectsRoot: CONFIG.projectsRoot, tmux });
100
+ const resources = new ResourceMonitor({ rssLimitMB: CONFIG.rssLimitMB });
101
+
102
+ /** id -> { tailer, clients:Set<ws>, pending } */
103
+ const subscriptions = new Map();
104
+
105
+ function sessionById(id) {
106
+ return registry.getSessions().find((s) => s.id === id) || null;
107
+ }
108
+
109
+ // Authenticate an HTTP/API request: the token rides `Authorization: Bearer
110
+ // <token>` (NOT the URL). Tokenless server → always authorized. Thin wrapper
111
+ // over lib/auth so CONFIG.token isn't threaded through every call site.
112
+ function checkToken(req) {
113
+ return authCheckToken(req, CONFIG.token);
114
+ }
115
+
116
+ // ttyd exception: the raw-terminal surface is opened with `window.open` to a
117
+ // separately-proxied URL and CANNOT send an Authorization header, so it keeps a
118
+ // `?token=` in its own URL. This gate reads the token from the query string for
119
+ // /term/* requests ONLY — the rest of the app is header/subprotocol-based.
120
+ // Tokenless server → always authorized.
121
+ function checkTerminalToken(reqUrl) {
122
+ if (!CONFIG.token) return true;
123
+ try {
124
+ const u = new URL(reqUrl, 'http://localhost');
125
+ return u.searchParams.get('token') === CONFIG.token;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ // Reject cross-origin WebSocket upgrades. A page at any origin can open
132
+ // ws://127.0.0.1:<port> from the user's browser; since this UI can type into
133
+ // live tmux sessions, we only accept connections from our own localhost origin
134
+ // or from non-browser clients (which send no Origin header).
135
+ function isAllowedOrigin(origin) {
136
+ if (!origin) return true; // non-browser WS client (e.g. a script)
137
+ try {
138
+ const host = new URL(origin).hostname;
139
+ if (host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1') return true;
140
+ // Tailscale MagicDNS hostnames (tailnet-private) when reached via `tailscale
141
+ // serve`. The tailnet ACL already restricts who can connect; the token gate
142
+ // is the second factor since this UI can type into live sessions.
143
+ if (host.endsWith('.ts.net')) return true;
144
+ return false;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ // --- HTTP -------------------------------------------------------------------
151
+ const server = http.createServer((req, res) => {
152
+ const u = new URL(req.url, 'http://localhost');
153
+
154
+ if (u.pathname === '/api/sessions') {
155
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
156
+ return endJson(res, 200, { sessions: registry.getSessions() });
157
+ }
158
+ if (u.pathname === '/api/health') {
159
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
160
+ return endJson(res, 200, { ok: true, snapshot: resources.snapshot() });
161
+ }
162
+ if (u.pathname === '/api/version') {
163
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
164
+ return getVersionInfo()
165
+ .then((info) => endJson(res, 200, info))
166
+ .catch(() => endJson(res, 200, { current: currentVersion(), latest: null, behind: 0, updateAvailable: false }));
167
+ }
168
+ if (u.pathname === '/api/update') {
169
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
170
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
171
+ return handleUpdate(res);
172
+ }
173
+ if (u.pathname === '/api/upload') {
174
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
175
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
176
+ return handleUpload(req, res, u);
177
+ }
178
+ if (u.pathname === '/api/push/vapid') {
179
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
180
+ return endJson(res, 200, { publicKey: push.getPublicKey() });
181
+ }
182
+ if (u.pathname === '/api/push/subscribe') {
183
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
184
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
185
+ return readJsonBody(req)
186
+ .then((sub) => {
187
+ if (!sub || typeof sub.endpoint !== 'string') {
188
+ return endJson(res, 400, { error: 'invalid subscription' });
189
+ }
190
+ push.addSubscription(sub);
191
+ return endJson(res, 200, { ok: true });
192
+ })
193
+ .catch((err) => endJson(res, 400, { error: String(err?.message || err) }));
194
+ }
195
+ if (u.pathname === '/api/push/unsubscribe') {
196
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
197
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
198
+ return readJsonBody(req)
199
+ .then((body) => {
200
+ const endpoint = typeof body?.endpoint === 'string' ? body.endpoint : null;
201
+ if (!endpoint) return endJson(res, 400, { error: 'endpoint required' });
202
+ push.removeSubscription(endpoint);
203
+ return endJson(res, 200, { ok: true });
204
+ })
205
+ .catch((err) => endJson(res, 400, { error: String(err?.message || err) }));
206
+ }
207
+ if (u.pathname === '/api/config') {
208
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
209
+ if (req.method === 'GET') return endJson(res, 200, readConfig());
210
+ if (req.method === 'POST') return handleConfigSave(req, res);
211
+ return endJson(res, 405, { error: 'method not allowed' });
212
+ }
213
+ if (u.pathname === '/api/session/new') {
214
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
215
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
216
+ return handleSessionNew(req, res);
217
+ }
218
+ if (u.pathname === '/api/session/rename') {
219
+ if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
220
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
221
+ return handleSessionRename(req, res);
222
+ }
223
+ // GET /api/uploads/<basename> — token-gated, path-traversal-guarded.
224
+ // Serves a single file from uploadsDir by basename only; no directory
225
+ // segments are allowed. Used by the React UI to render inline attachment
226
+ // previews (thumbnails + lightbox) without exposing the filesystem path.
227
+ if (u.pathname.startsWith('/api/uploads/')) {
228
+ if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
229
+ return handleServeUpload(req, res, u);
230
+ }
231
+
232
+ // Raw-terminal escape hatch: token-gated reverse proxy to an on-demand,
233
+ // loopback-bound ttyd attached to this session's tmux pane. ttyd itself runs
234
+ // with no auth; this branch (and the matching upgrade branch) is the gate.
235
+ if (u.pathname.startsWith('/term/')) {
236
+ if (!checkTerminalToken(req.url)) return endJson(res, 401, { error: 'unauthorized' });
237
+ return proxyTerminalHttp(req, res, u);
238
+ }
239
+
240
+ // static
241
+ serveStatic(u.pathname, res);
242
+ });
243
+
244
+ // In-UI "Update now" (POST /api/update): run the self-update script DETACHED
245
+ // (it git-pulls, reinstalls, rebuilds the web bundle, then restarts this
246
+ // server). Returns immediately; the client shows "updating…" and reconnects
247
+ // when the new server comes back up on the same port.
248
+ function handleUpdate(res) {
249
+ const script = path.join(__dirname, 'bin', 'self-update.sh');
250
+ if (!fs.existsSync(script)) {
251
+ return endJson(res, 500, { error: 'self-update script missing' });
252
+ }
253
+ try {
254
+ const child = spawn('/bin/bash', [script], {
255
+ cwd: __dirname,
256
+ env: process.env,
257
+ detached: true,
258
+ stdio: 'ignore',
259
+ });
260
+ child.unref();
261
+ return endJson(res, 200, { ok: true, updating: true });
262
+ } catch (err) {
263
+ return endJson(res, 500, { error: String(err?.message || err) });
264
+ }
265
+ }
266
+
267
+ function endJson(res, code, obj) {
268
+ const body = JSON.stringify(obj);
269
+ res.writeHead(code, { 'content-type': MIME['.json'], 'content-length': Buffer.byteLength(body) });
270
+ res.end(body);
271
+ }
272
+
273
+ // Sanitize an uploaded filename to a safe basename (no path traversal).
274
+ function sanitizeName(name) {
275
+ const base = path.basename(String(name || 'file'));
276
+ const safe = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '').slice(0, 128);
277
+ return safe || 'file';
278
+ }
279
+
280
+ // Receive a raw-body file upload (the client POSTs the file bytes directly),
281
+ // cap the size, save under uploadsDir, and return the absolute path so the
282
+ // client can inject it into the prompt for the Claude session to read.
283
+ function handleUpload(req, res, u) {
284
+ const maxBytes = CONFIG.maxUploadMB * 1024 * 1024;
285
+ const name = sanitizeName(u.searchParams.get('name'));
286
+ const chunks = [];
287
+ let size = 0;
288
+ let aborted = false;
289
+
290
+ req.on('data', (c) => {
291
+ if (aborted) return;
292
+ size += c.length;
293
+ if (size > maxBytes) {
294
+ aborted = true;
295
+ endJson(res, 413, { error: `file exceeds ${CONFIG.maxUploadMB} MB limit` });
296
+ req.destroy();
297
+ return;
298
+ }
299
+ chunks.push(c);
300
+ });
301
+
302
+ req.on('end', async () => {
303
+ if (aborted) return;
304
+ if (size === 0) return endJson(res, 400, { error: 'empty upload' });
305
+ try {
306
+ await fs.promises.mkdir(CONFIG.uploadsDir, { recursive: true });
307
+ const stamped = `${Date.now()}-${name}`;
308
+ const full = path.join(CONFIG.uploadsDir, stamped);
309
+ // Defense-in-depth: ensure the resolved path stays inside uploadsDir.
310
+ if (!full.startsWith(CONFIG.uploadsDir + path.sep)) {
311
+ return endJson(res, 400, { error: 'invalid filename' });
312
+ }
313
+ await fs.promises.writeFile(full, Buffer.concat(chunks), { mode: 0o600 });
314
+ endJson(res, 200, { ok: true, path: full, name });
315
+ } catch (err) {
316
+ endJson(res, 500, { error: String(err?.message || err) });
317
+ }
318
+ });
319
+
320
+ req.on('error', () => {
321
+ if (!aborted) endJson(res, 400, { error: 'upload stream error' });
322
+ });
323
+ }
324
+
325
+ // GET /api/uploads/<basename> — serve a single upload by basename.
326
+ // Security:
327
+ // 1. Only the basename is taken from the URL segment — no sub-dirs allowed.
328
+ // 2. sanitizeName strips any remaining path traversal characters.
329
+ // 3. The resolved absolute path is checked to start with uploadsDir + sep.
330
+ // Returns: 404 if the file doesn't exist, 200 with the correct content-type.
331
+ // Only image types get an image/* content-type; everything else is
332
+ // application/octet-stream with Content-Disposition: attachment to prevent
333
+ // the browser from executing arbitrary served files.
334
+ function handleServeUpload(req, res, u) {
335
+ // Extract the last path segment only (drop any leading slashes / sub-dirs).
336
+ const rawSegment = u.pathname.replace(/^\/api\/uploads\//, '');
337
+ // Reject if the caller tried to include a sub-directory.
338
+ if (rawSegment.includes('/') || rawSegment.includes('\\')) {
339
+ res.writeHead(404); return res.end('not found');
340
+ }
341
+ const basename = sanitizeName(rawSegment);
342
+ if (!basename) { res.writeHead(404); return res.end('not found'); }
343
+
344
+ const full = path.join(CONFIG.uploadsDir, basename);
345
+ // Defense-in-depth: resolved path must stay inside uploadsDir.
346
+ if (!full.startsWith(CONFIG.uploadsDir + path.sep)) {
347
+ res.writeHead(404); return res.end('not found');
348
+ }
349
+
350
+ fs.stat(full, (statErr) => {
351
+ if (statErr) { res.writeHead(404); return res.end('not found'); }
352
+ const ext = path.extname(basename).toLowerCase();
353
+ const imageMime = IMAGE_MIME[ext];
354
+ const headers = imageMime
355
+ ? { 'content-type': imageMime, 'cache-control': 'private, max-age=3600' }
356
+ : {
357
+ 'content-type': 'application/octet-stream',
358
+ 'content-disposition': `attachment; filename="${basename}"`,
359
+ 'cache-control': 'private, max-age=3600',
360
+ };
361
+ res.writeHead(200, headers);
362
+ fs.createReadStream(full).pipe(res);
363
+ });
364
+ }
365
+
366
+ // Read a small JSON request body with a hard size cap (control payloads are
367
+ // tiny — same defense as handleUpload's byte cap, just much smaller). Resolves
368
+ // to the parsed object, or {} for an empty body. Rejects on overflow/bad JSON.
369
+ function readJsonBody(req, maxBytes = 64 * 1024) {
370
+ return new Promise((resolve, reject) => {
371
+ const chunks = [];
372
+ let size = 0;
373
+ let aborted = false;
374
+ req.on('data', (c) => {
375
+ if (aborted) return;
376
+ size += c.length;
377
+ if (size > maxBytes) {
378
+ aborted = true;
379
+ req.destroy();
380
+ reject(new Error('request body too large'));
381
+ return;
382
+ }
383
+ chunks.push(c);
384
+ });
385
+ req.on('end', () => {
386
+ if (aborted) return;
387
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
388
+ if (!raw) return resolve({});
389
+ try {
390
+ resolve(JSON.parse(raw));
391
+ } catch {
392
+ reject(new Error('invalid JSON body'));
393
+ }
394
+ });
395
+ req.on('error', (err) => {
396
+ if (!aborted) reject(err);
397
+ });
398
+ });
399
+ }
400
+
401
+ // POST /api/config — validate + persist the launch config. 400 on bad input.
402
+ async function handleConfigSave(req, res) {
403
+ let body;
404
+ try {
405
+ body = await readJsonBody(req);
406
+ } catch (err) {
407
+ return endJson(res, 400, { error: String(err?.message || err) });
408
+ }
409
+ try {
410
+ const saved = writeConfig(body);
411
+ return endJson(res, 200, saved);
412
+ } catch (err) {
413
+ return endJson(res, 400, { error: String(err?.message || err) });
414
+ }
415
+ }
416
+
417
+ // POST /api/session/new — create a new tmux window in the configured (or
418
+ // body-overridden) cwd, then type the launch command into it via send-keys so
419
+ // the interactive shell resolves aliases. Security: the command is operator
420
+ // config and is only ever sent into a pane (never shell-exec'd), consistent
421
+ // with this app already typing into live sessions. Token-gated + localhost.
422
+ async function handleSessionNew(req, res) {
423
+ let body;
424
+ try {
425
+ body = await readJsonBody(req);
426
+ } catch (err) {
427
+ return endJson(res, 400, { error: String(err?.message || err) });
428
+ }
429
+ const config = readConfig();
430
+ const cwd =
431
+ typeof body.cwd === 'string' && body.cwd.trim() ? body.cwd : config.defaultCwd;
432
+ // Name is required-with-default: sanitize the requested name, falling back to
433
+ // `session-<short-ts>` so a session is ALWAYS named (the rail reads the tmux
434
+ // window name until a transcript title exists).
435
+ const name = tmux.sanitizeName(body.name) || tmux.defaultSessionName();
436
+ try {
437
+ // (1) Reliable named path: the tmux window name. createWindow sets it via
438
+ // `new-window -n`, so the rail shows the name immediately.
439
+ const target = await tmux.createWindow({ cwd, name });
440
+ // (2) Claude's own session title: `claude --help` exposes `-n/--name`
441
+ // (display name in the prompt box, /resume picker, terminal title), so
442
+ // we append it to the launch command rather than relying on a delayed
443
+ // `/rename`. The name is shell-quoted (sanitizeName already stripped
444
+ // control chars/newlines) since the command is typed into an interactive
445
+ // shell so aliases like `yolo` resolve. sendText appends Enter → runs it.
446
+ const launch = `${config.launchCommand} --name ${tmux.shellQuoteName(name)}`;
447
+ await tmux.sendText(target, launch);
448
+ return endJson(res, 200, { ok: true, target, name });
449
+ } catch (err) {
450
+ return endJson(res, 500, { error: String(err?.message || err) });
451
+ }
452
+ }
453
+
454
+ // POST /api/session/rename — rename an existing session's tmux window. We do
455
+ // BOTH: (1) `rename-window` so the rail shows the new name on the next refresh,
456
+ // and (2) type `/rename <name>` into the pane so Claude updates its own session
457
+ // title (which the transcript records as a custom-title). The name is
458
+ // sanitized (control chars/newlines stripped) before either path. Token-gated +
459
+ // localhost, consistent with the rest of the control surface.
460
+ async function handleSessionRename(req, res) {
461
+ let body;
462
+ try {
463
+ body = await readJsonBody(req);
464
+ } catch (err) {
465
+ return endJson(res, 400, { error: String(err?.message || err) });
466
+ }
467
+ const id = typeof body.id === 'string' ? body.id : '';
468
+ const name = tmux.sanitizeName(body.name);
469
+ if (!name) return endJson(res, 400, { error: 'name is required' });
470
+ const session = sessionById(id);
471
+ if (!session) return endJson(res, 404, { error: 'unknown session' });
472
+ if (!tmux.isValidTarget(session.target)) {
473
+ return endJson(res, 400, { error: 'invalid tmux target' });
474
+ }
475
+ try {
476
+ // (1) tmux window name — instant in the rail (read until a transcript title exists).
477
+ await tmux.renameWindow(session.target, name);
478
+ // (2) Claude's own session title via the /rename slash command, typed into
479
+ // the pane (sanitizeName already removed newlines/control chars). The
480
+ // name follows /rename verbatim as a single argument to the command.
481
+ await tmux.sendText(session.target, `/rename ${name}`);
482
+ return endJson(res, 200, { ok: true });
483
+ } catch (err) {
484
+ return endJson(res, 500, { error: String(err?.message || err) });
485
+ }
486
+ }
487
+
488
+ // Extract and validate the session id (== tmux target) from a /term/ path.
489
+ // The id is the first path segment after /term/, percent-decoded. Returns the
490
+ // decoded id only if it is both a known session AND a valid tmux target;
491
+ // otherwise null (caller responds 404/401). This is the injection guard: an id
492
+ // never reaches `spawn` unless it matches the CONTRACT target pattern.
493
+ function termIdFromPath(pathname) {
494
+ const m = /^\/term\/([^/]+)/.exec(pathname);
495
+ if (!m) return null;
496
+ let id;
497
+ try { id = decodeURIComponent(m[1]); } catch { return null; }
498
+ if (!tmux.isValidTarget(id)) return null;
499
+ const session = sessionById(id);
500
+ if (!session) return null;
501
+ return { id, target: session.target };
502
+ }
503
+
504
+ // HTTP pass-through to a session's ttyd. Ensures the process is up, then pipes
505
+ // the request/response verbatim. Registers the response socket as a client for
506
+ // idle ref-counting (the long-lived ttyd HTTP keep-alive / SSE keeps the proc
507
+ // warm; the WS upgrade is the real liveness signal).
508
+ async function proxyTerminalHttp(req, res, u) {
509
+ const parsed = termIdFromPath(u.pathname);
510
+ if (!parsed) { res.writeHead(404); return res.end('unknown terminal'); }
511
+
512
+ let port;
513
+ try {
514
+ ({ port } = await terminal.ensureTerminal(parsed.id, parsed.target));
515
+ } catch (err) {
516
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
517
+ return res.end(`terminal unavailable: ${err?.message || err}`);
518
+ }
519
+
520
+ // Forward the original path+query unchanged; ttyd was started with `-b` set to
521
+ // /term/<encoded-id> so its own asset/WS links already match this prefix.
522
+ const proxyReq = http.request(
523
+ {
524
+ host: '127.0.0.1',
525
+ port,
526
+ method: req.method,
527
+ path: u.pathname + (u.search || ''),
528
+ headers: req.headers,
529
+ },
530
+ (proxyRes) => {
531
+ res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
532
+ proxyRes.pipe(res);
533
+ },
534
+ );
535
+ proxyReq.on('error', (err) => {
536
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' });
537
+ res.end(`terminal proxy error: ${err.message}`);
538
+ });
539
+ req.pipe(proxyReq);
540
+ }
541
+
542
+ function serveStatic(pathname, res) {
543
+ const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
544
+ const full = path.join(PUBLIC_DIR, rel);
545
+ // path-traversal guard
546
+ if (!full.startsWith(PUBLIC_DIR + path.sep) && full !== path.join(PUBLIC_DIR, 'index.html')) {
547
+ res.writeHead(403); return res.end('forbidden');
548
+ }
549
+ fs.readFile(full, (err, data) => {
550
+ if (err) { res.writeHead(404); return res.end('not found'); }
551
+ const ext = path.extname(full).toLowerCase();
552
+ res.writeHead(200, {
553
+ 'content-type': MIME[ext] || 'application/octet-stream',
554
+ // Personal tool under active iteration: never let a phone serve a stale
555
+ // UI. Always revalidate so CSS/JS fixes show up on the next load.
556
+ 'cache-control': 'no-store, must-revalidate',
557
+ });
558
+ res.end(data);
559
+ });
560
+ }
561
+
562
+ // --- WebSocket --------------------------------------------------------------
563
+ // 1 MB cap: control messages are tiny; this prevents a single huge frame from
564
+ // forcing a multi-hundred-MB string allocation in the cockpit process.
565
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });
566
+
567
+ server.on('upgrade', (req, socket, head) => {
568
+ // Origin check first (403) — applies to every upgrade regardless of path.
569
+ if (!isAllowedOrigin(req.headers.origin)) {
570
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
571
+ socket.destroy();
572
+ return;
573
+ }
574
+ const upgradePath = new URL(req.url, 'http://localhost').pathname;
575
+
576
+ // Raw-terminal escape hatch: relay /term/* WS upgrades to the session's ttyd
577
+ // via a raw TCP pipe (ttyd speaks its own WS protocol; we are a transparent
578
+ // byte relay, not a `ws` endpoint). Browsers can't set headers on the ttyd
579
+ // WebSocket, so this surface authenticates via the `?token=` it keeps in its
580
+ // URL. All other upgrades go to the existing claude-control WebSocketServer.
581
+ if (upgradePath.startsWith('/term/')) {
582
+ if (!checkTerminalToken(req.url)) {
583
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
584
+ socket.destroy();
585
+ return;
586
+ }
587
+ relayTerminalUpgrade(req, socket, head);
588
+ return;
589
+ }
590
+
591
+ // Main cockpit WS: the browser can't set an Authorization header on
592
+ // `new WebSocket(...)`, so the client offers the token as a subprotocol
593
+ // (Sec-WebSocket-Protocol). Tokenless server → accept. We do NOT echo the
594
+ // raw token back; if a subprotocol must be selected, we pick the non-secret
595
+ // WS_PROTOCOL label (which the client always offers alongside the token).
596
+ if (!checkWsToken(req, CONFIG.token)) {
597
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
598
+ socket.destroy();
599
+ return;
600
+ }
601
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
602
+ });
603
+
604
+ // Relay a /term/* WebSocket upgrade to the session's loopback ttyd as a raw
605
+ // TCP byte pipe. We reconstruct the upgrade request line + headers verbatim and
606
+ // replay them (plus any bytes already buffered in `head`) onto a fresh socket
607
+ // to 127.0.0.1:<port>, then pipe both directions. Auth was already enforced by
608
+ // the origin+token checks above; this inherits it.
609
+ async function relayTerminalUpgrade(req, socket, head) {
610
+ const upgradePath = new URL(req.url, 'http://localhost').pathname;
611
+ const parsed = termIdFromPath(upgradePath);
612
+ if (!parsed) {
613
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
614
+ socket.destroy();
615
+ return;
616
+ }
617
+
618
+ let port;
619
+ try {
620
+ ({ port } = await terminal.ensureTerminal(parsed.id, parsed.target));
621
+ } catch {
622
+ socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
623
+ socket.destroy();
624
+ return;
625
+ }
626
+
627
+ const upstream = net.connect(port, '127.0.0.1', () => {
628
+ // Replay the original upgrade request to ttyd verbatim.
629
+ const headerLines = [`${req.method} ${req.url} HTTP/1.1`];
630
+ const h = req.rawHeaders;
631
+ for (let i = 0; i < h.length; i += 2) headerLines.push(`${h[i]}: ${h[i + 1]}`);
632
+ upstream.write(headerLines.join('\r\n') + '\r\n\r\n');
633
+ if (head && head.length) upstream.write(head);
634
+ socket.pipe(upstream);
635
+ upstream.pipe(socket);
636
+ });
637
+
638
+ // Ref-count this connection for idle teardown; release on either end closing.
639
+ terminal.addClient(parsed.id, socket);
640
+ const release = () => terminal.removeClient(parsed.id, socket);
641
+
642
+ upstream.on('error', () => { socket.destroy(); });
643
+ socket.on('error', () => { upstream.destroy(); });
644
+ upstream.on('close', () => { release(); socket.destroy(); });
645
+ socket.on('close', () => { release(); upstream.destroy(); });
646
+ }
647
+
648
+ function send(ws, obj) {
649
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
650
+ }
651
+ function broadcast(obj) {
652
+ const msg = JSON.stringify(obj);
653
+ for (const ws of wss.clients) if (ws.readyState === ws.OPEN) ws.send(msg);
654
+ }
655
+ function broadcastTo(id, obj) {
656
+ const sub = subscriptions.get(id);
657
+ if (!sub) return;
658
+ const msg = JSON.stringify(obj);
659
+ for (const ws of sub.clients) if (ws.readyState === ws.OPEN) ws.send(msg);
660
+ }
661
+
662
+ function ensureSubscription(id) {
663
+ let sub = subscriptions.get(id);
664
+ if (sub) {
665
+ // Upgrade a previously tailer-less subscription once the session's
666
+ // transcript has been matched on a later refresh: tear it down so the
667
+ // block below recreates it WITH a tailer (clients re-subscribe).
668
+ const cur = sessionById(id);
669
+ if (sub.tailer === null && cur?.transcriptPath) {
670
+ subscriptions.delete(id);
671
+ } else {
672
+ return sub;
673
+ }
674
+ }
675
+ const session = sessionById(id);
676
+ if (!session) return null; // genuinely unknown tmux target
677
+
678
+ // A live Claude pane may have NO matched transcript (a brand-new session, or
679
+ // a worktree whose transcript Claude records under a different cwd than the
680
+ // pane's current path). Previously this returned null → the session showed in
681
+ // the rail but errored "unknown session" the moment it was opened. Instead,
682
+ // allow the subscription with no tailer: the UI still shows the live pane via
683
+ // `capture` and accepts `reply`, and a later refresh that matches the
684
+ // transcript upgrades the subscription (see the tailer-null branch above).
685
+ if (!session.transcriptPath) {
686
+ sub = { tailer: null, clients: new Set(), pending: null, ready: Promise.resolve() };
687
+ subscriptions.set(id, sub);
688
+ return sub;
689
+ }
690
+
691
+ const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer });
692
+ sub = { tailer, clients: new Set(), pending: null };
693
+ subscriptions.set(id, sub);
694
+
695
+ tailer.on('append', (msgs) => broadcastTo(id, { type: 'append', id, messages: msgs }));
696
+ tailer.on('pending', (pending) => {
697
+ sub.pending = pending;
698
+ registry.setPending(id, !!pending);
699
+ broadcastTo(id, { type: 'pending', id, pending });
700
+ });
701
+ tailer.on('error', (err) => broadcastTo(id, { type: 'ack', op: 'tail', ok: false, error: String(err?.message || err) }));
702
+
703
+ // Kick off the bounded tail load once; all clients await this same promise so
704
+ // the initial `messages` frame never races the first read.
705
+ sub.ready = tailer.start();
706
+ sub.ready.catch(() => {}); // errors surface via the per-subscribe await below
707
+ return sub;
708
+ }
709
+
710
+ function maybeTeardown(id) {
711
+ const sub = subscriptions.get(id);
712
+ if (sub && sub.clients.size === 0) {
713
+ if (sub.tailer) sub.tailer.stop();
714
+ subscriptions.delete(id);
715
+ }
716
+ }
717
+
718
+ wss.on('connection', (ws) => {
719
+ send(ws, { type: 'sessions', sessions: registry.getSessions() });
720
+ send(ws, { type: 'resources', snapshot: resources.snapshot() });
721
+ ws._subs = new Set();
722
+
723
+ ws.on('message', async (raw) => {
724
+ let msg;
725
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
726
+ try {
727
+ await handleClientMessage(ws, msg);
728
+ } catch (err) {
729
+ send(ws, { type: 'ack', op: msg?.type || 'unknown', ok: false, error: String(err?.message || err) });
730
+ }
731
+ });
732
+
733
+ ws.on('close', () => {
734
+ for (const id of ws._subs) {
735
+ const sub = subscriptions.get(id);
736
+ if (sub) { sub.clients.delete(ws); maybeTeardown(id); }
737
+ }
738
+ });
739
+ });
740
+
741
+ async function handleClientMessage(ws, msg) {
742
+ switch (msg.type) {
743
+ case 'subscribe': {
744
+ const sub = ensureSubscription(msg.id);
745
+ if (!sub) return send(ws, { type: 'ack', op: 'subscribe', ok: false, error: 'unknown session' });
746
+ sub.clients.add(ws);
747
+ ws._subs.add(msg.id);
748
+ try {
749
+ await sub.ready; // wait for the bounded tail load to finish before snapshotting
750
+ } catch (err) {
751
+ return send(ws, { type: 'ack', op: 'subscribe', ok: false, error: String(err?.message || err) });
752
+ }
753
+ // Client may have unsubscribed/closed while we awaited.
754
+ if (!sub.clients.has(ws)) return;
755
+ send(ws, {
756
+ type: 'messages',
757
+ id: msg.id,
758
+ // Tailer-less subscription (no matched transcript): no history to send.
759
+ messages: sub.tailer ? sub.tailer.getMessages() : [],
760
+ pending: sub.tailer ? sub.tailer.getPending() : null,
761
+ });
762
+ return;
763
+ }
764
+ case 'unsubscribe': {
765
+ const sub = subscriptions.get(msg.id);
766
+ if (sub) { sub.clients.delete(ws); ws._subs.delete(msg.id); maybeTeardown(msg.id); }
767
+ return;
768
+ }
769
+ case 'reply': {
770
+ const session = sessionById(msg.id);
771
+ if (!session) throw new Error('unknown session');
772
+ if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
773
+ await tmux.sendText(session.target, String(msg.text ?? ''));
774
+ return send(ws, { type: 'ack', op: 'reply', ok: true });
775
+ }
776
+ case 'answer': {
777
+ const session = sessionById(msg.id);
778
+ if (!session) throw new Error('unknown session');
779
+ if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
780
+ const sub = subscriptions.get(msg.id);
781
+ const pending = sub?.tailer ? sub.tailer.getPending() : null;
782
+ if (!pending) throw new Error('no pending question');
783
+ // Require the client to name the exact question it is answering, so a
784
+ // mismatched (or omitted) id can't be applied to whatever is now pending.
785
+ if (msg.toolUseId !== pending.toolUseId) {
786
+ throw new Error('stale question (already answered or changed)');
787
+ }
788
+ const keys = buildAnswerProgram(pending, msg.selections || []);
789
+ // Sequenced (with delays) so single-select auto-advance settles between keys.
790
+ await tmux.sendRawKeysSequenced(session.target, keys);
791
+ return send(ws, { type: 'ack', op: 'answer', ok: true });
792
+ }
793
+ case 'capture': {
794
+ const session = sessionById(msg.id);
795
+ if (!session) throw new Error('unknown session');
796
+ if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
797
+ const lines = Math.max(1, Math.min(10000, Number(msg.lines) || 40));
798
+ const text = await tmux.capturePane(session.target, lines);
799
+ return send(ws, { type: 'capture', id: msg.id, text });
800
+ }
801
+ default:
802
+ return;
803
+ }
804
+ }
805
+
806
+ // --- wiring -----------------------------------------------------------------
807
+ // Edge-detect AskUserQuestion pending per session so a phone gets exactly one
808
+ // push when a question opens (re-arms once it's answered). id -> last pending.
809
+ const lastPending = new Map();
810
+ // Skip the very first 'change' so already-pending sessions present at startup
811
+ // don't all fire a push when the server boots.
812
+ let pushPrimed = false;
813
+
814
+ function firePushForChange(sessions) {
815
+ try {
816
+ if (!pushPrimed) {
817
+ for (const s of sessions) lastPending.set(s.id, !!s.pending);
818
+ pushPrimed = true;
819
+ return;
820
+ }
821
+ const seen = new Set();
822
+ for (const s of sessions) {
823
+ seen.add(s.id);
824
+ const was = lastPending.get(s.id) ?? false;
825
+ if (s.pending && !was) {
826
+ push
827
+ .sendToAll({
828
+ title: s.name || s.id,
829
+ body: s.pendingQuestion || 'is asking a question',
830
+ data: { id: s.id },
831
+ })
832
+ .catch((err) => console.error('push: sendToAll failed:', err?.message || err));
833
+ }
834
+ lastPending.set(s.id, !!s.pending);
835
+ }
836
+ // Forget sessions that disappeared so a returning id re-arms cleanly.
837
+ for (const id of [...lastPending.keys()]) {
838
+ if (!seen.has(id)) lastPending.delete(id);
839
+ }
840
+ } catch (err) {
841
+ // Never let push logic break the session broadcast.
842
+ console.error('push: firePushForChange error:', err?.message || err);
843
+ }
844
+ }
845
+
846
+ registry.on('change', (sessions) => {
847
+ firePushForChange(sessions);
848
+ broadcast({ type: 'sessions', sessions });
849
+ });
850
+ resources.on('sample', (snapshot) => broadcast({ type: 'resources', snapshot }));
851
+ resources.on('overlimit', (snapshot) => {
852
+ // Trim memory pressure: drop tailers nobody is watching, then halve the
853
+ // retained buffer on the active ones too.
854
+ const keep = Math.floor(CONFIG.maxBuffer / 2);
855
+ for (const [id, sub] of subscriptions) {
856
+ if (sub.clients.size === 0) maybeTeardown(id);
857
+ else if (sub.tailer) sub.tailer.trim(keep);
858
+ }
859
+ broadcast({ type: 'resources', snapshot, warning: 'self RSS over limit — trimming buffers' });
860
+ });
861
+
862
+ let uploadSweepTimer = null;
863
+
864
+ async function runUploadSweep() {
865
+ try {
866
+ const ttlMs = CONFIG.uploadTtlHours * 3600 * 1000;
867
+ const { removed } = await sweepUploads(CONFIG.uploadsDir, ttlMs);
868
+ if (removed > 0) console.log(`uploads sweep: removed ${removed} file(s) older than ${CONFIG.uploadTtlHours}h`);
869
+ } catch (err) {
870
+ console.error('uploads sweep failed:', err?.message || err);
871
+ }
872
+ }
873
+
874
+ async function main() {
875
+ registry.start();
876
+ resources.start();
877
+ await registry.refresh().catch(() => {});
878
+
879
+ // Daily attachment cleanup: sweep at startup, then every 24h.
880
+ runUploadSweep();
881
+ uploadSweepTimer = setInterval(runUploadSweep, 24 * 3600 * 1000);
882
+ uploadSweepTimer.unref();
883
+
884
+ server.listen(CONFIG.port, CONFIG.host, () => {
885
+ // eslint-disable-next-line no-console
886
+ console.log(`claude-control → http://${CONFIG.host}:${CONFIG.port}/`);
887
+ if (CONFIG.token) {
888
+ // The token is no longer carried in the URL — the web app prompts for it
889
+ // on load and sends it as an Authorization header (HTTP) / subprotocol
890
+ // (WS). Print it so the operator can paste it into the login prompt.
891
+ console.log(` (access token: ${CONFIG.token} — enter it at the login prompt)`);
892
+ } else {
893
+ console.log(' (no COCKPIT_TOKEN set — relying on 127.0.0.1 bind. This UI can type into your sessions.)');
894
+ }
895
+ });
896
+ }
897
+
898
+ function shutdown() {
899
+ for (const [, sub] of subscriptions) sub.tailer?.stop();
900
+ terminal.shutdownAll();
901
+ registry.stop();
902
+ resources.stop();
903
+ if (uploadSweepTimer) clearInterval(uploadSweepTimer);
904
+ server.close();
905
+ process.exit(0);
906
+ }
907
+ process.on('SIGINT', shutdown);
908
+ process.on('SIGTERM', shutdown);
909
+
910
+ main();