caproom 0.3.1 → 0.5.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.
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ // caproom-mcp — MCP server wrapping the caproom CLI so coding agents can
3
+ // discover, freeze, and restore memory-heavy process trees natively.
4
+ //
5
+ // Tools:
6
+ // top {pid?, park_min_mb?} read-only tree inventory (stable schema)
7
+ // park {pid} SIGSTOP an idle tree's root
8
+ // wake {pid} SIGCONT it back
9
+ // watch_start {pids[], threshold_mb?, auto_park?, auto_wake_free_pct?, interval?}
10
+ // watch_events {id} drain NDJSON events from a running watcher
11
+ // watch_stop {id}
12
+ // run {command[], limit_mb?, grace?, image?, docker?}
13
+ // cap a command end-to-end; returns exit code + stderr tail
14
+ //
15
+ // Hand-rolled MCP stdio transport (newline-delimited JSON-RPC 2.0): zero
16
+ // dependencies, same rule as the CLI itself.
17
+
18
+ 'use strict';
19
+
20
+ const { spawn, spawnSync } = require('child_process');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ const CAPROOM = path.join(__dirname, process.platform === 'win32' ? 'caproom.ps1' : 'caproom');
25
+ if (process.platform !== 'win32') {
26
+ try { require('fs').chmodSync(CAPROOM, 0o755); } catch (_) { /* already exec */ }
27
+ }
28
+
29
+ const watchers = new Map();
30
+ let watcherSeq = 0;
31
+
32
+ function caproom(args, opts = {}) {
33
+ const exe = process.platform === 'win32' ? 'powershell.exe' : 'bash';
34
+ const argv = process.platform === 'win32' ? ['-NoProfile', '-File', CAPROOM].concat(args) : [CAPROOM].concat(args);
35
+ return spawnSync(exe, argv, { encoding: 'utf8', timeout: opts.timeout || 30000 });
36
+ }
37
+
38
+ function text(s) {
39
+ return { content: [{ type: 'text', text: String(s) }] };
40
+ }
41
+
42
+ function startWatcher(p) {
43
+ const id = 'w' + (++watcherSeq);
44
+ const args = ['watch', '--json'];
45
+ if (p.threshold_mb != null) args.push('--threshold-mb', String(p.threshold_mb));
46
+ if (p.interval != null) args.push('--interval', String(p.interval));
47
+ if (p.auto_park) args.push('--auto-park');
48
+ if (p.auto_wake_free_pct != null) args.push('--auto-wake-free-pct', String(p.auto_wake_free_pct));
49
+ const pids = Array.isArray(p.pids) ? p.pids : [];
50
+ if (!pids.length) throw new Error('pids[] required');
51
+ for (const x of pids) args.push(String(x));
52
+
53
+ const exe = process.platform === 'win32' ? 'powershell.exe' : 'bash';
54
+ const argv = process.platform === 'win32' ? ['-NoProfile', '-File', CAPROOM].concat(args) : [CAPROOM].concat(args);
55
+ const child = spawn(exe, argv, { stdio: ['ignore', 'pipe', 'pipe'], detached: false });
56
+ const rec = {
57
+ id, child, pids,
58
+ events: [], buf: '', stderr: [],
59
+ };
60
+ child.stdout.on('data', d => {
61
+ rec.buf += d.toString();
62
+ let i;
63
+ while ((i = rec.buf.indexOf('\n')) >= 0) {
64
+ const line = rec.buf.slice(0, i).trim();
65
+ rec.buf = rec.buf.slice(i + 1);
66
+ if (!line) continue;
67
+ try { rec.events.push(JSON.parse(line)); } catch (_) { /* partial */ }
68
+ if (rec.events.length > 1000) rec.events.splice(0, rec.events.length - 1000);
69
+ }
70
+ });
71
+ child.stderr.on('data', d => {
72
+ rec.stderr.push(d.toString());
73
+ if (rec.stderr.length > 50) rec.stderr.splice(0, rec.stderr.length - 50);
74
+ });
75
+ child.on('exit', () => { rec.exited = true; });
76
+ watchers.set(id, rec);
77
+ return { id, pids };
78
+ }
79
+
80
+ function drainWatcher(id, clear) {
81
+ const rec = watchers.get(id);
82
+ if (!rec) throw new Error('unknown watcher id: ' + id);
83
+ const out = rec.events.slice();
84
+ if (clear) rec.events = [];
85
+ return { events: out, exited: !!rec.exited, stderr_tail: rec.stderr.join('').slice(-2000) };
86
+ }
87
+
88
+ function stopWatcher(id) {
89
+ const rec = watchers.get(id);
90
+ if (!rec) throw new Error('unknown watcher id: ' + id);
91
+ if (!rec.exited) {
92
+ try { rec.child.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); } catch (_) {}
93
+ }
94
+ const out = drainWatcher(id, true);
95
+ watchers.delete(id);
96
+ return out;
97
+ }
98
+
99
+ function callTool(name, args) {
100
+ switch (name) {
101
+ case 'top': {
102
+ const a = ['top', '--json'];
103
+ if (args.pid != null) a.push('--pid', String(args.pid));
104
+ if (args.park_min_mb != null) a.push('--park-min-mb', String(args.park_min_mb));
105
+ const r = caproom(a);
106
+ if (r.status !== 0) return text(r.stderr || 'top failed');
107
+ return text(r.stdout.trim());
108
+ }
109
+ case 'park':
110
+ case 'wake': {
111
+ if (args.pid == null) return text('pid required');
112
+ const r = caproom([name, String(args.pid)]);
113
+ return text((r.stderr || '').trim() + (r.status === 0 ? '' : `\nexit=${r.status}`));
114
+ }
115
+ case 'watch_start': {
116
+ const { id, pids } = startWatcher(args);
117
+ return text(JSON.stringify({ id, pids, note: 'poll watch_events{ id } to drain NDJSON events' }));
118
+ }
119
+ case 'watch_events': {
120
+ return text(JSON.stringify(drainWatcher(String(args.id), args.clear !== false)));
121
+ }
122
+ case 'watch_stop': {
123
+ return text(JSON.stringify(stopWatcher(String(args.id))));
124
+ }
125
+ case 'run': {
126
+ const cmd = Array.isArray(args.command) ? args.command : null;
127
+ if (!cmd || !cmd.length) return text('command[] required');
128
+ const a = [];
129
+ a.push('--limit', String(args.limit_mb != null ? args.limit_mb : 4096));
130
+ if (args.grace != null) a.push('--grace', String(args.grace));
131
+ if (args.docker) { a.push('--docker'); if (args.image) a.push('--image', String(args.image)); }
132
+ a.push('--');
133
+ const r = caproom(a.concat(cmd), { timeout: Math.max(60000, (args.timeout_ms || 300000)) });
134
+ let verdict = '';
135
+ if (r.status === 137 || r.signal === 'SIGKILL') verdict = 'RESULT: KILLED BY CAP (exit 137)';
136
+ else if (r.status === 143 || r.signal === 'SIGTERM') verdict = 'RESULT: terminated during grace (SIGTERM honored)';
137
+ else verdict = 'RESULT: exit=' + r.status;
138
+ return text(verdict + '\n--- stderr ---\n' + ((r.stderr || '').slice(-4000) || '(empty)'));
139
+ }
140
+ default:
141
+ throw new Error('unknown tool: ' + name);
142
+ }
143
+ }
144
+
145
+ const TOOLS = [
146
+ {
147
+ name: 'top',
148
+ description: 'Snapshot every process tree you own, sorted by tree RSS. Read-only. Returns caproom\'s stable schema-1 JSON: rows are tree roots with cmd, tree_rss_kb, tree_pids (blast radius), state (running|parked|zombie), park_candidate + reason heuristic.',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ pid: { type: 'number', description: 'restrict to one subtree' },
153
+ park_min_mb: { type: 'number', description: 'park-candidate threshold in MB (default 512)' },
154
+ },
155
+ },
156
+ },
157
+ {
158
+ name: 'park',
159
+ description: 'SIGSTOP an idle process (and only its root — use watch auto_park for whole trees). Pages become eligible for lazy kernel reclaim under pressure; not immediate RAM return. Only park genuinely idle processes — never one an agent awaits a reply from.',
160
+ inputSchema: { type: 'object', properties: { pid: { type: 'number' } }, required: ['pid'] },
161
+ },
162
+ {
163
+ name: 'wake',
164
+ description: 'SIGCONT a parked process back to life, same state, same PID.',
165
+ inputSchema: { type: 'object', properties: { pid: { type: 'number' } }, required: ['pid'] },
166
+ },
167
+ {
168
+ name: 'watch_start',
169
+ description: 'Start a caproom watch daemon on explicit pids. Naming pids is the opt-in; there is no system-wide mode. Emits NDJSON events (started/breach/parked/recovered/woke/all-exited). auto_park freezes whole breaching trees; auto_wake_free_pct restores its own parks when free memory recovers.',
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ pids: { type: 'array', items: { type: 'number' }, description: 'explicit pids to watch' },
174
+ threshold_mb: { type: 'number', description: 'per-tree breach threshold (default 2048)' },
175
+ auto_park: { type: 'boolean' },
176
+ auto_wake_free_pct: { type: 'number' },
177
+ interval: { type: 'number', description: 'poll seconds (default 5)' },
178
+ },
179
+ required: ['pids'],
180
+ },
181
+ },
182
+ {
183
+ name: 'watch_events',
184
+ description: 'Drain accumulated NDJSON events from a watcher (clears them unless clear=false). Also reports whether the watcher exited.',
185
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, clear: { type: 'boolean' } }, required: ['id'] },
186
+ },
187
+ {
188
+ name: 'watch_stop',
189
+ description: 'Stop a watcher and return its final drained events.',
190
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
191
+ },
192
+ {
193
+ name: 'run',
194
+ description: 'Run a command under a caproom memory cap (default watchdog backend; docker:true opts into the container cgroup backend). Kills the whole tree on breach. Returns verdict line (KILLED BY CAP at exit 137) plus captured stderr.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ command: { type: 'array', items: { type: 'string' }, description: 'argv, e.g. ["npm","run","build"]' },
199
+ limit_mb: { type: 'number' },
200
+ grace: { type: 'number', description: 'seconds between SIGTERM and SIGKILL (default 5)' },
201
+ docker: { type: 'boolean' },
202
+ image: { type: 'string' },
203
+ timeout_ms: { type: 'number', description: 'default 300000' },
204
+ },
205
+ required: ['command'],
206
+ },
207
+ },
208
+ ];
209
+
210
+ process.stdin.setEncoding('utf8');
211
+ let inbuf = '';
212
+
213
+ function send(obj) {
214
+ process.stdout.write(JSON.stringify(obj) + '\n');
215
+ }
216
+
217
+ function handle(msg) {
218
+ if (msg.method === 'initialize') {
219
+ send({
220
+ jsonrpc: '2.0', id: msg.id,
221
+ result: {
222
+ protocolVersion: msg.params && msg.params.protocolVersion || '2024-11-05',
223
+ capabilities: { tools: {} },
224
+ serverInfo: { name: 'caproom-mcp', version: require('../package.json').version },
225
+ },
226
+ });
227
+ } else if (msg.method === 'tools/list') {
228
+ send({ jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
229
+ } else if (msg.method === 'tools/call') {
230
+ const { name, arguments: args } = msg.params || {};
231
+ try {
232
+ const res = callTool(name, args || {});
233
+ send({ jsonrpc: '2.0', id: msg.id, result: res });
234
+ } catch (e) {
235
+ send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: 'error: ' + e.message }], isError: true } });
236
+ }
237
+ } else if (msg.method === 'ping') {
238
+ send({ jsonrpc: '2.0', id: msg.id, result: {} });
239
+ } else if (msg.id !== undefined) {
240
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found: ' + msg.method } });
241
+ }
242
+ // notifications (initialized, etc.) get no reply
243
+ }
244
+
245
+ process.stdin.on('data', chunk => {
246
+ inbuf += chunk;
247
+ let i;
248
+ while ((i = inbuf.indexOf('\n')) >= 0) {
249
+ const line = inbuf.slice(0, i).trim();
250
+ inbuf = inbuf.slice(i + 1);
251
+ if (!line) continue;
252
+ let msg;
253
+ try { msg = JSON.parse(line); } catch (_) { continue; }
254
+ try { handle(msg); } catch (e) {
255
+ if (msg && msg.id !== undefined) {
256
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(e.message || e) } });
257
+ }
258
+ }
259
+ }
260
+ });
261
+
262
+ process.on('disconnect', () => {
263
+ for (const id of Array.from(watchers.keys())) stopWatcher(id);
264
+ });
package/bin/caproom.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // Platform dispatch: bash script on POSIX, PowerShell script on Windows.
3
+ const { spawnSync } = require('child_process');
4
+ const { join } = require('path');
5
+
6
+ const args = process.argv.slice(2);
7
+ const isWin = process.platform === 'win32';
8
+
9
+ const result = isWin
10
+ ? spawnSync(
11
+ 'powershell.exe',
12
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(__dirname, 'caproom.ps1'), ...args],
13
+ { stdio: 'inherit' }
14
+ )
15
+ : spawnSync('bash', [join(__dirname, 'caproom'), ...args], { stdio: 'inherit' });
16
+
17
+ if (result.error) {
18
+ console.error(`caproom: failed to launch backend — ${result.error.message}`);
19
+ process.exit(1);
20
+ }
21
+ process.exit(result.status === null ? 1 : result.status);