@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,451 @@
1
+ // lib/transcript.js — bounded transcript tailing for claude-cockpit.
2
+ // Resource doctrine: NEVER read a whole file. Initial load reads only the last
3
+ // min(size, 1 MB) bytes (tail), then watches and reads ONLY new bytes via offset.
4
+ // Files can be 200 MB+; whole-file reads will blow RAM.
5
+
6
+ import fs from 'node:fs';
7
+ import { EventEmitter } from 'node:events';
8
+
9
+ const TAIL_MAX_BYTES = 1 * 1024 * 1024; // 1 MB initial tail cap
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Internal helper: read the last `maxBytes` of a file without loading it all.
13
+ // Returns a Buffer of at most maxBytes bytes from the end of the file.
14
+ // ---------------------------------------------------------------------------
15
+ async function readTail(filePath, maxBytes) {
16
+ const stat = await fs.promises.stat(filePath);
17
+ const size = stat.size;
18
+ if (size === 0) return { buf: Buffer.alloc(0), readFrom: 0, fileSize: 0 };
19
+ const readFrom = Math.max(0, size - maxBytes);
20
+ const toRead = size - readFrom;
21
+ const buf = Buffer.allocUnsafe(toRead);
22
+ const fh = await fs.promises.open(filePath, 'r');
23
+ try {
24
+ let totalRead = 0;
25
+ while (totalRead < toRead) {
26
+ const { bytesRead } = await fh.read(buf, totalRead, toRead - totalRead, readFrom + totalRead);
27
+ if (bytesRead === 0) break;
28
+ totalRead += bytesRead;
29
+ }
30
+ return { buf: buf.slice(0, totalRead), readFrom, fileSize: size };
31
+ } finally {
32
+ await fh.close();
33
+ }
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Read bytes [start, end) from an open path. Returns a Buffer.
38
+ // ---------------------------------------------------------------------------
39
+ async function readRange(filePath, start, end) {
40
+ if (end <= start) return Buffer.alloc(0);
41
+ const toRead = end - start;
42
+ const buf = Buffer.allocUnsafe(toRead);
43
+ const fh = await fs.promises.open(filePath, 'r');
44
+ try {
45
+ let totalRead = 0;
46
+ while (totalRead < toRead) {
47
+ const { bytesRead } = await fh.read(buf, totalRead, toRead - totalRead, start + totalRead);
48
+ if (bytesRead === 0) break;
49
+ totalRead += bytesRead;
50
+ }
51
+ return buf.slice(0, totalRead);
52
+ } finally {
53
+ await fh.close();
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Flatten tool_result content: string | {type:'text',text}[] -> string
59
+ // ---------------------------------------------------------------------------
60
+ function flattenContent(content) {
61
+ if (typeof content === 'string') return content;
62
+ if (Array.isArray(content)) {
63
+ return content
64
+ .filter((b) => b && b.type === 'text')
65
+ .map((b) => b.text ?? '')
66
+ .join('');
67
+ }
68
+ return '';
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Build a one-line <=120-char summary of a tool_use input object.
73
+ // ---------------------------------------------------------------------------
74
+ function inputSummary(input) {
75
+ if (input == null) return '';
76
+ let s;
77
+ try {
78
+ s = JSON.stringify(input);
79
+ } catch {
80
+ s = String(input);
81
+ }
82
+ // Collapse newlines/tabs to spaces, then truncate.
83
+ s = s.replace(/[\r\n\t]+/g, ' ');
84
+ if (s.length > 120) s = s.slice(0, 117) + '...';
85
+ return s;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // parseRecord(line) -> NormalizedMessage | null
90
+ //
91
+ // Parses one JSONL line. Only type:"user" and type:"assistant" produce messages.
92
+ // All other types (summary, last-prompt, tool, etc.) return null.
93
+ // ---------------------------------------------------------------------------
94
+ export function parseRecord(line) {
95
+ const trimmed = line.trim();
96
+ if (!trimmed) return null;
97
+
98
+ let record;
99
+ try {
100
+ record = JSON.parse(trimmed);
101
+ } catch {
102
+ return null;
103
+ }
104
+
105
+ const rawType = record.type;
106
+ if (rawType !== 'user' && rawType !== 'assistant') return null;
107
+
108
+ const msg = record.message;
109
+ if (!msg) return null;
110
+
111
+ const role = rawType; // 'user' | 'assistant'
112
+ const uuid = record.uuid ?? null;
113
+ const ts = record.timestamp ?? null;
114
+
115
+ // Normalize content -> Block[]
116
+ const rawContent = msg.content;
117
+ let blocks = [];
118
+
119
+ if (typeof rawContent === 'string') {
120
+ // User prompt as plain string.
121
+ blocks = [{ kind: 'text', text: rawContent }];
122
+ } else if (Array.isArray(rawContent)) {
123
+ for (const block of rawContent) {
124
+ if (!block || typeof block !== 'object') continue;
125
+ const btype = block.type;
126
+
127
+ if (btype === 'text') {
128
+ blocks.push({ kind: 'text', text: block.text ?? '' });
129
+
130
+ } else if (btype === 'thinking') {
131
+ blocks.push({ kind: 'thinking', text: block.thinking ?? block.text ?? '' });
132
+
133
+ } else if (btype === 'tool_use') {
134
+ blocks.push({
135
+ kind: 'tool_use',
136
+ id: block.id ?? null,
137
+ name: block.name ?? '',
138
+ input: block.input ?? {},
139
+ inputSummary: inputSummary(block.input),
140
+ });
141
+
142
+ } else if (btype === 'tool_result') {
143
+ blocks.push({
144
+ kind: 'tool_result',
145
+ forId: block.tool_use_id ?? null,
146
+ text: flattenContent(block.content),
147
+ isError: !!block.is_error,
148
+ });
149
+ }
150
+ // Unknown block types are silently skipped.
151
+ }
152
+ }
153
+
154
+ return { uuid, role, ts, blocks, rawType };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // TranscriptTailer — EventEmitter that tails a single JSONL transcript file.
159
+ //
160
+ // Events:
161
+ // 'append' (msgs: NormalizedMessage[]) new messages parsed since last read
162
+ // 'pending' (p: Pending | null) AskUserQuestion open/close state changed
163
+ // 'error' (err)
164
+ // ---------------------------------------------------------------------------
165
+ export class TranscriptTailer extends EventEmitter {
166
+ /**
167
+ * @param {string} filePath
168
+ * @param {{ maxBuffer?: number, debounceMs?: number }} options
169
+ */
170
+ constructor(filePath, { maxBuffer = 500, debounceMs = 150 } = {}) {
171
+ super();
172
+ this._filePath = filePath;
173
+ this._maxBuffer = maxBuffer;
174
+ this._debounceMs = debounceMs;
175
+
176
+ /** @type {import('./transcript.js').NormalizedMessage[]} */
177
+ this._messages = [];
178
+
179
+ /** Byte offset: next read starts here. */
180
+ this._offset = 0;
181
+
182
+ /** Partial line leftover from the last incremental read. */
183
+ this._leftover = '';
184
+
185
+ /** Map of open AskUserQuestion tool_use_id -> Pending */
186
+ this._pendingMap = new Map();
187
+
188
+ /** The currently-reported Pending (what we last emitted). null = none. */
189
+ this._currentPending = null;
190
+
191
+ /** fs.FSWatcher | null */
192
+ this._watcher = null;
193
+
194
+ /** Debounce timer handle */
195
+ this._debounceTimer = null;
196
+
197
+ /** Serializes _readIncremental so two reads can't double-consume bytes. */
198
+ this._reading = false;
199
+
200
+ /** Set by stop(); guards against attaching a watcher after teardown. */
201
+ this._stopped = false;
202
+ }
203
+
204
+ // -------------------------------------------------------------------------
205
+ // Public API
206
+ // -------------------------------------------------------------------------
207
+
208
+ /** Full buffered message list (up to maxBuffer most recent). */
209
+ getMessages() {
210
+ return this._messages.slice();
211
+ }
212
+
213
+ /** Most-recently-opened still-open Pending, or null. */
214
+ getPending() {
215
+ return this._currentPending;
216
+ }
217
+
218
+ /** Drop all but the most recent `keepN` buffered messages (memory pressure relief). */
219
+ trim(keepN) {
220
+ if (keepN >= 0 && this._messages.length > keepN) {
221
+ this._messages = this._messages.slice(this._messages.length - keepN);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Perform the bounded initial tail load, set the byte offset, then start
227
+ * watching for new data. Safe to call only once; calling again is a no-op
228
+ * if already watching.
229
+ */
230
+ async start() {
231
+ if (this._watcher) return;
232
+
233
+ try {
234
+ await this._initialLoad();
235
+ } catch (err) {
236
+ this.emit('error', err);
237
+ return;
238
+ }
239
+
240
+ // stop() may have been called while the bounded tail read was awaiting
241
+ // (e.g. the subscribing client disconnected mid-load). Don't attach a
242
+ // watcher to a tailer nobody is listening to — that would leak an fd.
243
+ if (this._stopped) return;
244
+
245
+ // Start watching. Use 'rename' events too (handles log rotation).
246
+ try {
247
+ this._watcher = fs.watch(this._filePath, { persistent: false }, () => {
248
+ this._scheduleRead();
249
+ });
250
+ this._watcher.on('error', (err) => this.emit('error', err));
251
+ } catch (err) {
252
+ this.emit('error', err);
253
+ return;
254
+ }
255
+
256
+ // Bridge the race window: bytes may have arrived between the initial stat
257
+ // (which set this._offset) and the watcher being registered by the OS.
258
+ // Kick off an immediate incremental read to catch any bytes missed in that gap.
259
+ // Use setImmediate to let the watcher finish OS-level registration first.
260
+ setImmediate(() => {
261
+ if (!this._watcher) return; // stop() was called before this fired
262
+ this._readIncremental().catch((err) => {
263
+ // ENOENT means the file was deleted/rotated; not a hard error here.
264
+ if (err.code !== 'ENOENT') this.emit('error', err);
265
+ });
266
+ });
267
+ }
268
+
269
+ /** Stop watching, cancel any pending debounce. */
270
+ stop() {
271
+ this._stopped = true;
272
+ if (this._debounceTimer !== null) {
273
+ clearTimeout(this._debounceTimer);
274
+ this._debounceTimer = null;
275
+ }
276
+ if (this._watcher) {
277
+ try { this._watcher.close(); } catch { /* ignore */ }
278
+ this._watcher = null;
279
+ }
280
+ }
281
+
282
+ // -------------------------------------------------------------------------
283
+ // Private helpers
284
+ // -------------------------------------------------------------------------
285
+
286
+ /** Load the last TAIL_MAX_BYTES, parse, populate buffer, set offset. */
287
+ async _initialLoad() {
288
+ const { buf, readFrom, fileSize } = await readTail(this._filePath, TAIL_MAX_BYTES);
289
+ this._offset = fileSize;
290
+
291
+ if (buf.length === 0) return;
292
+
293
+ const text = buf.toString('utf8');
294
+ let lines = text.split('\n');
295
+
296
+ // If we started reading mid-file, the first segment is a partial line.
297
+ // Drop it unless we read from byte 0.
298
+ if (readFrom > 0) {
299
+ lines = lines.slice(1);
300
+ }
301
+
302
+ // Trailing partial: if text doesn't end with \n, the last element is an
303
+ // in-progress record. Carry it as leftover (offset already points past it),
304
+ // so the next incremental read reassembles the full record instead of
305
+ // dropping it.
306
+ this._leftover = '';
307
+ if (text.length > 0 && text[text.length - 1] !== '\n') {
308
+ this._leftover = lines[lines.length - 1];
309
+ lines = lines.slice(0, -1);
310
+ }
311
+
312
+ const parsed = [];
313
+ for (const line of lines) {
314
+ const msg = parseRecord(line);
315
+ if (msg) {
316
+ parsed.push(msg);
317
+ this._trackPending(msg);
318
+ }
319
+ }
320
+
321
+ // Cap buffer.
322
+ const all = parsed;
323
+ if (all.length > this._maxBuffer) {
324
+ this._messages = all.slice(all.length - this._maxBuffer);
325
+ } else {
326
+ this._messages = all;
327
+ }
328
+
329
+ // Emit initial pending state (so a question already on-screen is detected).
330
+ this._syncPending();
331
+ }
332
+
333
+ _scheduleRead() {
334
+ if (this._debounceTimer !== null) return;
335
+ this._debounceTimer = setTimeout(() => {
336
+ this._debounceTimer = null;
337
+ this._readIncremental().catch((err) => this.emit('error', err));
338
+ }, this._debounceMs);
339
+ }
340
+
341
+ async _readIncremental() {
342
+ if (this._stopped) return;
343
+ // Serialize: if a read is already in flight, ask for another pass after it
344
+ // finishes rather than double-consuming the same byte range.
345
+ if (this._reading) {
346
+ this._scheduleRead();
347
+ return;
348
+ }
349
+ this._reading = true;
350
+ try {
351
+ let stat;
352
+ try {
353
+ stat = await fs.promises.stat(this._filePath);
354
+ } catch (err) {
355
+ this.emit('error', err);
356
+ return;
357
+ }
358
+
359
+ const newSize = stat.size;
360
+
361
+ // Truncation / log rotation: the file is effectively new. Re-initialize
362
+ // from the bounded tail so we never read a multi-MB replacement in full.
363
+ if (newSize < this._offset) {
364
+ this._leftover = '';
365
+ this._pendingMap.clear();
366
+ await this._initialLoad();
367
+ return;
368
+ }
369
+
370
+ if (newSize <= this._offset) return; // Nothing new.
371
+
372
+ const rawBuf = await readRange(this._filePath, this._offset, newSize);
373
+ this._offset = newSize;
374
+
375
+ const chunk = this._leftover + rawBuf.toString('utf8');
376
+ const lines = chunk.split('\n');
377
+
378
+ // Last element: may be an incomplete line if no trailing newline.
379
+ this._leftover = lines[lines.length - 1];
380
+ // Guard against a pathological never-newline-terminated line growing
381
+ // without bound (honors the same 1 MB ceiling as the initial tail).
382
+ if (this._leftover.length > TAIL_MAX_BYTES) this._leftover = '';
383
+ const complete = lines.slice(0, -1);
384
+
385
+ const newMsgs = [];
386
+ for (const line of complete) {
387
+ const msg = parseRecord(line);
388
+ if (msg) {
389
+ newMsgs.push(msg);
390
+ this._trackPending(msg);
391
+ }
392
+ }
393
+
394
+ if (newMsgs.length > 0) {
395
+ // Append to buffer, cap at maxBuffer.
396
+ this._messages.push(...newMsgs);
397
+ if (this._messages.length > this._maxBuffer) {
398
+ this._messages = this._messages.slice(this._messages.length - this._maxBuffer);
399
+ }
400
+ this.emit('append', newMsgs);
401
+ }
402
+
403
+ this._syncPending();
404
+ } finally {
405
+ this._reading = false;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Inspect a newly-parsed message for AskUserQuestion tool_use blocks
411
+ * (adds to pendingMap) and tool_result blocks (closes them).
412
+ */
413
+ _trackPending(msg) {
414
+ for (const block of msg.blocks) {
415
+ if (block.kind === 'tool_use' && block.name === 'AskUserQuestion') {
416
+ // input.questions is the questions array per CONTRACT spec.
417
+ const questions = Array.isArray(block.input?.questions)
418
+ ? block.input.questions
419
+ : [];
420
+ this._pendingMap.set(block.id, {
421
+ toolUseId: block.id,
422
+ ts: msg.ts,
423
+ questions,
424
+ });
425
+ } else if (block.kind === 'tool_result' && block.forId) {
426
+ this._pendingMap.delete(block.forId);
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Derive the effective pending (most-recently-opened still-open entry).
433
+ * Emit 'pending' only when it actually changes.
434
+ */
435
+ _syncPending() {
436
+ // Most-recently-opened = last entry in insertion order of the Map.
437
+ let latest = null;
438
+ for (const v of this._pendingMap.values()) {
439
+ latest = v; // last wins (Map preserves insertion order)
440
+ }
441
+
442
+ // Compare by toolUseId; null == null.
443
+ const prevId = this._currentPending?.toolUseId ?? null;
444
+ const nextId = latest?.toolUseId ?? null;
445
+
446
+ if (prevId !== nextId) {
447
+ this._currentPending = latest;
448
+ this.emit('pending', this._currentPending);
449
+ }
450
+ }
451
+ }
package/lib/tui.js ADDED
@@ -0,0 +1,50 @@
1
+ // lib/tui.js — parse the Claude Code TUI status line from a capture-pane dump.
2
+ //
3
+ // The bottom of a Claude session renders a status line such as:
4
+ // /claude-cockpit Opus 4.8 (1M context) ctx:35% Remote Control active
5
+ // and a title rule line such as:
6
+ // ───────────────────── auto-cleanup-uploads ──
7
+ // We extract the model label, the context-remaining percentage, and (best
8
+ // effort) the title. All fields are optional — older/narrower panes may omit them.
9
+
10
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
11
+ const CTX_RE = /ctx:\s*(\d+)\s*%/i;
12
+ const MODEL_RE = /\b(Opus|Sonnet|Haiku)\s+[\d.]+(?:\s*\([^)]*\))?/i;
13
+
14
+ /**
15
+ * @param {string} capture raw `tmux capture-pane -p` output (ANSI ok)
16
+ * @returns {{ ctxPct: number|null, model: string|null }}
17
+ */
18
+ export function parseTuiStatus(capture) {
19
+ const text = String(capture || '').replace(ANSI_RE, '');
20
+
21
+ let ctxPct = null;
22
+ const ctxMatch = text.match(CTX_RE);
23
+ if (ctxMatch) {
24
+ const n = Number(ctxMatch[1]);
25
+ if (Number.isFinite(n) && n >= 0 && n <= 100) ctxPct = n;
26
+ }
27
+
28
+ let model = null;
29
+ const modelMatch = text.match(MODEL_RE);
30
+ if (modelMatch) model = modelMatch[0].replace(/\s+/g, ' ').trim();
31
+
32
+ return { ctxPct, model };
33
+ }
34
+
35
+ /**
36
+ * Prettify a transcript model id (e.g. "claude-opus-4-8") into a short label
37
+ * ("Opus 4.8"). Falls back to the raw id when the shape is unfamiliar.
38
+ *
39
+ * @param {string|null} modelId
40
+ * @returns {string|null}
41
+ */
42
+ export function prettyModel(modelId) {
43
+ if (!modelId || typeof modelId !== 'string') return null;
44
+ const m = modelId.match(/(opus|sonnet|haiku)-(\d+)-(\d+)/i);
45
+ if (m) {
46
+ const family = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
47
+ return `${family} ${m[2]}.${m[3]}`;
48
+ }
49
+ return modelId;
50
+ }
package/lib/uploads.js ADDED
@@ -0,0 +1,42 @@
1
+ // lib/uploads.js — retention sweep for the attachment uploads directory.
2
+ // Deletes files older than ttlMs. Safe to call on a missing directory.
3
+
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Remove files in `dir` whose mtime is older than `ttlMs`.
9
+ *
10
+ * @param {string} dir
11
+ * @param {number} ttlMs max age in milliseconds
12
+ * @param {number} [now] current epoch ms (injectable for tests)
13
+ * @returns {Promise<{removed:number, kept:number}>}
14
+ */
15
+ export async function sweepUploads(dir, ttlMs, now = Date.now()) {
16
+ let entries;
17
+ try {
18
+ entries = await fs.readdir(dir, { withFileTypes: true });
19
+ } catch (err) {
20
+ if (err.code === 'ENOENT') return { removed: 0, kept: 0 };
21
+ throw err;
22
+ }
23
+
24
+ let removed = 0;
25
+ let kept = 0;
26
+ for (const e of entries) {
27
+ if (!e.isFile()) continue;
28
+ const full = path.join(dir, e.name);
29
+ try {
30
+ const st = await fs.stat(full);
31
+ if (now - st.mtimeMs > ttlMs) {
32
+ await fs.unlink(full);
33
+ removed += 1;
34
+ } else {
35
+ kept += 1;
36
+ }
37
+ } catch {
38
+ // Ignore per-file races (e.g. concurrently deleted); count nothing.
39
+ }
40
+ }
41
+ return { removed, kept };
42
+ }
package/lib/version.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * lib/version.js — release-update detection (git-based).
3
+ *
4
+ * claude-control is distributed as a git checkout and updates via `git pull`
5
+ * (the in-UI "Update now" button), so "is there a new release?" is answered by
6
+ * comparing the local checkout against its `origin` upstream — accurate, and
7
+ * immune to npm name-squatting (the public `claude-control`/`claude-cockpit`
8
+ * names are namesakes, not this project). Version NUMBERS still follow npm
9
+ * semver via package.json.
10
+ *
11
+ * Best-effort + cached: a non-git checkout, missing origin, or offline state
12
+ * simply reports "no update" and never throws.
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { execFile as _execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+
20
+ const execFile = promisify(_execFile);
21
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const PKG_PATH = path.join(ROOT, 'package.json');
23
+ const REFRESH_MS = 6 * 60 * 60 * 1000; // re-check upstream at most every 6h
24
+
25
+ /** Running version from package.json. */
26
+ export function currentVersion() {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')).version || '0.0.0';
29
+ } catch {
30
+ return '0.0.0';
31
+ }
32
+ }
33
+
34
+ async function git(args) {
35
+ const { stdout } = await execFile('git', args, { cwd: ROOT, timeout: 8000 });
36
+ return stdout.trim();
37
+ }
38
+
39
+ let cache = { info: null, checkedAt: 0 };
40
+
41
+ /**
42
+ * { current, latest, behind, updateAvailable }.
43
+ * - behind: commits on origin/<branch> not in HEAD.
44
+ * - latest: version field of origin's package.json (may equal current if the
45
+ * upstream bumped commits without bumping the version).
46
+ */
47
+ export async function getVersionInfo({ force = false, now = Date.now() } = {}) {
48
+ const current = currentVersion();
49
+ if (!force && cache.info && now - cache.checkedAt < REFRESH_MS) {
50
+ return { current, ...cache.info };
51
+ }
52
+
53
+ let behind = 0;
54
+ let latest = null;
55
+ try {
56
+ const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD']);
57
+ await git(['fetch', '--quiet', 'origin', branch]);
58
+ behind = parseInt(await git(['rev-list', '--count', `HEAD..origin/${branch}`]), 10) || 0;
59
+ if (behind > 0) {
60
+ try {
61
+ latest = JSON.parse(await git(['show', `origin/${branch}:package.json`])).version || null;
62
+ } catch {
63
+ latest = null;
64
+ }
65
+ }
66
+ } catch {
67
+ // not a git checkout / no origin / offline — treat as up to date.
68
+ }
69
+
70
+ const info = { latest, behind, updateAvailable: behind > 0 };
71
+ cache = { info, checkedAt: now };
72
+ return { current, ...info };
73
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@idl3/claude-control",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.",
6
+ "keywords": [
7
+ "claude",
8
+ "claude-code",
9
+ "tmux",
10
+ "dashboard",
11
+ "agent"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/idl3/claude-control.git"
17
+ },
18
+ "homepage": "https://github.com/idl3/claude-control#readme",
19
+ "bin": {
20
+ "claude-control": "bin/cli.js"
21
+ },
22
+ "files": [
23
+ "server.js",
24
+ "lib/",
25
+ "public/",
26
+ "web/dist/",
27
+ "bin/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "start": "node server.js",
33
+ "dev": "node --watch server.js",
34
+ "test": "node --test",
35
+ "build:web": "cd web && npm install && npm run build",
36
+ "build": "npm run build:web",
37
+ "prepack": "npm run build"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "web-push": "^3.6.7",
47
+ "ws": "^8.18.0"
48
+ }
49
+ }