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.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/bin/cli.js +129 -0
- package/package.json +49 -0
- package/src/agent/actions.js +157 -0
- package/src/agent/attentionInfer.js +149 -0
- package/src/agent/dashboard.js +1178 -0
- package/src/agent/detector.js +235 -0
- package/src/agent/explainer.js +137 -0
- package/src/agent/grouper.js +161 -0
- package/src/agent/index.js +233 -0
- package/src/agent/progressInfer.js +46 -0
- package/src/agent/purposer.js +253 -0
- package/src/agent/repoActivity.js +142 -0
- package/src/agent/scanner.js +117 -0
- package/src/agent/shellEvents.js +115 -0
- package/src/agent/shellMerge.js +103 -0
- package/src/agent/stateInfer.js +72 -0
- package/src/agent/tmux.js +210 -0
- package/src/agent/tmuxMerge.js +96 -0
- package/src/shell/hooks.js +181 -0
- package/src/shell/setup.js +85 -0
- package/src/wrapper/buffer.js +34 -0
- package/src/wrapper/runner.js +149 -0
|
@@ -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
|
+
}
|