@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
@@ -0,0 +1,278 @@
1
+ /**
2
+ * lib/terminal.js — on-demand ttyd lifecycle for the raw-terminal escape hatch.
3
+ *
4
+ * ESM, Node >=20 built-ins only (no new runtime deps — CONTRACT: only `ws`).
5
+ * Spawns a per-target `ttyd` bound to 127.0.0.1 on an ephemeral port, attached
6
+ * to the session's tmux pane via `tmux -S <socket> attach -t <target>`. ttyd
7
+ * itself runs with NO basic-auth (`-W -i 127.0.0.1`): it is reachable only
8
+ * through claude-control's token-gated reverse proxy, which is the auth gate.
9
+ *
10
+ * Lifecycle mirrors server.js's `maybeTeardown` ref-count: clients are counted
11
+ * per terminal; when the last client disconnects we start an idle grace timer
12
+ * and reap the ttyd process if no client reconnects. A global cap bounds the
13
+ * number of live ttyd processes. All processes are killed on server shutdown.
14
+ *
15
+ * Never shells out with user text — `spawn` with an args array. The session id
16
+ * is the tmux target string and is validated with `tmux.isValidTarget` (which
17
+ * enforces the CONTRACT pattern) before it ever reaches `spawn`.
18
+ */
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import net from 'node:net';
22
+
23
+ import * as tmux from './tmux.js';
24
+
25
+ const TTYD_BIN = process.env.CLAUDE_CONTROL_TTYD || '/opt/homebrew/bin/ttyd';
26
+
27
+ // Reap a ttyd that has had zero clients for this long. A short grace absorbs
28
+ // page reloads / iframe re-mounts without thrashing the process.
29
+ const IDLE_GRACE_MS = Number(process.env.CLAUDE_CONTROL_TERM_IDLE_MS) || 30_000;
30
+
31
+ // Hard ceiling on concurrent ttyd processes (resource bound, per plan P1).
32
+ const MAX_TERMINALS = Number(process.env.CLAUDE_CONTROL_TERM_MAX) || 4;
33
+
34
+ // How long to wait for a freshly-spawned ttyd to start accepting connections
35
+ // on its ephemeral port before giving up.
36
+ const READY_TIMEOUT_MS = 5_000;
37
+
38
+ /** @typedef {{ proc: import('node:child_process').ChildProcess, port: number, target: string, clients: Set<unknown>, idleTimer: NodeJS.Timeout | null, ready: Promise<number> }} Terminal */
39
+
40
+ /** id (tmux target) -> Terminal */
41
+ const terminals = new Map();
42
+
43
+ /**
44
+ * Find a free ephemeral TCP port on 127.0.0.1 by binding port 0 and reading
45
+ * back the assigned port. There is an unavoidable tiny race between close and
46
+ * ttyd's bind, but ttyd binds immediately on spawn and a collision simply
47
+ * surfaces as a failed `ensureTerminal` (the caller returns an error).
48
+ *
49
+ * @returns {Promise<number>}
50
+ */
51
+ function findFreePort() {
52
+ return new Promise((resolve, reject) => {
53
+ const srv = net.createServer();
54
+ srv.once('error', reject);
55
+ srv.listen(0, '127.0.0.1', () => {
56
+ const { port } = srv.address();
57
+ srv.close(() => resolve(port));
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Resolve once a TCP connect to 127.0.0.1:port succeeds, or reject on timeout.
64
+ * Used to gate the first proxied request until ttyd is actually listening.
65
+ *
66
+ * @param {number} port
67
+ * @param {number} timeoutMs
68
+ * @returns {Promise<number>} the port, once ready
69
+ */
70
+ function waitForPort(port, timeoutMs) {
71
+ const deadline = Date.now() + timeoutMs;
72
+ return new Promise((resolve, reject) => {
73
+ const attempt = () => {
74
+ const sock = net.connect(port, '127.0.0.1');
75
+ sock.once('connect', () => {
76
+ sock.destroy();
77
+ resolve(port);
78
+ });
79
+ sock.once('error', () => {
80
+ sock.destroy();
81
+ if (Date.now() >= deadline) {
82
+ reject(new Error(`ttyd on :${port} did not become ready in ${timeoutMs}ms`));
83
+ } else {
84
+ setTimeout(attempt, 100);
85
+ }
86
+ });
87
+ };
88
+ attempt();
89
+ });
90
+ }
91
+
92
+ /**
93
+ * The URL base path used in links the browser navigates to. The id (a tmux
94
+ * target like `name:0`) is percent-encoded so the path is a single safe
95
+ * segment. The proxy forwards this verbatim to ttyd, which URL-decodes the
96
+ * request path before routing (verified: ttyd 1.7.7 / libwebsockets decodes
97
+ * `%3A` → `:` and matches against the decoded `-b` value).
98
+ *
99
+ * @param {string} id
100
+ * @returns {string} e.g. "/term/name%3A0"
101
+ */
102
+ export function basePathFor(id) {
103
+ return `/term/${encodeURIComponent(id)}`;
104
+ }
105
+
106
+ /**
107
+ * The literal base path passed to ttyd's `-b` flag. ttyd routes on the DECODED
108
+ * request path, so `-b` must be the decoded form (`/term/name:0`) — NOT the
109
+ * percent-encoded URL form. With the decoded base, ttyd serves 200 for both the
110
+ * encoded (`%3A`) and decoded request paths, and the asset/WS links it
111
+ * generates resolve under this prefix. (OQ7 resolved at build: an encoded `-b`
112
+ * yields 404 for every request.)
113
+ *
114
+ * @param {string} id
115
+ * @returns {string} e.g. "/term/name:0"
116
+ */
117
+ export function ttydBasePath(id) {
118
+ return `/term/${id}`;
119
+ }
120
+
121
+ /**
122
+ * Ensure a ttyd is running for `id` (a tmux target) and return its loopback
123
+ * port once it is accepting connections. Reuses a live process if present.
124
+ *
125
+ * @param {string} id the session id == tmux target string
126
+ * @param {string} target the tmux target to attach to (validated here)
127
+ * @returns {Promise<{ port: number }>}
128
+ * @throws if the target is invalid, the cap is hit, or ttyd fails to start
129
+ */
130
+ export async function ensureTerminal(id, target) {
131
+ if (!tmux.isValidTarget(target)) {
132
+ throw new Error(`invalid tmux target: ${JSON.stringify(target)}`);
133
+ }
134
+
135
+ const existing = terminals.get(id);
136
+ if (existing) {
137
+ // Cancel any pending idle reap — a new request revived it.
138
+ if (existing.idleTimer) {
139
+ clearTimeout(existing.idleTimer);
140
+ existing.idleTimer = null;
141
+ }
142
+ const port = await existing.ready;
143
+ return { port };
144
+ }
145
+
146
+ if (terminals.size >= MAX_TERMINALS) {
147
+ throw new Error(`terminal cap reached (${MAX_TERMINALS} live); close one and retry`);
148
+ }
149
+
150
+ const tmuxBin = await tmux.resolveTmuxBin();
151
+ const socket = await tmux.getSocketPath();
152
+ const port = await findFreePort();
153
+ const base = ttydBasePath(id);
154
+
155
+ // ttyd flags:
156
+ // -W allow client write (interactive input)
157
+ // -i 127.0.0.1 bind loopback only (proxy is the only ingress)
158
+ // -p <port> ephemeral loopback port
159
+ // -b <base> URL base path so assets/WS resolve under the proxied subpath
160
+ // followed by the command ttyd runs: tmux -S <socket> attach -t <target>.
161
+ // NO basic-auth — the claude-control token proxy is the gate.
162
+ const proc = spawn(
163
+ TTYD_BIN,
164
+ ['-W', '-i', '127.0.0.1', '-p', String(port), '-b', base,
165
+ tmuxBin, '-S', socket, 'attach', '-t', target],
166
+ { stdio: ['ignore', 'ignore', 'pipe'] },
167
+ );
168
+
169
+ let stderrTail = '';
170
+ proc.stderr?.on('data', (chunk) => {
171
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000);
172
+ });
173
+
174
+ const ready = new Promise((resolve, reject) => {
175
+ let settled = false;
176
+ proc.once('error', (err) => {
177
+ if (settled) return;
178
+ settled = true;
179
+ terminals.delete(id);
180
+ reject(new Error(`failed to spawn ttyd: ${err.message}`));
181
+ });
182
+ proc.once('exit', (code, signal) => {
183
+ // If ttyd dies before it ever became ready, surface a useful error.
184
+ if (!settled) {
185
+ settled = true;
186
+ terminals.delete(id);
187
+ reject(new Error(`ttyd exited (code=${code} signal=${signal}) before ready: ${stderrTail.trim()}`));
188
+ } else {
189
+ // Died after running: drop the entry so a later request respawns it.
190
+ terminals.delete(id);
191
+ }
192
+ });
193
+ waitForPort(port, READY_TIMEOUT_MS).then(
194
+ (p) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ resolve(p);
198
+ },
199
+ (err) => {
200
+ if (settled) return;
201
+ settled = true;
202
+ try { proc.kill('SIGTERM'); } catch { /* already gone */ }
203
+ terminals.delete(id);
204
+ reject(err);
205
+ },
206
+ );
207
+ });
208
+
209
+ /** @type {Terminal} */
210
+ const term = { proc, port, target, clients: new Set(), idleTimer: null, ready };
211
+ terminals.set(id, term);
212
+
213
+ const readyPort = await ready;
214
+ return { port: readyPort };
215
+ }
216
+
217
+ /**
218
+ * Register a connected client (HTTP keep-alive conn or WS socket) against a
219
+ * terminal, cancelling any pending idle reap. Mirrors the ref-count in
220
+ * server.js's subscription model.
221
+ *
222
+ * @param {string} id
223
+ * @param {unknown} client an opaque handle (the socket) used only for identity
224
+ */
225
+ export function addClient(id, client) {
226
+ const term = terminals.get(id);
227
+ if (!term) return;
228
+ term.clients.add(client);
229
+ if (term.idleTimer) {
230
+ clearTimeout(term.idleTimer);
231
+ term.idleTimer = null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Deregister a client. When the last client leaves, start an idle grace timer;
237
+ * if no client reconnects within IDLE_GRACE_MS, reap the ttyd process.
238
+ *
239
+ * @param {string} id
240
+ * @param {unknown} client
241
+ */
242
+ export function removeClient(id, client) {
243
+ const term = terminals.get(id);
244
+ if (!term) return;
245
+ term.clients.delete(client);
246
+ if (term.clients.size > 0) return;
247
+ if (term.idleTimer) clearTimeout(term.idleTimer);
248
+ term.idleTimer = setTimeout(() => reap(id), IDLE_GRACE_MS);
249
+ // Don't keep the event loop alive solely for the reap timer.
250
+ term.idleTimer.unref?.();
251
+ }
252
+
253
+ /**
254
+ * Kill the ttyd for `id` and drop its registry entry. No-op if already gone.
255
+ * @param {string} id
256
+ */
257
+ export function reap(id) {
258
+ const term = terminals.get(id);
259
+ if (!term) return;
260
+ if (term.idleTimer) clearTimeout(term.idleTimer);
261
+ terminals.delete(id);
262
+ try { term.proc.kill('SIGTERM'); } catch { /* already gone */ }
263
+ }
264
+
265
+ /**
266
+ * Kill every live ttyd. Call from the server's shutdown handler.
267
+ */
268
+ export function shutdownAll() {
269
+ for (const [id] of terminals) reap(id);
270
+ }
271
+
272
+ /**
273
+ * Test/diagnostic helper: number of live terminals.
274
+ * @returns {number}
275
+ */
276
+ export function liveCount() {
277
+ return terminals.size;
278
+ }