deckide 3.5.41 → 3.5.42

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/dist/config.js CHANGED
@@ -35,6 +35,34 @@ export const MAX_FILE_SIZE = parseIntEnv(process.env.MAX_FILE_SIZE, 10 * 1024 *
35
35
  export const TERMINAL_BUFFER_LIMIT = parseIntEnv(process.env.TERMINAL_BUFFER_LIMIT, 500_000);
36
36
  export const MAX_REQUEST_BODY_SIZE = parseIntEnv(process.env.MAX_REQUEST_BODY_SIZE, 1024 * 1024); // 1MB default
37
37
  export const TRUST_PROXY = process.env.TRUST_PROXY === 'true'; // Only trust proxy headers if explicitly enabled
38
+ // --- Agent browser WebRTC streaming (A1: direct Xvfb capture → ffmpeg → werift) ---
39
+ // Master switch. 'false' (default) keeps the JPEG screencast path and never even
40
+ // imports werift. 'true'/'auto' advertise the WebRTC capability; the client still
41
+ // falls back to JPEG on any failure.
42
+ const webrtcFlag = (process.env.AGENT_BROWSER_WEBRTC || 'false').toLowerCase();
43
+ export const AGENT_BROWSER_WEBRTC = webrtcFlag === 'true' || webrtcFlag === 'auto';
44
+ // Video codec for the encoded stream. Only 'vp8' is wired today; 'vp9'/'h264'
45
+ // are accepted for forward-compat and validated in the relay.
46
+ export const AGENT_BROWSER_WEBRTC_CODEC = (process.env.AGENT_BROWSER_WEBRTC_CODEC || 'vp8').toLowerCase();
47
+ // Target video bitrate passed to the encoder (ffmpeg -b:v syntax, e.g. '3M').
48
+ export const AGENT_BROWSER_WEBRTC_BITRATE = process.env.AGENT_BROWSER_WEBRTC_BITRATE || '3M';
49
+ export const AGENT_BROWSER_WEBRTC_FPS = parseIntEnv(process.env.AGENT_BROWSER_WEBRTC_FPS, 30);
50
+ // Optional explicit X display to capture (e.g. ':99'). When unset the relay
51
+ // discovers it from the running Chrome process environment.
52
+ export const AGENT_BROWSER_DISPLAY = process.env.AGENT_BROWSER_DISPLAY || '';
53
+ // Optional ICE servers as JSON (e.g. '[{"urls":"stun:stun.l.google.com:19302"}]').
54
+ // Empty = host candidates only, which is correct for same-host / LAN.
55
+ export const AGENT_BROWSER_WEBRTC_ICE_SERVERS = process.env.AGENT_BROWSER_WEBRTC_ICE_SERVERS || '';
56
+ // --- Shared-browser maintenance ---
57
+ // CDP port the shared agent browser listens on (must match agent-browser's).
58
+ export const AGENT_BROWSER_CDP_PORT = parseIntEnv(process.env.AGENT_BROWSER_CDP_PORT, 9222);
59
+ // Reap leaked chrome-devtools-mcp server stacks when one agent accumulates too
60
+ // many (the observed leak). On by default; set 'false' to disable.
61
+ export const AGENT_BROWSER_REAP_MCP = (process.env.AGENT_BROWSER_REAP_MCP || 'true').toLowerCase() !== 'false';
62
+ // Max chrome-devtools-mcp stacks a single agent may keep before old extras are reaped.
63
+ export const AGENT_BROWSER_MAX_MCP_PER_AGENT = parseIntEnv(process.env.AGENT_BROWSER_MAX_MCP_PER_AGENT, 3);
64
+ // Extra stacks must be older than this before being reaped (spares restart bursts).
65
+ export const AGENT_BROWSER_MCP_IDLE_MS = parseIntEnv(process.env.AGENT_BROWSER_MCP_IDLE_MS, 600_000);
38
66
  // Flat structure: dist/ and web/dist/ are siblings at project root
39
67
  export const distDir = path.resolve(__dirname, '..', 'web', 'dist');
40
68
  export const hasStatic = fsSync.existsSync(distDir);
@@ -0,0 +1,79 @@
1
+ import { Hono } from 'hono';
2
+ // Hop-by-hop / encoding headers that must not be forwarded verbatim. undici's
3
+ // fetch already decompresses the body, so leaving content-encoding/length on the
4
+ // response would make the browser try to decompress decoded bytes.
5
+ const STRIP_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
6
+ async function proxy(c, codeServer) {
7
+ const wsId = c.req.param('wsId');
8
+ if (!wsId) {
9
+ return c.json({ error: 'workspace id required' }, 400);
10
+ }
11
+ let port;
12
+ try {
13
+ port = await codeServer.ensure(wsId);
14
+ }
15
+ catch (error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return c.json({ error: message }, 503);
18
+ }
19
+ const url = new URL(c.req.url);
20
+ const prefix = `/codeserver/${wsId}`;
21
+ let upstreamPath = url.pathname.slice(prefix.length) || '/';
22
+ if (!upstreamPath.startsWith('/')) {
23
+ upstreamPath = `/${upstreamPath}`;
24
+ }
25
+ const target = `http://127.0.0.1:${port}${upstreamPath}${url.search}`;
26
+ const headers = new Headers(c.req.raw.headers);
27
+ headers.set('host', `127.0.0.1:${port}`);
28
+ // Ask code-server not to compress; we re-stream the body raw.
29
+ headers.set('accept-encoding', 'identity');
30
+ const method = c.req.method;
31
+ const init = {
32
+ method,
33
+ headers,
34
+ redirect: 'manual',
35
+ };
36
+ if (method !== 'GET' && method !== 'HEAD') {
37
+ init.body = c.req.raw.body;
38
+ init.duplex = 'half';
39
+ }
40
+ let upstream;
41
+ try {
42
+ upstream = await fetch(target, init);
43
+ }
44
+ catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ return c.json({ error: `code-server proxy failed: ${message}` }, 502);
47
+ }
48
+ const respHeaders = new Headers(upstream.headers);
49
+ for (const name of STRIP_RESPONSE_HEADERS) {
50
+ respHeaders.delete(name);
51
+ }
52
+ // Let code-server's service worker claim the whole subpath scope.
53
+ respHeaders.set('service-worker-allowed', `/codeserver/${wsId}/`);
54
+ return new Response(upstream.body, {
55
+ status: upstream.status,
56
+ statusText: upstream.statusText,
57
+ headers: respHeaders,
58
+ });
59
+ }
60
+ export function createCodeServerRouter(codeServer) {
61
+ const router = new Hono();
62
+ // Capability/status (must be registered before the :wsId catch-all).
63
+ router.get('/status', async (c) => {
64
+ const wsId = c.req.query('wsId') || undefined;
65
+ return c.json(await codeServer.getStatus(wsId));
66
+ });
67
+ router.post('/stop', async (c) => {
68
+ const wsId = c.req.query('wsId');
69
+ if (wsId) {
70
+ await codeServer.stop(wsId);
71
+ }
72
+ return c.json(await codeServer.getStatus(wsId || undefined));
73
+ });
74
+ // Reverse-proxy everything else under /codeserver/:wsId/* to that workspace's
75
+ // code-server instance (prefix stripped).
76
+ router.all('/:wsId/*', (c) => proxy(c, codeServer));
77
+ router.all('/:wsId', (c) => proxy(c, codeServer));
78
+ return router;
79
+ }
@@ -186,6 +186,78 @@ export function createFileRouter(workspaces) {
186
186
  return handleError(c, error);
187
187
  }
188
188
  });
189
+ // Move / rename a file or directory within the workspace (Drive UI).
190
+ router.post('/move', async (c) => {
191
+ try {
192
+ const body = await readJson(c);
193
+ const workspaceId = body?.workspaceId;
194
+ if (!workspaceId) {
195
+ throw createHttpError('workspaceId is required', 400);
196
+ }
197
+ if (!body?.from || !body?.to) {
198
+ throw createHttpError('from and to are required', 400);
199
+ }
200
+ const workspace = requireWorkspace(workspaces, workspaceId);
201
+ const fromPath = await resolveSafePath(workspace.path, body.from);
202
+ const toPath = await resolveSafePath(workspace.path, body.to);
203
+ // Source must exist (throws ENOENT otherwise).
204
+ await fs.stat(fromPath);
205
+ // No-clobber unless the caller explicitly opts in.
206
+ if (!body.overwrite) {
207
+ try {
208
+ await fs.access(toPath);
209
+ throw createHttpError('Destination already exists', 409);
210
+ }
211
+ catch (err) {
212
+ if (err.code !== 'ENOENT') {
213
+ throw err;
214
+ }
215
+ }
216
+ }
217
+ await fs.mkdir(path.dirname(toPath), { recursive: true });
218
+ await fs.rename(fromPath, toPath);
219
+ return c.json({ from: body.from, to: body.to, moved: true });
220
+ }
221
+ catch (error) {
222
+ return handleError(c, error);
223
+ }
224
+ });
225
+ // Upload one or more files into a workspace directory (multipart form). The
226
+ // form field `file` may repeat. Filenames are reduced to their basename so an
227
+ // upload can never escape the target directory.
228
+ router.post('/upload', async (c) => {
229
+ try {
230
+ const form = await c.req.parseBody({ all: true });
231
+ const workspaceId = typeof form.workspaceId === 'string' ? form.workspaceId : undefined;
232
+ if (!workspaceId) {
233
+ throw createHttpError('workspaceId is required', 400);
234
+ }
235
+ const dir = typeof form.path === 'string' ? form.path : '';
236
+ const workspace = requireWorkspace(workspaces, workspaceId);
237
+ const field = form.file;
238
+ const files = (Array.isArray(field) ? field : [field]).filter((item) => item instanceof File);
239
+ if (files.length === 0) {
240
+ throw createHttpError('no files provided', 400);
241
+ }
242
+ const uploaded = [];
243
+ for (const file of files) {
244
+ if (file.size > MAX_FILE_SIZE) {
245
+ throw createHttpError(`File too large: ${file.name} (${Math.round(file.size / 1024 / 1024)}MB). Maximum is ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.`, 413);
246
+ }
247
+ const safeName = path.basename(file.name);
248
+ const rel = dir ? `${dir}/${safeName}` : safeName;
249
+ const target = await resolveSafePath(workspace.path, rel);
250
+ await fs.mkdir(path.dirname(target), { recursive: true });
251
+ const buffer = Buffer.from(await file.arrayBuffer());
252
+ await fs.writeFile(target, buffer);
253
+ uploaded.push(rel);
254
+ }
255
+ return c.json({ uploaded });
256
+ }
257
+ catch (error) {
258
+ return handleError(c, error);
259
+ }
260
+ });
189
261
  // Delete directory
190
262
  router.delete('/dir', async (c) => {
191
263
  try {
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ import { serve } from '@hono/node-server';
7
7
  import { serveStatic } from '@hono/node-server/serve-static';
8
8
  import { bodyLimit } from 'hono/body-limit';
9
9
  import { DatabaseSync } from 'node:sqlite';
10
- import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN, MAX_FILE_SIZE, MAX_REQUEST_BODY_SIZE, TRUST_PROXY, hasStatic, distDir, dbPath, } from './config.js';
10
+ import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN, MAX_FILE_SIZE, MAX_REQUEST_BODY_SIZE, TRUST_PROXY, hasStatic, distDir, dbPath, AGENT_BROWSER_REAP_MCP, AGENT_BROWSER_CDP_PORT, AGENT_BROWSER_MAX_MCP_PER_AGENT, AGENT_BROWSER_MCP_IDLE_MS, } from './config.js';
11
11
  import { securityHeaders } from './middleware/security.js';
12
12
  import { corsMiddleware } from './middleware/cors.js';
13
13
  import { basicAuthMiddleware, generateWsToken, isBasicAuthEnabled } from './middleware/auth.js';
@@ -20,7 +20,10 @@ import { createGitRouter } from './routes/git.js';
20
20
  import { createSettingsRouter } from './routes/settings.js';
21
21
  import { createMcpRouter } from './routes/mcp.js';
22
22
  import { createBrowserRouter } from './routes/browser.js';
23
+ import { createCodeServerRouter } from './routes/codeserver.js';
23
24
  import { AgentBrowserService } from './utils/agent-browser.js';
25
+ import { CodeServerService } from './utils/code-server.js';
26
+ import { startMcpReaper } from './utils/browser-maintenance.js';
24
27
  import { setupWebSocketServer, getConnectionLimit, setConnectionLimit, getConnectionStats, clearAllConnections, } from './websocket.js';
25
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
29
  // Request ID and logging middleware
@@ -51,6 +54,7 @@ export async function createServer() {
51
54
  const decks = new Map();
52
55
  const terminals = new Map();
53
56
  const browserService = new AgentBrowserService();
57
+ const codeServerService = new CodeServerService(workspaces);
54
58
  // 共有ブラウザを起動時から常駐させ、BrowserPane を開いていなくても
55
59
  // Codex/Claude の chrome MCP(--browserUrl 127.0.0.1:9222)が常に使えるようにする。
56
60
  // 失敗してもサーバ起動は継続(Chrome 未導入の環境などを考慮)。AGENT_BROWSER_AUTOSTART=false で無効化可。
@@ -59,6 +63,17 @@ export async function createServer() {
59
63
  console.error('[browser] 共有ブラウザの自動起動に失敗:', err instanceof Error ? err.message : err);
60
64
  });
61
65
  }
66
+ // Periodically reap leaked chrome-devtools-mcp servers (agents spawn one per
67
+ // session against our CDP port and can leave them disconnected — wedging the
68
+ // agent's chrome tools and leaking GBs). Safe: only disconnected + idle ones.
69
+ const stopMcpReaper = AGENT_BROWSER_REAP_MCP
70
+ ? startMcpReaper({
71
+ cdpPort: AGENT_BROWSER_CDP_PORT,
72
+ maxPerAgent: AGENT_BROWSER_MAX_MCP_PER_AGENT,
73
+ idleMs: AGENT_BROWSER_MCP_IDLE_MS,
74
+ intervalMs: 120_000,
75
+ })
76
+ : null;
62
77
  loadPersistedState(db, workspaces, workspacePathIndex, decks);
63
78
  // Create Hono app
64
79
  const app = new Hono();
@@ -80,6 +95,8 @@ export async function createServer() {
80
95
  if (basicAuthMiddleware) {
81
96
  app.use('/api/*', basicAuthMiddleware);
82
97
  app.use('/oauth/authorize', basicAuthMiddleware);
98
+ // code-server runs with --auth none; this same-origin proxy is the gate.
99
+ app.use('/codeserver/*', basicAuthMiddleware);
83
100
  }
84
101
  app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }));
85
102
  // Mount routers
@@ -90,6 +107,7 @@ export async function createServer() {
90
107
  app.route('/api/terminals', terminalRouter);
91
108
  app.route('/api/git', createGitRouter(workspaces));
92
109
  app.route('/api/browser', createBrowserRouter(browserService));
110
+ app.route('/codeserver', createCodeServerRouter(codeServerService));
93
111
  app.get('/api/config', getConfigHandler());
94
112
  app.get('/api/ws-token', (c) => c.json({ token: generateWsToken(), authEnabled: isBasicAuthEnabled() }));
95
113
  app.get('/api/ws/stats', (c) => c.json({ limit: getConnectionLimit(), connections: getConnectionStats() }));
@@ -122,7 +140,7 @@ export async function createServer() {
122
140
  });
123
141
  }
124
142
  const server = serve({ fetch: app.fetch, port: PORT, hostname: HOST });
125
- setupWebSocketServer(server, terminals, browserService);
143
+ setupWebSocketServer(server, terminals, browserService, codeServerService);
126
144
  server.on('listening', () => {
127
145
  const baseUrl = `http://localhost:${PORT}`;
128
146
  console.log(`Deck IDE server listening on ${baseUrl}`);
@@ -157,7 +175,9 @@ export async function createServer() {
157
175
  session.kill();
158
176
  });
159
177
  terminals.clear();
178
+ stopMcpReaper?.();
160
179
  await browserService.stop();
180
+ await codeServerService.stopAll();
161
181
  try {
162
182
  db.close();
163
183
  }
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { WebSocket } from 'ws';
7
7
  import { BrowserAudioRelay } from './browser-audio.js';
8
+ import { BrowserWebRtcRelay } from './browser-webrtc.js';
8
9
  const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'profile');
9
10
  const DEFAULT_OUTPUT_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'output');
10
11
  const START_TIMEOUT_MS = 30_000;
@@ -320,6 +321,9 @@ export class AgentBrowserService {
320
321
  viewport = { ...DEFAULT_VIEWPORT };
321
322
  lastError;
322
323
  audioRelay = new BrowserAudioRelay();
324
+ // A1 WebRTC video path (direct Xvfb capture → ffmpeg → werift). Constructed
325
+ // always, but inert until AGENT_BROWSER_WEBRTC is enabled and a client connects.
326
+ webrtcRelay = new BrowserWebRtcRelay(this);
323
327
  profileDir = process.env.AGENT_BROWSER_PROFILE_DIR || DEFAULT_PROFILE_DIR;
324
328
  outputDir = process.env.AGENT_BROWSER_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
325
329
  async getStatus() {
@@ -336,6 +340,7 @@ export class AgentBrowserService {
336
340
  tabs: [...this.tabs.values()].map((tab) => ({ ...tab })),
337
341
  activeTabId: this.activeTargetId,
338
342
  audio: await this.audioRelay.getStatus(),
343
+ webrtc: await this.webrtcRelay.getStatus(),
339
344
  error: this.lastError,
340
345
  };
341
346
  }
@@ -421,6 +426,7 @@ export class AgentBrowserService {
421
426
  }
422
427
  this.closeLogStream();
423
428
  await this.audioRelay.stop();
429
+ await this.webrtcRelay.stop();
424
430
  await this.broadcastStatus();
425
431
  }
426
432
  attachWebSocket(socket) {
@@ -442,6 +448,38 @@ export class AgentBrowserService {
442
448
  attachAudioWebSocket(socket) {
443
449
  this.audioRelay.attachWebSocket(socket);
444
450
  }
451
+ attachWebRtcWebSocket(socket) {
452
+ this.webrtcRelay.attachWebSocket(socket);
453
+ }
454
+ // --- WebRtcHost: capabilities the WebRTC relay needs from the browser ---
455
+ isBrowserRunning() {
456
+ return this.isRunning();
457
+ }
458
+ // Position+size the active tab's OS window on the X display. No window manager
459
+ // runs under Xvfb, so 'maximized'/'fullscreen' window states are unreliable —
460
+ // explicit normal bounds are not.
461
+ async setActiveWindowBounds(left, top, width, height) {
462
+ if (!this.activeTargetId) {
463
+ return;
464
+ }
465
+ await this.connectBrowserCdp();
466
+ const cdp = this.browserCdp;
467
+ if (!cdp) {
468
+ return;
469
+ }
470
+ const win = await cdp
471
+ .send('Browser.getWindowForTarget', { targetId: this.activeTargetId })
472
+ .catch(() => null);
473
+ if (!win || typeof win.windowId !== 'number') {
474
+ return;
475
+ }
476
+ await cdp
477
+ .send('Browser.setWindowBounds', {
478
+ windowId: win.windowId,
479
+ bounds: { left, top, width, height, windowState: 'normal' },
480
+ })
481
+ .catch(() => undefined);
482
+ }
445
483
  async navigate(input) {
446
484
  const url = normalizeBrowserUrl(input);
447
485
  await this.start();
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs/promises';
2
+ // Linux clock ticks per second. Effectively always 100 on Linux; used only to
3
+ // estimate process age, where small drift is harmless.
4
+ const CLOCK_HZ = 100;
5
+ /**
6
+ * Pure selection: given the set of chrome-devtools-mcp processes (node servers +
7
+ * their npm/sh launcher wrappers), return the pids to kill. Exported for testing.
8
+ *
9
+ * A "stack top" is a proc whose parent is not itself in the set (its parent is
10
+ * the agent). Stacks are grouped by agent; for any agent owning more than
11
+ * `maxPerAgent` stacks, the oldest extras beyond the cap — and only those older
12
+ * than `idleMs` — are selected along with their whole subtree. Healthy agents
13
+ * (≤ maxPerAgent stacks) are never touched.
14
+ */
15
+ export function planReap(procs, maxPerAgent, idleMs) {
16
+ const stacksByAgent = new Map();
17
+ for (const [pid, info] of procs) {
18
+ if (!procs.has(info.ppid)) {
19
+ const list = stacksByAgent.get(info.ppid) ?? [];
20
+ list.push(pid);
21
+ stacksByAgent.set(info.ppid, list);
22
+ }
23
+ }
24
+ const children = new Map();
25
+ for (const [pid, info] of procs) {
26
+ const list = children.get(info.ppid) ?? [];
27
+ list.push(pid);
28
+ children.set(info.ppid, list);
29
+ }
30
+ const kill = [];
31
+ for (const tops of stacksByAgent.values()) {
32
+ if (tops.length <= maxPerAgent) {
33
+ continue;
34
+ }
35
+ tops.sort((a, b) => procs.get(b).ageSec - procs.get(a).ageSec); // oldest first
36
+ for (const top of tops.slice(0, tops.length - maxPerAgent)) {
37
+ if (procs.get(top).ageSec * 1000 < idleMs) {
38
+ continue; // spare recent stacks (e.g. mid-restart)
39
+ }
40
+ const queue = [top];
41
+ while (queue.length) {
42
+ const pid = queue.shift();
43
+ kill.push(pid);
44
+ for (const child of children.get(pid) ?? []) {
45
+ if (procs.has(child))
46
+ queue.push(child);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return kill;
52
+ }
53
+ /**
54
+ * Reaps leaked `chrome-devtools-mcp` server stacks.
55
+ *
56
+ * Agents (Claude/Codex) spawn one chrome-devtools-mcp server per session pointed
57
+ * at our shared browser's CDP port. A buggy agent can respawn it on every failure
58
+ * without reaping the old one — we observed a single Codex process holding 66
59
+ * servers (~24 GB) with the agent's active one wedged ("can't use chrome").
60
+ *
61
+ * NOTE: chrome-devtools-mcp connects to the browser lazily (only during a tool
62
+ * call), so "not connected to 9222" does NOT mean "dead" — an idle-but-live
63
+ * session would look disconnected. We therefore key off the actual leak
64
+ * signature instead: when ONE agent process owns more than `maxPerAgent`
65
+ * chrome-devtools-mcp stacks (aimed at our port), the oldest extras beyond the
66
+ * cap that are also older than `idleMs` are killed, keeping the newest (active)
67
+ * ones. A healthy agent with a single MCP is never touched. Linux-only.
68
+ */
69
+ export function startMcpReaper(opts) {
70
+ const timer = setInterval(() => {
71
+ void reapOnce(opts).catch(() => undefined);
72
+ }, opts.intervalMs);
73
+ timer.unref?.();
74
+ return () => clearInterval(timer);
75
+ }
76
+ // All chrome-devtools-mcp processes (node servers + npm/sh launcher wrappers)
77
+ // whose cmdline targets our CDP port. Keyed by pid.
78
+ async function readMcpProcs(cdpPort) {
79
+ const procs = new Map();
80
+ let uptime;
81
+ try {
82
+ uptime = parseFloat((await fs.readFile('/proc/uptime', 'utf8')).split(' ')[0]);
83
+ }
84
+ catch {
85
+ return procs; // not Linux / no procfs
86
+ }
87
+ let pids;
88
+ try {
89
+ pids = (await fs.readdir('/proc')).filter((n) => /^\d+$/.test(n));
90
+ }
91
+ catch {
92
+ return procs;
93
+ }
94
+ const portToken = `127.0.0.1:${cdpPort}`;
95
+ for (const pid of pids) {
96
+ let cmdline;
97
+ try {
98
+ cmdline = (await fs.readFile(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ').trim();
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ // Target only chrome-devtools-mcp aimed at OUR shared browser.
104
+ if (!cmdline.includes('chrome-devtools-mcp') || !cmdline.includes(portToken)) {
105
+ continue;
106
+ }
107
+ let stat;
108
+ try {
109
+ stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8');
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ // comm (field 2) is parenthesised and may contain spaces/parens — parse after
115
+ // the final ')'. Then index 1 = ppid (field 4), index 19 = starttime (field 22).
116
+ const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
117
+ const ppid = Number.parseInt(after[1], 10);
118
+ const starttime = Number.parseInt(after[19], 10);
119
+ if (!Number.isFinite(ppid) || !Number.isFinite(starttime)) {
120
+ continue;
121
+ }
122
+ procs.set(Number.parseInt(pid, 10), { ppid, cmdline, ageSec: uptime - starttime / CLOCK_HZ });
123
+ }
124
+ return procs;
125
+ }
126
+ async function reapOnce(opts) {
127
+ const procs = await readMcpProcs(opts.cdpPort);
128
+ if (procs.size === 0) {
129
+ return;
130
+ }
131
+ const kill = planReap(procs, opts.maxPerAgent, opts.idleMs);
132
+ let reaped = 0;
133
+ for (const pid of kill) {
134
+ try {
135
+ process.kill(pid, 'SIGTERM');
136
+ reaped++;
137
+ }
138
+ catch {
139
+ // already gone
140
+ }
141
+ }
142
+ if (reaped > 0) {
143
+ console.log(`[browser-maintenance] reaped ${reaped} process(es) from leaked chrome-devtools-mcp stacks`);
144
+ }
145
+ }