runtime-inspector 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.
@@ -0,0 +1,233 @@
1
+ // RuntimeInspector Agent — main entry point
2
+ // Scans processes, groups into sessions, serves API + embedded dashboard
3
+
4
+ import express from 'express';
5
+ import { scanProcesses, evictStaleCwdEntries } from './scanner.js';
6
+ import { groupIntoSessions } from './grouper.js';
7
+ import { addExplanations } from './explainer.js';
8
+ import { addPurpose } from './purposer.js';
9
+ import { addStateInference } from './stateInfer.js';
10
+ import { mergeRepoActivity } from './repoActivity.js';
11
+ import { addProgressInference } from './progressInfer.js';
12
+ import { registerActions } from './actions.js';
13
+ import { registerShellEventRoutes, evictStaleShellEvents } from './shellEvents.js';
14
+ import { mergeShellContext } from './shellMerge.js';
15
+ import { addAttentionInference, computeSafeToLeave } from './attentionInfer.js';
16
+ import { isTmuxAvailable, listPanes, samplePaneActivity, recheckTmux, registerTmuxRoutes } from './tmux.js';
17
+ import { mergeTmuxContext } from './tmuxMerge.js';
18
+ import { dashboardHTML } from './dashboard.js';
19
+
20
+ const PORT = parseInt(process.env.PORT || '7331', 10);
21
+ const SCAN_INTERVAL = 120_000; // 2 minutes
22
+ const WRAPPER_TTL = 5 * 60 * 1000; // 5 minutes after exit
23
+
24
+ let currentSessions = [];
25
+ let lastScanTime = null;
26
+
27
+ // Wrapped session store: pid -> wrapper report data
28
+ const wrappedSessions = new Map();
29
+
30
+ function mergeWrappedData(sessions) {
31
+ for (const session of sessions) {
32
+ // Check if any PID in this session matches a wrapped session
33
+ for (const proc of session.processes) {
34
+ const wrapped = wrappedSessions.get(proc.pid);
35
+ if (wrapped) {
36
+ session.isWrapped = true;
37
+ session.runtimeState = wrapped.state;
38
+ session.lastActivityAt = wrapped.lastOutputAt;
39
+ session.recentOutput = wrapped.recentOutput;
40
+ session.wrappedCmd = wrapped.cmd;
41
+ session.wrappedStartedAt = wrapped.startedAt;
42
+ session.wrappedLineCount = wrapped.lineCount;
43
+ session.eta = null; // will be set by stateInfer
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ function cleanExpiredWrapped() {
51
+ const now = Date.now();
52
+ for (const [pid, data] of wrappedSessions) {
53
+ if (data.exitCode !== null && data.exitCode !== undefined) {
54
+ const reportAge = now - (data._receivedAt || 0);
55
+ if (reportAge > WRAPPER_TTL) {
56
+ wrappedSessions.delete(pid);
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // ------------------------------------------------------------------
63
+ // Scan pipeline — order matters!
64
+ //
65
+ // The pipeline is split into named stages with documented dependencies.
66
+ // Each stage mutates `sessions` in place. Reordering stages will break
67
+ // downstream consumers that depend on fields populated by earlier stages.
68
+ //
69
+ // Stage | Depends on | Populates
70
+ // ------------------- | ----------------------- | -------------------------
71
+ // 1. scan | — | raw process list
72
+ // 2. group | scan | sessions[] with pids, cpu, mem, cwd
73
+ // 3. explain | group | session.explanation
74
+ // 4. purpose | group | session.purpose
75
+ // 5. repoActivity | group (cwd) | session.repoActivity
76
+ // 6. shellContext | group (cwd, pids) | session.shellEvents
77
+ // 7. tmux | group (cwd, pids) | session.tmux
78
+ // 8. wrappedData | group (pids) | session.isWrapped, runtimeState, etc.
79
+ // 9. progress | repoActivity, wrapped | session.progress
80
+ // 10. state | progress | session.inferredState
81
+ // 11. attention | state, tmux | session.attention, severity
82
+ // ------------------------------------------------------------------
83
+ async function scan() {
84
+ try {
85
+ // Stage 1–2: Collect and group processes
86
+ const processes = await scanProcesses();
87
+ const sessions = await groupIntoSessions(processes);
88
+
89
+ // Stage 3–4: Static enrichment (no cross-stage deps)
90
+ addExplanations(sessions);
91
+ addPurpose(sessions);
92
+
93
+ // Stage 5–7: Data-source merges (order among these is independent)
94
+ await mergeRepoActivity(sessions);
95
+ mergeShellContext(sessions);
96
+
97
+ // tmux two-pass: list panes -> merge metadata -> sample activity -> merge output timestamps
98
+ const panes = await listPanes();
99
+ if (panes.length > 0) {
100
+ mergeTmuxContext(sessions, panes, null);
101
+ const mappedPaneIds = sessions.filter(s => s.tmux).map(s => s.tmux.paneId);
102
+ if (mappedPaneIds.length > 0) {
103
+ const activity = await samplePaneActivity(mappedPaneIds);
104
+ mergeTmuxContext(sessions, panes, activity);
105
+ }
106
+ }
107
+
108
+ // Stage 8: Merge wrapper data (before inference stages)
109
+ mergeWrappedData(sessions);
110
+
111
+ // Stage 9–11: Inference chain — MUST run in this order
112
+ addProgressInference(sessions); // reads repoActivity, wrapped
113
+ addStateInference(sessions); // reads progress
114
+ addAttentionInference(sessions); // reads state, tmux
115
+
116
+ // Final sort: ai-agents first, then dev-servers, then scripts
117
+ const typeOrder = { 'ai-agent': 0, 'dev-server': 1, 'script': 2, 'unknown': 3 };
118
+ sessions.sort((a, b) => {
119
+ const typeDiff = (typeOrder[a.type] ?? 3) - (typeOrder[b.type] ?? 3);
120
+ if (typeDiff !== 0) return typeDiff;
121
+ return b.cpu - a.cpu;
122
+ });
123
+
124
+ currentSessions = sessions;
125
+ lastScanTime = new Date().toISOString();
126
+
127
+ // Housekeeping: evict dead PIDs from cwd cache + stale shell events + expired wrappers
128
+ const livePids = new Set(processes.map(p => p.pid));
129
+ evictStaleCwdEntries(livePids);
130
+ evictStaleShellEvents();
131
+ cleanExpiredWrapped();
132
+ } catch (err) {
133
+ console.error('[agent] Scan error:', err.message);
134
+ }
135
+ }
136
+
137
+ export async function startServer({ open: shouldOpen = false } = {}) {
138
+ await scan();
139
+ const scanTimer = setInterval(() => scan().catch(err => console.error('[agent] Scan error:', err.message)), SCAN_INTERVAL);
140
+
141
+ const app = express();
142
+ app.use(express.json());
143
+
144
+ app.get('/api/sessions', (req, res) => {
145
+ const safetySummary = computeSafeToLeave(currentSessions);
146
+ res.json({
147
+ sessions: currentSessions,
148
+ scannedAt: lastScanTime,
149
+ processCount: currentSessions.reduce((sum, s) => sum + s.processes.length, 0),
150
+ hasWrapped: currentSessions.some(s => s.isWrapped),
151
+ ...safetySummary,
152
+ });
153
+ });
154
+
155
+ // Wrapper report endpoint — receives output + state from wrapper runner
156
+ app.post('/api/wrapper/report', (req, res) => {
157
+ const { pid, cmd, state, recentOutput, lastOutputAt, startedAt, exitCode, lineCount } = req.body;
158
+ if (!pid) {
159
+ return res.status(400).json({ error: 'pid required' });
160
+ }
161
+
162
+ wrappedSessions.set(pid, {
163
+ pid,
164
+ cmd,
165
+ state,
166
+ recentOutput: recentOutput || [],
167
+ lastOutputAt,
168
+ startedAt,
169
+ exitCode,
170
+ lineCount: lineCount || 0,
171
+ _receivedAt: Date.now(),
172
+ });
173
+
174
+ // Re-merge wrapped data into current sessions without full rescan
175
+ mergeWrappedData(currentSessions);
176
+ addStateInference(currentSessions);
177
+
178
+ res.json({ ok: true });
179
+ });
180
+
181
+ app.get('/api/health', (req, res) => {
182
+ res.json({ status: 'ok', uptime: process.uptime() });
183
+ });
184
+
185
+ // Shell event bus (command start/end from shell hooks)
186
+ registerShellEventRoutes(app);
187
+
188
+ // tmux pane debug endpoint
189
+ registerTmuxRoutes(app);
190
+
191
+ // Context actions (open terminal, open folder, copy command)
192
+ registerActions(app, (sessionId) => currentSessions.find(s => s.id === sessionId));
193
+
194
+ app.get('/', (req, res) => {
195
+ res.type('html').send(dashboardHTML());
196
+ });
197
+
198
+ // Re-check tmux availability every 2 minutes (user may start/stop tmux)
199
+ setInterval(recheckTmux, SCAN_INTERVAL);
200
+
201
+ // Log tmux status at startup
202
+ if (isTmuxAvailable()) {
203
+ const panes = await listPanes();
204
+ console.log(` [tmux] Detected ${panes.length} pane(s)`);
205
+ }
206
+
207
+ app.listen(PORT, '0.0.0.0', () => {
208
+ console.log('');
209
+ console.log(' ╔══════════════════════════════════════════╗');
210
+ console.log(' ║ RuntimeInspector v0.1.0 ║');
211
+ console.log(' ╠══════════════════════════════════════════╣');
212
+ console.log(` ║ Dashboard: http://localhost:${PORT} ║`);
213
+ console.log(` ║ API: http://localhost:${PORT}/api ║`);
214
+ console.log(' ║ Scanning: every 2 min ║');
215
+ console.log(' ╚══════════════════════════════════════════╝');
216
+ console.log('');
217
+
218
+ if (shouldOpen) {
219
+ import('open').then(({ default: open }) => {
220
+ open(`http://localhost:${PORT}`);
221
+ }).catch(() => {});
222
+ }
223
+ });
224
+
225
+ const shutdown = () => {
226
+ console.log('\n[agent] Shutting down...');
227
+ clearInterval(scanTimer);
228
+ process.exit(0);
229
+ };
230
+
231
+ process.on('SIGINT', shutdown);
232
+ process.on('SIGTERM', shutdown);
233
+ }
@@ -0,0 +1,46 @@
1
+ // Progress inference — uses repo file activity to refine session runtime state.
2
+ // Runs after mergeRepoActivity() and before addStateInference().
3
+ // Does NOT override wrapped session states.
4
+
5
+ /**
6
+ * Add progress inference to sessions based on repoActivity.
7
+ * Mutates in place.
8
+ */
9
+ export function addProgressInference(sessions) {
10
+ for (const session of sessions) {
11
+ // Skip sessions without repo activity data
12
+ if (!session.repoActivity) continue;
13
+
14
+ // Never override wrapped session states — the wrapper has better signal
15
+ if (session.isWrapped && session.runtimeState) continue;
16
+
17
+ const { filesChangedLast2m, lastFileWriteAt } = session.repoActivity;
18
+ const cpu = session.cpu || 0;
19
+ const elapsed = session.durationSeconds || 0;
20
+
21
+ if (filesChangedLast2m > 0) {
22
+ // Files are being written — session is making progress
23
+ if (!session.status.includes('file-progress')) {
24
+ session.status.push('file-progress');
25
+ }
26
+ session.runtimeState = 'progressing';
27
+ } else if (cpu > 5 && elapsed > 600) {
28
+ // No file writes but CPU active for 10+ minutes — possibly stuck
29
+ if (!session.status.includes('no-file-progress')) {
30
+ session.status.push('no-file-progress');
31
+ }
32
+ session.runtimeState = 'possibly-stuck';
33
+ } else if (cpu < 1 && elapsed > 300) {
34
+ // No file writes, low CPU, running 5+ minutes — idle
35
+ session.runtimeState = 'idle';
36
+ }
37
+ // Else: leave existing state untouched
38
+
39
+ // Set lastProgressAt from repo activity
40
+ if (lastFileWriteAt) {
41
+ session.lastProgressAt = lastFileWriteAt;
42
+ }
43
+ }
44
+
45
+ return sessions;
46
+ }
@@ -0,0 +1,253 @@
1
+ // Purpose engine — infers *what* a session is doing, not just what it is.
2
+ // Uses deterministic heuristics on command args, cwd, and child process signatures.
3
+
4
+ // --- Activity classification ---
5
+ // Each rule returns { activity, purpose } or null.
6
+ // First match wins. Order matters: most specific first.
7
+
8
+ const ACTIVITY_RULES = [
9
+
10
+ // Scaffolding tools (detected in any child process args)
11
+ {
12
+ // shadcn CLI generating UI components
13
+ test: (s) => anyProcessMatches(s, /shadcn/),
14
+ activity: 'scaffolding',
15
+ purpose: (s) => `Generating UI components with shadcn${inRepo(s)}`,
16
+ },
17
+ {
18
+ // create-react-app, create-next-app, etc.
19
+ test: (s) => anyProcessMatches(s, /create-(react|next|vite|svelte|vue)-app/),
20
+ activity: 'scaffolding',
21
+ purpose: (s) => `Scaffolding a new project${inRepo(s)}`,
22
+ },
23
+ {
24
+ // npm init / npx create
25
+ test: (s) => anyProcessMatches(s, /npm init|npx create/),
26
+ activity: 'scaffolding',
27
+ purpose: (s) => `Initializing a new project${inRepo(s)}`,
28
+ },
29
+ {
30
+ // prisma generate / migrate
31
+ test: (s) => anyProcessMatches(s, /prisma\s+(generate|migrate|db\s+push)/),
32
+ activity: 'scaffolding',
33
+ purpose: (s) => `Running Prisma database operations${inRepo(s)}`,
34
+ },
35
+
36
+ // Dev servers (the session itself is a server)
37
+ {
38
+ test: (s) => s.detectedAs === 'nextjs',
39
+ activity: 'serving',
40
+ purpose: (s) => `Serving a Next.js app with hot reload${inRepo(s)}`,
41
+ },
42
+ {
43
+ test: (s) => s.detectedAs === 'vite',
44
+ activity: 'serving',
45
+ purpose: (s) => `Serving a Vite project with HMR${inRepo(s)}`,
46
+ },
47
+ {
48
+ test: (s) => s.detectedAs === 'react-scripts',
49
+ activity: 'serving',
50
+ purpose: (s) => `Serving a React app in development${inRepo(s)}`,
51
+ },
52
+ {
53
+ test: (s) => s.detectedAs === 'webpack-dev',
54
+ activity: 'serving',
55
+ purpose: (s) => `Serving via Webpack dev server${inRepo(s)}`,
56
+ },
57
+ {
58
+ test: (s) => s.detectedAs === 'fastapi',
59
+ activity: 'serving',
60
+ purpose: (s) => `Running a FastAPI backend${inRepo(s)}`,
61
+ },
62
+ {
63
+ test: (s) => s.detectedAs === 'flask',
64
+ activity: 'serving',
65
+ purpose: (s) => `Running a Flask backend${inRepo(s)}`,
66
+ },
67
+ {
68
+ test: (s) => s.detectedAs === 'django',
69
+ activity: 'serving',
70
+ purpose: (s) => `Running a Django development server${inRepo(s)}`,
71
+ },
72
+ {
73
+ test: (s) => s.detectedAs === 'docker-compose',
74
+ activity: 'serving',
75
+ purpose: (s) => `Running a Docker Compose stack${inRepo(s)}`,
76
+ },
77
+
78
+ // File watchers / compilers
79
+ {
80
+ // tsc --watch
81
+ test: (s) => s.detectedAs === 'tsc' || anyProcessMatches(s, /tsc.*--watch/),
82
+ activity: 'watching',
83
+ purpose: (s) => `Watching TypeScript files and recompiling on change${inRepo(s)}`,
84
+ },
85
+ {
86
+ // nodemon / tsx watch
87
+ test: (s) => anyProcessMatches(s, /nodemon|tsx\s+watch/),
88
+ activity: 'watching',
89
+ purpose: (s) => `Watching files and restarting on change${inRepo(s)}`,
90
+ },
91
+ {
92
+ // jest --watch / vitest
93
+ test: (s) => anyProcessMatches(s, /jest.*--watch|vitest/),
94
+ activity: 'watching',
95
+ purpose: (s) => `Running tests in watch mode${inRepo(s)}`,
96
+ },
97
+ {
98
+ // tailwind --watch
99
+ test: (s) => anyProcessMatches(s, /tailwind.*--watch/),
100
+ activity: 'watching',
101
+ purpose: (s) => `Compiling Tailwind CSS on file changes${inRepo(s)}`,
102
+ },
103
+
104
+ // AI agents — infer what they're doing from child processes
105
+ {
106
+ // AI agent with dev server child → coding + serving
107
+ test: (s) => isAIAgent(s) && anyProcessMatches(s, /next\s+dev|vite|npm\s+run\s+dev/),
108
+ activity: 'coding',
109
+ purpose: (s) => `AI coding agent running a dev server${inRepo(s)}`,
110
+ },
111
+ {
112
+ // AI agent with test runner
113
+ test: (s) => isAIAgent(s) && anyProcessMatches(s, /jest|vitest|pytest|mocha/),
114
+ activity: 'coding',
115
+ purpose: (s) => `AI coding agent running tests${inRepo(s)}`,
116
+ },
117
+ {
118
+ // AI agent with build tool
119
+ test: (s) => isAIAgent(s) && anyProcessMatches(s, /npm\s+run\s+build|tsc(?!\s+--watch)/),
120
+ activity: 'coding',
121
+ purpose: (s) => `AI coding agent building the project${inRepo(s)}`,
122
+ },
123
+ {
124
+ // Generic AI agent with a repo → coding
125
+ test: (s) => isAIAgent(s) && s.repo,
126
+ activity: 'coding',
127
+ purpose: (s) => `AI coding agent working on '${s.repo}'`,
128
+ },
129
+ {
130
+ // Generic AI agent without repo
131
+ test: (s) => isAIAgent(s),
132
+ activity: 'coding',
133
+ purpose: (s) => `AI coding agent active`,
134
+ },
135
+
136
+ // npm scripts
137
+ {
138
+ // npm run build
139
+ test: (s) => anyProcessMatches(s, /npm\s+run\s+build/),
140
+ activity: 'scaffolding',
141
+ purpose: (s) => `Building project${inRepo(s)}`,
142
+ },
143
+ {
144
+ // npm run dev (generic, not matched by specific framework)
145
+ test: (s) => anyProcessMatches(s, /npm\s+run\s+dev/),
146
+ activity: 'serving',
147
+ purpose: (s) => `Running dev server via npm${inRepo(s)}`,
148
+ },
149
+ {
150
+ // npm test
151
+ test: (s) => anyProcessMatches(s, /npm\s+(run\s+)?test/),
152
+ activity: 'watching',
153
+ purpose: (s) => `Running tests${inRepo(s)}`,
154
+ },
155
+
156
+ // Generic node/python with area inference
157
+ {
158
+ // Node script with identifiable file
159
+ test: (s) => s.detectedAs === 'node' && getScriptArea(s),
160
+ activity: 'coding',
161
+ purpose: (s) => {
162
+ const area = getScriptArea(s);
163
+ return `Running a ${area} Node.js script${inRepo(s)}`;
164
+ },
165
+ },
166
+ {
167
+ test: (s) => s.detectedAs === 'python' && getScriptArea(s),
168
+ activity: 'coding',
169
+ purpose: (s) => {
170
+ const area = getScriptArea(s);
171
+ return `Running a ${area} Python script${inRepo(s)}`;
172
+ },
173
+ },
174
+ ];
175
+
176
+
177
+ // --- Helpers ---
178
+
179
+ function isAIAgent(session) {
180
+ return session.type === 'ai-agent';
181
+ }
182
+
183
+ /** Check if any process in the session matches a regex against its cmd args. */
184
+ function anyProcessMatches(session, regex) {
185
+ return session.processes.some(p => regex.test(p.cmd));
186
+ }
187
+
188
+ /** Return " in 'repoName'" or empty string. */
189
+ function inRepo(session) {
190
+ return session.repo ? ` in '${session.repo}'` : '';
191
+ }
192
+
193
+ /**
194
+ * Infer the area of a script from file paths in args.
195
+ * Looks for common directory/file patterns to classify as frontend, backend, etc.
196
+ */
197
+ function getScriptArea(session) {
198
+ const allArgs = session.processes.map(p => p.cmd).join(' ');
199
+
200
+ // Frontend signals
201
+ if (/src\/(components?|pages?|app|views?|ui)\b/.test(allArgs)) return 'frontend';
202
+ if (/\.(tsx|jsx|vue|svelte)\b/.test(allArgs)) return 'frontend';
203
+ if (/webpack|babel|postcss|tailwind/.test(allArgs)) return 'frontend';
204
+
205
+ // Backend signals
206
+ if (/src\/(server|api|routes?|controllers?|middleware)\b/.test(allArgs)) return 'backend';
207
+ if (/server\.(js|ts|py)\b/.test(allArgs)) return 'backend';
208
+ if (/manage\.py|wsgi|asgi/.test(allArgs)) return 'backend';
209
+ if (/express|fastify|hono|koa/.test(allArgs)) return 'backend';
210
+
211
+ // Config / infra signals
212
+ if (/\.(config|rc|env|json|ya?ml)\b/.test(allArgs)) return 'config';
213
+ if (/docker|terraform|ansible|k8s|kubernetes/.test(allArgs)) return 'infra';
214
+
215
+ // Test signals
216
+ if (/test|spec|__tests__|\.test\.|\.spec\./.test(allArgs)) return 'test';
217
+
218
+ return null;
219
+ }
220
+
221
+
222
+ /**
223
+ * Add activity and purpose to each session. Mutates in place.
224
+ */
225
+ export function addPurpose(sessions) {
226
+ for (const session of sessions) {
227
+ let matched = false;
228
+
229
+ for (const rule of ACTIVITY_RULES) {
230
+ if (rule.test(session)) {
231
+ session.activity = rule.activity;
232
+ session.purpose = typeof rule.purpose === 'function'
233
+ ? rule.purpose(session)
234
+ : rule.purpose;
235
+ matched = true;
236
+ break;
237
+ }
238
+ }
239
+
240
+ // Fallback: idle if low CPU + long running, otherwise unknown
241
+ if (!matched) {
242
+ if (session.status.includes('idle')) {
243
+ session.activity = 'idle';
244
+ session.purpose = `Idle process${inRepo(session)}`;
245
+ } else {
246
+ session.activity = 'coding';
247
+ session.purpose = `Active process${inRepo(session)}`;
248
+ }
249
+ }
250
+ }
251
+
252
+ return sessions;
253
+ }
@@ -0,0 +1,142 @@
1
+ // Repo activity scanner — detects file write activity per session repo.
2
+ // Uses find -mmin -2 for both git and non-git repos (consistent 2-min window).
3
+ // Cached per cwd with 30s TTL. Never throws.
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { promisify } from 'node:util';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const CACHE_TTL = 30_000; // 30 seconds
13
+ const EXEC_TIMEOUT = 1000; // 1 second per command
14
+
15
+ // Map<cwd, { result, timestamp }>
16
+ const cache = new Map();
17
+
18
+ // Directories to exclude from find scans
19
+ const FIND_EXCLUDES = [
20
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
21
+ '__pycache__', '.cache', 'coverage', '.turbo', '.output',
22
+ ];
23
+
24
+ /**
25
+ * Build prune args for find (shared between probes).
26
+ */
27
+ function buildPruneArgs() {
28
+ const pruneArgs = [];
29
+ for (const dir of FIND_EXCLUDES) {
30
+ pruneArgs.push('-name', dir, '-prune', '-o');
31
+ }
32
+ return pruneArgs;
33
+ }
34
+
35
+ /**
36
+ * Best-effort newest mtime from a list of files using stat.
37
+ */
38
+ async function newestMtime(files) {
39
+ if (files.length === 0) return null;
40
+ try {
41
+ const filesToStat = files.slice(0, 50);
42
+ const statArgs = process.platform === 'darwin'
43
+ ? ['-f', '%m', ...filesToStat]
44
+ : ['-c', '%Y', ...filesToStat];
45
+
46
+ const { stdout } = await execFileAsync('stat', statArgs, {
47
+ encoding: 'utf-8',
48
+ timeout: EXEC_TIMEOUT,
49
+ });
50
+
51
+ const mtimes = stdout.trim().split('\n')
52
+ .map(s => parseInt(s.trim(), 10) * 1000)
53
+ .filter(t => !isNaN(t));
54
+ return mtimes.length > 0 ? Math.max(...mtimes) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find files modified in the last N minutes under cwd.
62
+ * Returns list of file paths (capped at 200).
63
+ */
64
+ async function findRecentFiles(cwd, minutes) {
65
+ try {
66
+ const pruneArgs = buildPruneArgs();
67
+ const args = [
68
+ cwd, '-maxdepth', '4',
69
+ ...pruneArgs,
70
+ '-type', 'f', '-mmin', `-${minutes}`, '-print',
71
+ ];
72
+
73
+ const { stdout } = await execFileAsync('find', args, {
74
+ encoding: 'utf-8',
75
+ timeout: EXEC_TIMEOUT,
76
+ maxBuffer: 64 * 1024,
77
+ });
78
+
79
+ const trimmed = stdout.trim();
80
+ if (!trimmed) return [];
81
+ return trimmed.split('\n').slice(0, 200);
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Probe a single repo for file activity.
89
+ * Returns a repoActivity object or null.
90
+ */
91
+ async function probeRepo(cwd) {
92
+ const now = Date.now();
93
+
94
+ // Check cache
95
+ const cached = cache.get(cwd);
96
+ if (cached && now - cached.timestamp < CACHE_TTL) {
97
+ return cached.result;
98
+ }
99
+
100
+ try {
101
+ // Count files modified in last 2 minutes
102
+ const recentFiles = await findRecentFiles(cwd, 2);
103
+ const filesChanged = recentFiles.length;
104
+
105
+ // Get most recent mtime from files modified in last 30 minutes
106
+ let lastFileWriteAt = null;
107
+ const recentFiles30m = await findRecentFiles(cwd, 30);
108
+ if (recentFiles30m.length > 0) {
109
+ lastFileWriteAt = await newestMtime(recentFiles30m);
110
+ }
111
+
112
+ const result = {
113
+ lastFileWriteAt,
114
+ filesChangedLast2m: filesChanged,
115
+ repoWriteRate: filesChanged / 2, // writes per minute (window is 2min)
116
+ lastCheckedAt: now,
117
+ };
118
+
119
+ cache.set(cwd, { result, timestamp: now });
120
+ return result;
121
+ } catch {
122
+ cache.set(cwd, { result: null, timestamp: now });
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Merge repo activity data into sessions. Mutates in place.
129
+ * Skips sessions without a cwd.
130
+ */
131
+ export async function mergeRepoActivity(sessions) {
132
+ const promises = sessions.map(async (session) => {
133
+ if (!session.cwd || session.cwd === '/') return;
134
+ const activity = await probeRepo(session.cwd);
135
+ if (activity) {
136
+ session.repoActivity = activity;
137
+ }
138
+ });
139
+
140
+ await Promise.all(promises);
141
+ return sessions;
142
+ }