@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/lib/terminal.js
ADDED
|
@@ -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
|
+
}
|