@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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.js +68 -0
- package/bin/install-service.sh +107 -0
- package/bin/self-update.sh +43 -0
- package/bin/uninstall-service.sh +22 -0
- package/lib/answer.js +64 -0
- package/lib/auth.js +81 -0
- package/lib/config.js +118 -0
- package/lib/push.js +153 -0
- package/lib/resources.js +137 -0
- package/lib/sessions.js +529 -0
- package/lib/terminal.js +278 -0
- package/lib/tmux.js +462 -0
- package/lib/transcript.js +451 -0
- package/lib/tui.js +50 -0
- package/lib/uploads.js +42 -0
- package/lib/version.js +73 -0
- package/package.json +49 -0
- package/public/app.js +756 -0
- package/public/index.html +120 -0
- package/public/styles.css +848 -0
- package/server.js +910 -0
- package/web/README.md +66 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/bash-I8pq0VWm.js +1 -0
- package/web/dist/assets/core-BYJcZW10.js +3 -0
- package/web/dist/assets/css-DazXZka4.js +1 -0
- package/web/dist/assets/diff-DiTmLxSS.js +1 -0
- package/web/dist/assets/index-Bb7gXgl-.css +1 -0
- package/web/dist/assets/index-wrjqfzbL.js +77 -0
- package/web/dist/assets/javascript-BKRaQes9.js +1 -0
- package/web/dist/assets/json-DIYVocXf.js +1 -0
- package/web/dist/assets/markdown-BrP960CR.js +1 -0
- package/web/dist/assets/python-sE43i1Pi.js +1 -0
- package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
- package/web/dist/assets/xml-BXBhIUeX.js +1 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +25 -0
- 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();
|