@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/push.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// lib/push.js — Web Push (VAPID) fan-out for claude-control.
|
|
2
|
+
//
|
|
3
|
+
// Persists a VAPID keypair and the set of browser PushSubscriptions under
|
|
4
|
+
// ~/.claude-control so notifications survive restarts. sendToAll() delivers a
|
|
5
|
+
// JSON payload to every subscription and prunes stale (404/410) endpoints.
|
|
6
|
+
// Best-effort throughout: a single failing send never throws out of here.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import webpush from 'web-push';
|
|
12
|
+
|
|
13
|
+
const STORE_DIR = path.join(os.homedir(), '.claude-control');
|
|
14
|
+
const VAPID_PATH = path.join(STORE_DIR, 'vapid.json');
|
|
15
|
+
const SUBS_PATH = path.join(STORE_DIR, 'push-subscriptions.json');
|
|
16
|
+
// 'mailto:' contact is required by the spec; localhost is fine for a personal tool.
|
|
17
|
+
const VAPID_CONTACT = 'mailto:claude-control@localhost';
|
|
18
|
+
|
|
19
|
+
/** @type {{publicKey:string, privateKey:string}} */
|
|
20
|
+
let keys;
|
|
21
|
+
/** @type {Array<object>} */
|
|
22
|
+
let subscriptions = [];
|
|
23
|
+
|
|
24
|
+
function ensureStoreDir() {
|
|
25
|
+
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Load existing VAPID keys or generate + persist a new pair (mode 0600). */
|
|
29
|
+
function loadOrCreateKeys() {
|
|
30
|
+
ensureStoreDir();
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(VAPID_PATH, 'utf8');
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (parsed?.publicKey && parsed?.privateKey) return parsed;
|
|
35
|
+
} catch {
|
|
36
|
+
// missing/corrupt → regenerate below
|
|
37
|
+
}
|
|
38
|
+
const generated = webpush.generateVAPIDKeys();
|
|
39
|
+
try {
|
|
40
|
+
fs.writeFileSync(VAPID_PATH, JSON.stringify(generated, null, 2), { mode: 0o600 });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('push: failed to persist VAPID keys:', err?.message || err);
|
|
43
|
+
}
|
|
44
|
+
return generated;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Load persisted subscriptions; tolerate a missing/corrupt file. */
|
|
48
|
+
function loadSubscriptions() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(SUBS_PATH, 'utf8');
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
return Array.isArray(parsed) ? parsed.filter((s) => s?.endpoint) : [];
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function persistSubscriptions() {
|
|
59
|
+
try {
|
|
60
|
+
ensureStoreDir();
|
|
61
|
+
fs.writeFileSync(SUBS_PATH, JSON.stringify(subscriptions, null, 2), { mode: 0o600 });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('push: failed to persist subscriptions:', err?.message || err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Initialize on import.
|
|
68
|
+
keys = loadOrCreateKeys();
|
|
69
|
+
subscriptions = loadSubscriptions();
|
|
70
|
+
webpush.setVapidDetails(VAPID_CONTACT, keys.publicKey, keys.privateKey);
|
|
71
|
+
|
|
72
|
+
/** @returns {string} the VAPID public key (handed to the browser to subscribe). */
|
|
73
|
+
export function getPublicKey() {
|
|
74
|
+
return keys.publicKey;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Add (or refresh) a PushSubscription, deduped by endpoint. Returns true if the
|
|
79
|
+
* store changed.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} sub a browser PushSubscription JSON object
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function addSubscription(sub) {
|
|
85
|
+
if (!sub || typeof sub.endpoint !== 'string') return false;
|
|
86
|
+
const existing = subscriptions.findIndex((s) => s.endpoint === sub.endpoint);
|
|
87
|
+
if (existing >= 0) {
|
|
88
|
+
subscriptions = subscriptions.map((s, i) => (i === existing ? sub : s));
|
|
89
|
+
} else {
|
|
90
|
+
subscriptions = [...subscriptions, sub];
|
|
91
|
+
}
|
|
92
|
+
persistSubscriptions();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Remove a subscription by endpoint. Returns true if one was removed.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} endpoint
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function removeSubscription(endpoint) {
|
|
103
|
+
if (!endpoint) return false;
|
|
104
|
+
const before = subscriptions.length;
|
|
105
|
+
subscriptions = subscriptions.filter((s) => s.endpoint !== endpoint);
|
|
106
|
+
if (subscriptions.length !== before) {
|
|
107
|
+
persistSubscriptions();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {number} current subscription count (for diagnostics/tests). */
|
|
114
|
+
export function subscriptionCount() {
|
|
115
|
+
return subscriptions.length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send a notification payload to every subscription. Stale endpoints (404/410)
|
|
120
|
+
* are pruned. Individual send errors are swallowed/logged so one bad sub never
|
|
121
|
+
* blocks the rest.
|
|
122
|
+
*
|
|
123
|
+
* @param {{title:string, body:string, data?:object}} payload
|
|
124
|
+
* @returns {Promise<{sent:number, removed:number}>}
|
|
125
|
+
*/
|
|
126
|
+
export async function sendToAll({ title, body, data }) {
|
|
127
|
+
if (subscriptions.length === 0) return { sent: 0, removed: 0 };
|
|
128
|
+
const json = JSON.stringify({ title, body, data: data ?? {} });
|
|
129
|
+
const stale = [];
|
|
130
|
+
let sent = 0;
|
|
131
|
+
|
|
132
|
+
await Promise.all(
|
|
133
|
+
subscriptions.map(async (sub) => {
|
|
134
|
+
try {
|
|
135
|
+
await webpush.sendNotification(sub, json);
|
|
136
|
+
sent += 1;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const code = err?.statusCode;
|
|
139
|
+
if (code === 404 || code === 410) {
|
|
140
|
+
stale.push(sub.endpoint);
|
|
141
|
+
} else {
|
|
142
|
+
console.error('push: send failed:', err?.message || err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
let removed = 0;
|
|
149
|
+
for (const endpoint of stale) {
|
|
150
|
+
if (removeSubscription(endpoint)) removed += 1;
|
|
151
|
+
}
|
|
152
|
+
return { sent, removed };
|
|
153
|
+
}
|
package/lib/resources.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/resources.js — Process and system resource monitoring.
|
|
3
|
+
*
|
|
4
|
+
* cpuPct is single-core normalized: 100 means one full CPU core is consumed.
|
|
5
|
+
* Formula: (deltaUser + deltaSystem) [microseconds] / wallMs [milliseconds] / 1000 * 100
|
|
6
|
+
* = cpuMicros / (wallMs * 1000) * 100
|
|
7
|
+
* This can exceed 100 on multi-core systems if the process uses multiple cores.
|
|
8
|
+
* It is intentionally NOT divided by cpuCount so the caller can judge load per-core.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reclaimable ("available") free memory in bytes.
|
|
17
|
+
*
|
|
18
|
+
* `os.freemem()` on macOS counts only truly-free pages — inactive, cached
|
|
19
|
+
* (file-backed), speculative and purgeable memory are all reclaimable but
|
|
20
|
+
* excluded, so memUsedPct computed from it pins near ~98% even when the
|
|
21
|
+
* machine is nowhere near pressure. On darwin we parse `vm_stat` and treat
|
|
22
|
+
* free + inactive + speculative + purgeable + file-backed as available
|
|
23
|
+
* (matching what `memory_pressure` / Activity Monitor report). Returns null
|
|
24
|
+
* on non-darwin or any parse/exec failure so the caller falls back to
|
|
25
|
+
* `os.freemem()`.
|
|
26
|
+
*/
|
|
27
|
+
function reclaimableFreeBytes() {
|
|
28
|
+
if (process.platform !== 'darwin') return null;
|
|
29
|
+
try {
|
|
30
|
+
const out = execSync('vm_stat', { encoding: 'utf8', timeout: 1000 });
|
|
31
|
+
const pageSize = Number((out.match(/page size of (\d+) bytes/) || [])[1]) || 4096;
|
|
32
|
+
const pages = (label) => {
|
|
33
|
+
const m = out.match(new RegExp(`${label}:\\s+(\\d+)\\.`));
|
|
34
|
+
return m ? Number(m[1]) : 0;
|
|
35
|
+
};
|
|
36
|
+
const available =
|
|
37
|
+
pages('Pages free') +
|
|
38
|
+
pages('Pages inactive') +
|
|
39
|
+
pages('Pages speculative') +
|
|
40
|
+
pages('Pages purgeable') +
|
|
41
|
+
pages('File-backed pages');
|
|
42
|
+
return available * pageSize;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class ResourceMonitor extends EventEmitter {
|
|
49
|
+
/**
|
|
50
|
+
* @param {{ intervalMs?: number, rssLimitMB?: number }} opts
|
|
51
|
+
*/
|
|
52
|
+
constructor({ intervalMs = 3000, rssLimitMB = 350 } = {}) {
|
|
53
|
+
super();
|
|
54
|
+
this._intervalMs = intervalMs;
|
|
55
|
+
this._rssLimitMB = rssLimitMB;
|
|
56
|
+
this._timer = null;
|
|
57
|
+
this._overLimit = false;
|
|
58
|
+
|
|
59
|
+
// Capture initial CPU usage baseline so the first tick has a valid delta.
|
|
60
|
+
this._prevCpu = process.cpuUsage();
|
|
61
|
+
this._prevWall = Date.now();
|
|
62
|
+
|
|
63
|
+
// Compute an initial snapshot so snapshot() works before start().
|
|
64
|
+
this._latest = this._compute();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Begin periodic sampling. */
|
|
68
|
+
start() {
|
|
69
|
+
if (this._timer) return;
|
|
70
|
+
this._timer = setInterval(() => this._tick(), this._intervalMs);
|
|
71
|
+
this._timer.unref();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stop periodic sampling. */
|
|
75
|
+
stop() {
|
|
76
|
+
if (this._timer) {
|
|
77
|
+
clearInterval(this._timer);
|
|
78
|
+
this._timer = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Return the latest computed Snapshot (available immediately after construction). */
|
|
83
|
+
snapshot() {
|
|
84
|
+
return this._latest;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- internals -------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
_tick() {
|
|
90
|
+
const snap = this._compute();
|
|
91
|
+
this._latest = snap;
|
|
92
|
+
this.emit('sample', snap);
|
|
93
|
+
|
|
94
|
+
if (snap.overLimit && !this._overLimit) {
|
|
95
|
+
// Rising edge only.
|
|
96
|
+
this._overLimit = true;
|
|
97
|
+
this.emit('overlimit', snap);
|
|
98
|
+
} else if (!snap.overLimit) {
|
|
99
|
+
// Reset so we can emit again if it crosses again later.
|
|
100
|
+
this._overLimit = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_compute() {
|
|
105
|
+
const nowWall = Date.now();
|
|
106
|
+
const nowCpu = process.cpuUsage();
|
|
107
|
+
|
|
108
|
+
const wallMs = Math.max(nowWall - this._prevWall, 1); // guard div-by-zero
|
|
109
|
+
const deltaUser = nowCpu.user - this._prevCpu.user; // microseconds
|
|
110
|
+
const deltaSystem = nowCpu.system - this._prevCpu.system; // microseconds
|
|
111
|
+
|
|
112
|
+
// Single-core normalized CPU %: 100 == one full CPU core.
|
|
113
|
+
// wallMs * 1000 converts wall time to microseconds for the same unit.
|
|
114
|
+
const cpuPct = Math.round(((deltaUser + deltaSystem) / (wallMs * 1000)) * 100 * 10) / 10;
|
|
115
|
+
|
|
116
|
+
this._prevCpu = nowCpu;
|
|
117
|
+
this._prevWall = nowWall;
|
|
118
|
+
|
|
119
|
+
const mem = process.memoryUsage();
|
|
120
|
+
const rssMB = Math.round(mem.rss / 1048576);
|
|
121
|
+
const heapMB = Math.round(mem.heapUsed / 1048576);
|
|
122
|
+
|
|
123
|
+
const loadavg = /** @type {[number, number, number]} */ (os.loadavg());
|
|
124
|
+
const cpuCount = os.cpus().length;
|
|
125
|
+
const totalMB = Math.round(os.totalmem() / 1048576);
|
|
126
|
+
const reclaimable = reclaimableFreeBytes();
|
|
127
|
+
const freeMB = Math.round((reclaimable != null ? reclaimable : os.freemem()) / 1048576);
|
|
128
|
+
const memUsedPct = Math.round(((totalMB - freeMB) / totalMB) * 100 * 10) / 10;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
ts: Date.now(),
|
|
132
|
+
self: { cpuPct, rssMB, heapMB },
|
|
133
|
+
system: { loadavg, cpuCount, totalMB, freeMB, memUsedPct },
|
|
134
|
+
overLimit: rssMB > this._rssLimitMB,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|