@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/lib/tmux.js ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * lib/tmux.js — tmux integration for claude-cockpit.
3
+ * ESM, Node >=20 built-ins only. Never shell out with user text.
4
+ */
5
+
6
+ import { execFile as _execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { access, stat } from 'node:fs/promises';
9
+ import { constants as fsConstants } from 'node:fs';
10
+
11
+ const execFile = promisify(_execFile);
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Binary resolution — cached after first successful probe
15
+ // ---------------------------------------------------------------------------
16
+ let _resolvedBin = null;
17
+
18
+ const PROBE_PATHS = [
19
+ '/opt/homebrew/bin/tmux',
20
+ '/usr/local/bin/tmux',
21
+ '/usr/bin/tmux',
22
+ ];
23
+
24
+ /**
25
+ * Resolve the tmux binary, honouring COCKPIT_TMUX, then absolute-path
26
+ * probes, then `command -v tmux` via a login shell (handles PATH correctly
27
+ * without triggering zsh aliases).
28
+ *
29
+ * @returns {Promise<string>} Absolute path to the tmux binary.
30
+ */
31
+ export async function resolveTmuxBin() {
32
+ if (_resolvedBin) return _resolvedBin;
33
+
34
+ // 1. Explicit override
35
+ const envBin = process.env.COCKPIT_TMUX;
36
+ if (envBin) {
37
+ _resolvedBin = envBin;
38
+ return _resolvedBin;
39
+ }
40
+
41
+ // 2. Fixed absolute paths
42
+ for (const p of PROBE_PATHS) {
43
+ try {
44
+ await access(p, fsConstants.X_OK);
45
+ _resolvedBin = p;
46
+ return _resolvedBin;
47
+ } catch {
48
+ // not present / not executable — try next
49
+ }
50
+ }
51
+
52
+ // 3. Login shell lookup (avoids zsh alias shadowing)
53
+ try {
54
+ const { stdout } = await execFile('/bin/sh', ['-lc', 'command -v tmux'], {
55
+ timeout: 5000,
56
+ });
57
+ const candidate = stdout.trim();
58
+ if (candidate) {
59
+ _resolvedBin = candidate;
60
+ return _resolvedBin;
61
+ }
62
+ } catch {
63
+ // fall through
64
+ }
65
+
66
+ throw new Error('tmux binary not found; set COCKPIT_TMUX or install tmux');
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Socket path
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Resolve the absolute path of the tmux server socket this process talks to.
75
+ *
76
+ * claude-control runs tmux on the *default* socket (no `-S`). When we spawn a
77
+ * separate `tmux attach` (e.g. for the ttyd escape hatch) it would otherwise
78
+ * resolve its own default socket from *its* environment — which under launchd
79
+ * can differ from ours, yielding a silent empty terminal. Passing this path as
80
+ * `-S <socket>` pins the attach to the SAME server we discovered the target on.
81
+ *
82
+ * @returns {Promise<string>} absolute socket path (e.g. /tmp/tmux-501/default)
83
+ */
84
+ export async function getSocketPath() {
85
+ const { stdout } = await runTmux(['display-message', '-p', '#{socket_path}']);
86
+ const socket = stdout.trim();
87
+ if (!socket) throw new Error('tmux did not report a socket path');
88
+ return socket;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Target validation
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** Pattern from CONTRACT: ^[A-Za-z0-9_.-]+:\d+(\.\d+)?$ */
96
+ const TARGET_RE = /^[A-Za-z0-9_.-]+:\d+(\.\d+)?$/;
97
+
98
+ /**
99
+ * Returns true when `target` is a syntactically valid tmux target string.
100
+ * Does NOT verify the target is live — that requires a round-trip to tmux.
101
+ *
102
+ * @param {string} target
103
+ * @returns {boolean}
104
+ */
105
+ export function isValidTarget(target) {
106
+ return typeof target === 'string' && TARGET_RE.test(target);
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Internal helpers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Run a tmux sub-command with an explicit args array.
115
+ * @param {string[]} args
116
+ * @param {{ timeout?: number }} [opts]
117
+ * @returns {Promise<{ stdout: string, stderr: string }>}
118
+ */
119
+ async function runTmux(args, opts = {}) {
120
+ const bin = await resolveTmuxBin();
121
+ return execFile(bin, args, {
122
+ timeout: opts.timeout ?? 10_000,
123
+ maxBuffer: 4 * 1024 * 1024,
124
+ // Force a UTF-8 locale: in the C/POSIX locale a launchd agent inherits, tmux
125
+ // sanitizes our \x1f field separator to '_', so list parsing yields nothing.
126
+ // A UTF-8 locale makes tmux emit \x1f literally. (Honor an existing locale.)
127
+ env: { ...process.env, LC_ALL: process.env.LC_ALL || 'en_US.UTF-8' },
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Assert the target is valid, throwing a descriptive error if not.
133
+ * @param {string} target
134
+ */
135
+ function assertTarget(target) {
136
+ if (!isValidTarget(target)) {
137
+ throw new Error(`Invalid tmux target: ${JSON.stringify(target)} — must match session:index[.pane]`);
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // List windows
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * @typedef {Object} Window
147
+ * @property {string} sessionName
148
+ * @property {number} windowIndex
149
+ * @property {string} windowName
150
+ * @property {string} target "sessionName:windowIndex"
151
+ * @property {boolean} active
152
+ * @property {number} panePid
153
+ * @property {string} cwd
154
+ * @property {string} cmd
155
+ * @property {string} windowId tmux @-id; SHARED across grouped sessions
156
+ * @property {number} paneIndex
157
+ */
158
+
159
+ const SEP = '\x1f';
160
+
161
+ const FORMAT = [
162
+ '#{session_name}',
163
+ '#{window_index}',
164
+ '#{window_name}',
165
+ '#{window_active}',
166
+ '#{pane_pid}',
167
+ '#{pane_current_path}',
168
+ '#{pane_current_command}',
169
+ '#{window_id}',
170
+ '#{pane_index}',
171
+ ].join(SEP);
172
+
173
+ /**
174
+ * List all tmux windows across all sessions.
175
+ * Resolves to [] when no tmux server is running.
176
+ *
177
+ * @returns {Promise<Window[]>}
178
+ */
179
+ export async function listWindows() {
180
+ let stdout;
181
+ try {
182
+ ({ stdout } = await runTmux(['list-windows', '-a', '-F', FORMAT]));
183
+ } catch (err) {
184
+ // tmux exits 1 with "no server running" or "error connecting to server"
185
+ const msg = String(err?.message || '');
186
+ if (
187
+ msg.includes('no server running') ||
188
+ msg.includes('error connecting') ||
189
+ msg.includes('no sessions') ||
190
+ (err?.code === 1 && (!err.stderr || err.stderr.includes('no server')))
191
+ ) {
192
+ return [];
193
+ }
194
+ // Also handle the case where stderr contains the no-server message
195
+ if (err?.stderr && (
196
+ err.stderr.includes('no server running') ||
197
+ err.stderr.includes('error connecting')
198
+ )) {
199
+ return [];
200
+ }
201
+ throw err;
202
+ }
203
+
204
+ const windows = [];
205
+ for (const line of stdout.split('\n')) {
206
+ const trimmed = line.trim();
207
+ if (!trimmed) continue;
208
+
209
+ const parts = trimmed.split(SEP);
210
+ if (parts.length < 7) continue;
211
+
212
+ const [sessionName, rawIndex, windowName, rawActive, rawPid, cwd, cmd, windowId, rawPane] = parts;
213
+ const windowIndex = Number(rawIndex);
214
+ const panePid = Number(rawPid);
215
+
216
+ windows.push({
217
+ sessionName,
218
+ windowIndex,
219
+ windowName,
220
+ target: `${sessionName}:${windowIndex}`,
221
+ active: rawActive === '1',
222
+ panePid,
223
+ cwd,
224
+ cmd,
225
+ windowId: windowId ?? `${sessionName}:${windowIndex}`,
226
+ paneIndex: Number(rawPane) || 0,
227
+ });
228
+ }
229
+
230
+ return windows;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Session-name helpers
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Sanitize a user-supplied session name for safe use as a tmux window name and
239
+ * inside a `send-keys` payload. Strips ASCII control characters and newlines
240
+ * (which could smuggle extra key events into the pane), collapses runs of
241
+ * whitespace, and caps length. tmux window names may legitimately contain
242
+ * spaces and punctuation, so those are kept — only dangerous bytes are removed.
243
+ * Returns '' when nothing usable remains (callers supply a default).
244
+ *
245
+ * @param {string} name
246
+ * @returns {string}
247
+ */
248
+ export function sanitizeName(name) {
249
+ return String(name ?? '')
250
+ .replace(/[\x00-\x1f\x7f]/g, ' ') // control chars incl. \n \r \t ESC, and DEL
251
+ .replace(/\s+/g, ' ')
252
+ .trim()
253
+ .slice(0, 80);
254
+ }
255
+
256
+ /**
257
+ * Generate a sensible default session name: `session-<short-timestamp>` where
258
+ * the suffix is the tail of the base-36 epoch-ms — short, and monotonic enough
259
+ * to disambiguate rapid creations.
260
+ *
261
+ * @param {number} [now=Date.now()]
262
+ * @returns {string}
263
+ */
264
+ export function defaultSessionName(now = Date.now()) {
265
+ return `session-${now.toString(36).slice(-6)}`;
266
+ }
267
+
268
+ /**
269
+ * Wrap an already-sanitized name in single quotes for safe interpolation into a
270
+ * shell command that is typed into a pane via `send-keys` (e.g. appending
271
+ * `--name '<name>'` to a launch command). Single-quote-escaping is the only
272
+ * shell metacharacter that matters inside single quotes; sanitizeName has
273
+ * already removed newlines/control chars, so this fully neutralizes the value.
274
+ *
275
+ * @param {string} name Output of sanitizeName (no control chars).
276
+ * @returns {string} e.g. "'my session'" or "'it'\''s'".
277
+ */
278
+ export function shellQuoteName(name) {
279
+ return `'${String(name).replace(/'/g, `'\\''`)}'`;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Create window
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Create a new tmux window running the DEFAULT shell (NOT a passed command),
288
+ * and return its "session:window" target. The launch command is sent
289
+ * separately via send-keys (see server.js) so the interactive shell loads its
290
+ * rc and resolves aliases (e.g. `yolo`) — passing the command as
291
+ * `new-window <cmd>` would exec it directly, bypassing alias resolution.
292
+ *
293
+ * If a tmux server/session already exists, the window is created in the first
294
+ * existing session. If no server is running, a detached "claude-control"
295
+ * session is created first and used.
296
+ *
297
+ * @param {{ cwd: string, name?: string }} opts
298
+ * @returns {Promise<string>} target "session:windowIndex"
299
+ */
300
+ export async function createWindow({ cwd, name } = {}) {
301
+ if (typeof cwd !== 'string' || !cwd) {
302
+ throw new Error('createWindow: cwd is required');
303
+ }
304
+ // Validate the cwd exists and is a directory before handing it to tmux, so we
305
+ // surface a clear error instead of tmux's terse "can't find directory".
306
+ let st;
307
+ try {
308
+ st = await stat(cwd);
309
+ } catch {
310
+ throw new Error(`createWindow: cwd does not exist: ${cwd}`);
311
+ }
312
+ if (!st.isDirectory()) {
313
+ throw new Error(`createWindow: cwd is not a directory: ${cwd}`);
314
+ }
315
+
316
+ // Re-sanitize defensively: the window name reaches tmux as an argv value, but
317
+ // callers may pass raw user text. An empty result means "let tmux auto-name".
318
+ const safeName = sanitizeName(name);
319
+
320
+ const windows = await listWindows();
321
+
322
+ // No tmux server/session yet — bootstrap a detached session in the cwd. The
323
+ // session's first window IS our target window, so no extra new-window needed.
324
+ if (windows.length === 0) {
325
+ const sessionName = 'claude-control';
326
+ const args = ['new-session', '-d', '-s', sessionName, '-c', cwd];
327
+ if (safeName) args.push('-n', safeName);
328
+ await runTmux(args);
329
+ // The fresh session opens at window index 0 (tmux's base-index may differ,
330
+ // but the first list entry is authoritative).
331
+ const after = await listWindows();
332
+ const win = after.find((w) => w.sessionName === sessionName);
333
+ const target = win ? win.target : `${sessionName}:0`;
334
+ if (!isValidTarget(target)) {
335
+ throw new Error(`createWindow: produced invalid target: ${target}`);
336
+ }
337
+ return target;
338
+ }
339
+
340
+ // A server exists — create the window in the first existing session and read
341
+ // back its "session:window" target via the -P/-F print format.
342
+ const targetSession = windows[0].sessionName;
343
+ const args = [
344
+ 'new-window',
345
+ '-t', targetSession,
346
+ '-P',
347
+ '-F', '#{session_name}:#{window_index}',
348
+ '-c', cwd,
349
+ ];
350
+ if (safeName) args.push('-n', safeName);
351
+ const { stdout } = await runTmux(args);
352
+ const target = stdout.trim();
353
+ if (!isValidTarget(target)) {
354
+ throw new Error(`createWindow: produced invalid target: ${JSON.stringify(target)}`);
355
+ }
356
+ return target;
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Rename window
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /**
364
+ * Rename a tmux window so it shows the new label in the rail immediately. The
365
+ * name reaches tmux as an argv value (NOT typed into the pane), and the `--`
366
+ * terminator stops tmux from treating a leading `-` as a flag. Callers must
367
+ * sanitize the name first (sanitizeName strips control chars/newlines).
368
+ *
369
+ * @param {string} target e.g. "0:3"
370
+ * @param {string} name already-sanitized window name
371
+ * @returns {Promise<void>}
372
+ */
373
+ export async function renameWindow(target, name) {
374
+ assertTarget(target);
375
+ await runTmux(['rename-window', '-t', target, '--', String(name)]);
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Send text (literal, with Enter)
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Send literal text to a tmux pane and then press Enter.
384
+ * Uses `-l` so tmux does not interpret key names.
385
+ *
386
+ * @param {string} target e.g. "0:3"
387
+ * @param {string} text
388
+ * @returns {Promise<void>}
389
+ */
390
+ export async function sendText(target, text) {
391
+ assertTarget(target);
392
+ // Step 1: literal text (no key interpretation)
393
+ await runTmux(['send-keys', '-t', target, '-l', text]);
394
+ // Step 2: press Enter
395
+ await runTmux(['send-keys', '-t', target, 'Enter']);
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Send raw key names (no -l)
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Send a sequence of key names (e.g. 'Down', 'Space', 'Enter') to a pane.
404
+ * Does NOT use `-l`, so tmux interprets these as key names.
405
+ *
406
+ * @param {string} target
407
+ * @param {string[]} keys e.g. ['Down', 'Down', 'Space', 'Enter']
408
+ * @returns {Promise<void>}
409
+ */
410
+ export async function sendRawKeys(target, keys) {
411
+ assertTarget(target);
412
+ if (!Array.isArray(keys) || keys.length === 0) return;
413
+ await runTmux(['send-keys', '-t', target, ...keys]);
414
+ }
415
+
416
+ /**
417
+ * Send key names ONE AT A TIME with a delay between each. Needed for the
418
+ * AskUserQuestion picker, whose single-select number keys trigger an async
419
+ * tab-advance re-render — firing the next key too soon lands it on the wrong
420
+ * question.
421
+ *
422
+ * @param {string} target
423
+ * @param {string[]} keys
424
+ * @param {number} [delayMs=130]
425
+ * @returns {Promise<void>}
426
+ */
427
+ export async function sendRawKeysSequenced(target, keys, delayMs = 130) {
428
+ assertTarget(target);
429
+ if (!Array.isArray(keys) || keys.length === 0) return;
430
+ for (let i = 0; i < keys.length; i += 1) {
431
+ await runTmux(['send-keys', '-t', target, keys[i]]);
432
+ if (i < keys.length - 1) {
433
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
434
+ }
435
+ }
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Capture pane
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /**
443
+ * Capture the visible content of a tmux pane.
444
+ * `-e` preserves ANSI escape sequences (server may strip before forwarding).
445
+ *
446
+ * @param {string} target
447
+ * @param {number} [lines=40] How many history lines above the visible area to include.
448
+ * @returns {Promise<string>}
449
+ */
450
+ export async function capturePane(target, lines = 40) {
451
+ assertTarget(target);
452
+ const { stdout } = await runTmux([
453
+ 'capture-pane',
454
+ '-t', target,
455
+ '-p', // print to stdout
456
+ // NOTE: no '-e' — the UI renders the capture as plain text (LivePane <pre>,
457
+ // AskModal peek), so ANSI escapes would show as literal garbage. Strip them
458
+ // at the source by capturing without escape sequences.
459
+ '-S', `-${lines}`, // start N lines above visible area
460
+ ]);
461
+ return stdout;
462
+ }