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,235 @@
1
+ // Framework and AI agent detection via heuristics
2
+
3
+ const AI_AGENT_PATTERNS = [
4
+ {
5
+ id: 'claude-code',
6
+ name: 'Claude Code',
7
+ icon: '๐Ÿค–',
8
+ type: 'ai-agent',
9
+ match: (p) => /claude/i.test(p.comm) || /claude/i.test(p.args),
10
+ },
11
+ {
12
+ id: 'codex',
13
+ name: 'Codex CLI',
14
+ icon: '๐Ÿง ',
15
+ type: 'ai-agent',
16
+ match: (p) => /codex/i.test(p.comm) || /codex/i.test(p.args),
17
+ },
18
+ {
19
+ id: 'cursor',
20
+ name: 'Cursor',
21
+ icon: '๐Ÿ“',
22
+ type: 'ai-agent',
23
+ match: (p) => /cursor/i.test(p.comm) || /cursor.*server/i.test(p.args),
24
+ },
25
+ {
26
+ id: 'copilot',
27
+ name: 'GitHub Copilot',
28
+ icon: '๐Ÿ™',
29
+ type: 'ai-agent',
30
+ match: (p) => /copilot/i.test(p.args),
31
+ },
32
+ {
33
+ id: 'aider',
34
+ name: 'Aider',
35
+ icon: '๐Ÿ› ๏ธ',
36
+ type: 'ai-agent',
37
+ match: (p) => /aider/i.test(p.comm) || /aider/i.test(p.args),
38
+ },
39
+ {
40
+ id: 'continue',
41
+ name: 'Continue',
42
+ icon: '๐Ÿ”„',
43
+ type: 'ai-agent',
44
+ match: (p) => /continue.*server/i.test(p.args),
45
+ },
46
+ ];
47
+
48
+ const FRAMEWORK_PATTERNS = [
49
+ {
50
+ id: 'nextjs',
51
+ name: 'Next.js',
52
+ icon: 'โ–ฒ',
53
+ type: 'dev-server',
54
+ match: (p) => /next\s+(dev|start|build)/.test(p.args) || /next-server/.test(p.args),
55
+ },
56
+ {
57
+ id: 'vite',
58
+ name: 'Vite',
59
+ icon: 'โšก',
60
+ type: 'dev-server',
61
+ // Guard: exclude when vite appears only inside a node_modules path in args
62
+ match: (p) => /vite/.test(p.args) && !/node_modules\/.*vite/.test(p.args),
63
+ },
64
+ {
65
+ id: 'react-scripts',
66
+ name: 'React Dev Server',
67
+ icon: 'โš›๏ธ',
68
+ type: 'dev-server',
69
+ match: (p) => /react-scripts\s+start/.test(p.args),
70
+ },
71
+ {
72
+ id: 'webpack-dev',
73
+ name: 'Webpack Dev Server',
74
+ icon: '๐Ÿ“ฆ',
75
+ type: 'dev-server',
76
+ match: (p) => /webpack.*dev.*server/.test(p.args) || /webpack.*serve/.test(p.args),
77
+ },
78
+ {
79
+ id: 'fastapi',
80
+ name: 'FastAPI',
81
+ icon: '๐Ÿš€',
82
+ type: 'dev-server',
83
+ match: (p) => /uvicorn/.test(p.args) || /fastapi/.test(p.args),
84
+ },
85
+ {
86
+ id: 'flask',
87
+ name: 'Flask',
88
+ icon: '๐Ÿงช',
89
+ type: 'dev-server',
90
+ match: (p) => /flask\s+run/.test(p.args) || /FLASK_APP/.test(p.args),
91
+ },
92
+ {
93
+ id: 'django',
94
+ name: 'Django',
95
+ icon: '๐ŸŽฏ',
96
+ type: 'dev-server',
97
+ match: (p) => /manage\.py\s+runserver/.test(p.args),
98
+ },
99
+ {
100
+ id: 'docker-compose',
101
+ name: 'Docker Compose',
102
+ icon: '๐Ÿณ',
103
+ type: 'dev-server',
104
+ match: (p) => /docker.compose/.test(p.args) || /docker-compose/.test(p.comm),
105
+ },
106
+ {
107
+ id: 'docker',
108
+ name: 'Docker',
109
+ icon: '๐Ÿณ',
110
+ type: 'dev-server',
111
+ // Only match docker CLI commands, not the daemon
112
+ match: (p) => p.comm === 'docker' && p.comm !== 'dockerd',
113
+ },
114
+ {
115
+ id: 'dockerd',
116
+ name: 'Docker Daemon',
117
+ icon: '๐Ÿณ',
118
+ type: 'script', // daemon is infrastructure, not a dev-server
119
+ match: (p) => p.comm === 'dockerd',
120
+ },
121
+ {
122
+ id: 'node',
123
+ name: 'Node.js',
124
+ icon: '๐ŸŸข',
125
+ type: 'script',
126
+ match: (p) => p.comm === 'node' && !/next|vite|webpack|react-scripts/.test(p.args),
127
+ },
128
+ {
129
+ id: 'python',
130
+ name: 'Python',
131
+ icon: '๐Ÿ',
132
+ type: 'script',
133
+ match: (p) => /^python/.test(p.comm),
134
+ },
135
+ {
136
+ id: 'npm-run',
137
+ name: 'npm script',
138
+ icon: '๐Ÿ“ฆ',
139
+ type: 'script',
140
+ // Exclude npm run build/test which have their own categories
141
+ match: (p) => /npm\s+run\b/.test(p.args) && !/npm\s+run\s+(build|test)\b/.test(p.args),
142
+ },
143
+ {
144
+ id: 'pnpm-run',
145
+ name: 'pnpm script',
146
+ icon: '๐Ÿ“ฆ',
147
+ type: 'script',
148
+ match: (p) => /pnpm\s+(run|dev|start)\b/.test(p.args),
149
+ },
150
+ {
151
+ id: 'yarn-run',
152
+ name: 'Yarn script',
153
+ icon: '๐Ÿ“ฆ',
154
+ type: 'script',
155
+ match: (p) => /yarn\s+(run|dev|start)\b/.test(p.args) || p.comm === 'yarn',
156
+ },
157
+ {
158
+ id: 'bun-run',
159
+ name: 'Bun script',
160
+ icon: '๐Ÿž',
161
+ type: 'script',
162
+ match: (p) => p.comm === 'bun' || /bun\s+(run|dev|start)\b/.test(p.args),
163
+ },
164
+ {
165
+ id: 'npm-build',
166
+ name: 'npm build',
167
+ icon: '๐Ÿ”จ',
168
+ type: 'script',
169
+ match: (p) => /npm\s+run\s+build\b/.test(p.args),
170
+ },
171
+ {
172
+ id: 'npm-test',
173
+ name: 'npm test',
174
+ icon: '๐Ÿงช',
175
+ type: 'script',
176
+ match: (p) => /npm\s+(test|run\s+test)\b/.test(p.args),
177
+ },
178
+ {
179
+ id: 'tsc-watch',
180
+ name: 'TypeScript Compiler (watch)',
181
+ icon: '๐Ÿ”ท',
182
+ type: 'script',
183
+ match: (p) => /tsc/.test(p.args) && /--watch\b/.test(p.args),
184
+ },
185
+ {
186
+ id: 'tsc',
187
+ name: 'TypeScript Compiler',
188
+ icon: '๐Ÿ”ท',
189
+ type: 'script',
190
+ match: (p) => /tsc/.test(p.args) && !/--watch\b/.test(p.args),
191
+ },
192
+ {
193
+ id: 'cargo',
194
+ name: 'Cargo (Rust)',
195
+ icon: '๐Ÿฆ€',
196
+ type: 'script',
197
+ match: (p) => p.comm === 'cargo' || /cargo\s+(build|run|test|watch)/.test(p.args),
198
+ },
199
+ {
200
+ id: 'go-run',
201
+ name: 'Go',
202
+ icon: '๐Ÿน',
203
+ type: 'script',
204
+ match: (p) => /go\s+(run|build|test)/.test(p.args),
205
+ },
206
+ ];
207
+
208
+ const ALL_PATTERNS = [...AI_AGENT_PATTERNS, ...FRAMEWORK_PATTERNS];
209
+
210
+ /**
211
+ * Detect what a process is.
212
+ * Returns { id, name, icon, type } or null.
213
+ */
214
+ export function detectProcess(process) {
215
+ for (const pattern of ALL_PATTERNS) {
216
+ if (pattern.match(process)) {
217
+ return { id: pattern.id, name: pattern.name, icon: pattern.icon, type: pattern.type };
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Detect all processes in a list, returning detection info keyed by pid.
225
+ */
226
+ export function detectAll(processes) {
227
+ const results = new Map();
228
+ for (const p of processes) {
229
+ const detection = detectProcess(p);
230
+ if (detection) {
231
+ results.set(p.pid, detection);
232
+ }
233
+ }
234
+ return results;
235
+ }
@@ -0,0 +1,137 @@
1
+ // Explanation engine โ€” generates human-readable descriptions for sessions.
2
+ // Tone: confident and direct. No "appears to be" or "looks like".
3
+
4
+ function timeSince(isoString) {
5
+ const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
6
+ if (seconds < 5) return 'just now';
7
+ if (seconds < 60) return `${seconds}s`;
8
+ const minutes = Math.floor(seconds / 60);
9
+ if (minutes < 60) return `${minutes}m`;
10
+ const hours = Math.floor(minutes / 60);
11
+ return `${hours}h ${minutes % 60}m`;
12
+ }
13
+
14
+ const TEMPLATES = {
15
+ 'claude-code': (s) => {
16
+ const childCount = s.processes.length - 1;
17
+ const children = childCount > 0 ? ` with ${childCount} child process${childCount > 1 ? 'es' : ''}` : '';
18
+ const repo = s.repo ? ` in '${s.repo}'` : '';
19
+ return `Claude Code session${repo}${children}, running for ${s.duration}.`;
20
+ },
21
+
22
+ 'codex': (s) => {
23
+ const repo = s.repo ? ` in '${s.repo}'` : '';
24
+ return `Codex CLI session${repo}, running for ${s.duration}.`;
25
+ },
26
+
27
+ 'cursor': (s) => {
28
+ const repo = s.repo ? ` editing '${s.repo}'` : '';
29
+ return `Cursor editor${repo}, active for ${s.duration}.`;
30
+ },
31
+
32
+ 'copilot': (s) => {
33
+ return `GitHub Copilot agent, running for ${s.duration}.`;
34
+ },
35
+
36
+ 'aider': (s) => {
37
+ const repo = s.repo ? ` in '${s.repo}'` : '';
38
+ return `Aider AI coding session${repo}, running for ${s.duration}.`;
39
+ },
40
+
41
+ 'nextjs': (s) => {
42
+ const repo = s.repo ? ` for '${s.repo}'` : '';
43
+ return `Next.js dev server${repo}, watching files and serving on localhost. Running for ${s.duration}.`;
44
+ },
45
+
46
+ 'vite': (s) => {
47
+ const repo = s.repo ? ` for '${s.repo}'` : '';
48
+ return `Vite dev server${repo} with HMR active. Running for ${s.duration}.`;
49
+ },
50
+
51
+ 'react-scripts': (s) => {
52
+ const repo = s.repo ? ` for '${s.repo}'` : '';
53
+ return `React dev server${repo} with live reload. Running for ${s.duration}.`;
54
+ },
55
+
56
+ 'webpack-dev': (s) => {
57
+ const repo = s.repo ? ` for '${s.repo}'` : '';
58
+ return `Webpack dev server${repo} watching files. Running for ${s.duration}.`;
59
+ },
60
+
61
+ 'fastapi': (s) => {
62
+ const repo = s.repo ? ` serving '${s.repo}'` : '';
63
+ return `FastAPI server${repo} via Uvicorn. Active for ${s.duration}.`;
64
+ },
65
+
66
+ 'flask': (s) => {
67
+ const repo = s.repo ? ` for '${s.repo}'` : '';
68
+ return `Flask dev server${repo}. Running for ${s.duration}.`;
69
+ },
70
+
71
+ 'django': (s) => {
72
+ const repo = s.repo ? ` for '${s.repo}'` : '';
73
+ return `Django dev server${repo}. Active for ${s.duration}.`;
74
+ },
75
+
76
+ 'docker-compose': (s) => {
77
+ const count = s.processes.length;
78
+ return `Docker Compose stack, ${count} process${count > 1 ? 'es' : ''}. Running for ${s.duration}.`;
79
+ },
80
+
81
+ 'docker': (s) => {
82
+ return `Docker daemon, active for ${s.duration}.`;
83
+ },
84
+
85
+ 'node': (s) => {
86
+ const repo = s.repo ? ` in '${s.repo}'` : '';
87
+ return `Node.js process${repo}. Running for ${s.duration}.`;
88
+ },
89
+
90
+ 'python': (s) => {
91
+ const repo = s.repo ? ` in '${s.repo}'` : '';
92
+ return `Python process${repo}. Running for ${s.duration}.`;
93
+ },
94
+
95
+ 'npm-run': (s) => {
96
+ const repo = s.repo ? ` in '${s.repo}'` : '';
97
+ return `npm script${repo}. Running for ${s.duration}.`;
98
+ },
99
+
100
+ 'tsc': (s) => {
101
+ const repo = s.repo ? ` for '${s.repo}'` : '';
102
+ return `TypeScript compiler in watch mode${repo}. Running for ${s.duration}.`;
103
+ },
104
+ };
105
+
106
+ /**
107
+ * Add explanations to sessions. Mutates in place.
108
+ */
109
+ export function addExplanations(sessions) {
110
+ for (const session of sessions) {
111
+ const template = TEMPLATES[session.detectedAs];
112
+ if (template) {
113
+ session.explanation = template(session);
114
+ } else {
115
+ const repo = session.repo ? ` in '${session.repo}'` : '';
116
+ session.explanation = `Process group${repo}, ${session.processes.length} process${session.processes.length > 1 ? 'es' : ''}. Running for ${session.duration}.`;
117
+ }
118
+
119
+ // Prepend wrapper context if wrapped
120
+ if (session.isWrapped && session.runtimeState) {
121
+ const stateLabel = session.runtimeState;
122
+ const lastActivity = session.lastActivityAt
123
+ ? ` Last output ${timeSince(session.lastActivityAt)} ago.`
124
+ : '';
125
+ session.explanation = `Running via RuntimeInspector wrapper. Currently ${stateLabel}.${lastActivity} ` + session.explanation;
126
+ }
127
+
128
+ // Append status context
129
+ if (session.status.includes('high-cpu')) {
130
+ session.explanation += ' High CPU usage.';
131
+ }
132
+ if (session.status.includes('idle')) {
133
+ session.explanation += ' Currently idle.';
134
+ }
135
+ }
136
+ return sessions;
137
+ }
@@ -0,0 +1,161 @@
1
+ // Session grouper โ€” groups processes into logical sessions
2
+ import { detectAll } from './detector.js';
3
+ import { getCwd } from './scanner.js';
4
+
5
+ /**
6
+ * Build a pid->process map and a pid->children map (process tree).
7
+ */
8
+ function buildTree(processes) {
9
+ const byPid = new Map();
10
+ const children = new Map();
11
+
12
+ for (const p of processes) {
13
+ byPid.set(p.pid, p);
14
+ if (!children.has(p.ppid)) children.set(p.ppid, []);
15
+ children.get(p.ppid).push(p.pid);
16
+ }
17
+
18
+ return { byPid, children };
19
+ }
20
+
21
+ /**
22
+ * Get all descendant pids of a given pid.
23
+ */
24
+ function getDescendants(pid, children) {
25
+ const result = [];
26
+ const stack = [pid];
27
+ while (stack.length > 0) {
28
+ const current = stack.pop();
29
+ const kids = children.get(current) || [];
30
+ for (const kid of kids) {
31
+ result.push(kid);
32
+ stack.push(kid);
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+
38
+ /**
39
+ * Extract repo name from a cwd path.
40
+ */
41
+ function extractRepo(cwd) {
42
+ if (!cwd) return null;
43
+ // Try to find a meaningful directory name
44
+ const parts = cwd.split('/').filter(Boolean);
45
+ // Skip common prefixes
46
+ const skip = new Set(['Users', 'home', 'var', 'tmp', 'opt', 'usr', 'private']);
47
+ for (let i = parts.length - 1; i >= 0; i--) {
48
+ if (!skip.has(parts[i]) && !parts[i].startsWith('.')) {
49
+ return parts[i];
50
+ }
51
+ }
52
+ return parts[parts.length - 1] || null;
53
+ }
54
+
55
+ /**
56
+ * Group processes into sessions.
57
+ */
58
+ export async function groupIntoSessions(processes) {
59
+ const { byPid, children } = buildTree(processes);
60
+ const detections = detectAll(processes);
61
+ const assigned = new Set();
62
+ const sessions = [];
63
+
64
+ // Ignore system/low-level processes
65
+ const IGNORE_COMMS = new Set([
66
+ 'kernel_task', 'launchd', 'syslogd', 'mds', 'mds_stores',
67
+ 'WindowServer', 'loginwindow', 'SystemUIServer', 'Dock', 'Finder',
68
+ 'coreaudiod', 'bluetoothd', 'distnoted', 'cfprefsd',
69
+ ]);
70
+
71
+ // Pass 1: Build sessions around detected root processes
72
+ // Sort by detection priority: ai-agents first, then dev-servers, then scripts
73
+ const typePriority = { 'ai-agent': 0, 'dev-server': 1, 'script': 2 };
74
+ const detectedPids = [...detections.entries()]
75
+ .sort((a, b) => (typePriority[a[1].type] ?? 3) - (typePriority[b[1].type] ?? 3));
76
+
77
+ for (const [pid, detection] of detectedPids) {
78
+ if (assigned.has(pid)) continue;
79
+
80
+ const rootProcess = byPid.get(pid);
81
+ if (!rootProcess || IGNORE_COMMS.has(rootProcess.comm)) continue;
82
+
83
+ // Gather this process + all descendants
84
+ const descendantPids = getDescendants(pid, children);
85
+ const sessionPids = [pid, ...descendantPids].filter(p => !assigned.has(p));
86
+
87
+ if (sessionPids.length === 0) continue;
88
+
89
+ // Mark all as assigned
90
+ for (const sp of sessionPids) assigned.add(sp);
91
+
92
+ const sessionProcesses = sessionPids
93
+ .map(p => byPid.get(p))
94
+ .filter(Boolean);
95
+
96
+ // Aggregate stats
97
+ const totalCpu = sessionProcesses.reduce((sum, p) => sum + p.cpu, 0);
98
+ const totalMem = sessionProcesses.reduce((sum, p) => sum + p.mem, 0);
99
+ const maxElapsed = sessionProcesses.reduce((max, p) => Math.max(max, p.elapsedSeconds), 0);
100
+
101
+ // Lookup cwd only for the root process of each session (lazy, avoids scanning all)
102
+ const cwd = await getCwd(rootProcess.pid);
103
+
104
+ const repo = extractRepo(cwd);
105
+
106
+ // Determine statuses
107
+ const statuses = [];
108
+ if (maxElapsed > 3600) statuses.push('long-running');
109
+ if (totalCpu > 80) statuses.push('high-cpu');
110
+ if (totalCpu < 0.1 && maxElapsed > 300) statuses.push('idle');
111
+
112
+ // Orphan detection: ppid=1 means parented by launchd/init, which is normal
113
+ // for system services. Only flag as orphan if this is a dev-tool type session
114
+ // (dev-server or script) that was likely started from a terminal but whose
115
+ // parent shell died. AI agents manage their own lifecycle so exclude them.
116
+ if (rootProcess.ppid === 1 && detection.type !== 'ai-agent') {
117
+ // Check if this looks like it was started from a terminal: dev-servers
118
+ // and scripts with a cwd inside a user home directory
119
+ const home = process.env.HOME || '/Users';
120
+ const isUserProcess = cwd && cwd.startsWith(home);
121
+ if (isUserProcess) {
122
+ statuses.push('orphan');
123
+ }
124
+ }
125
+
126
+ sessions.push({
127
+ id: `session-${pid}`,
128
+ type: detection.type,
129
+ icon: detection.icon,
130
+ title: detection.name + (repo ? ` โ€” ${repo}` : ''),
131
+ detectedAs: detection.id,
132
+ repo,
133
+ cwd,
134
+ duration: formatDuration(maxElapsed),
135
+ durationSeconds: maxElapsed,
136
+ cpu: Math.round(totalCpu * 10) / 10,
137
+ memory: Math.round(totalMem * 10) / 10,
138
+ status: statuses,
139
+ explanation: '', // filled in by explainer
140
+ processes: sessionProcesses.map(p => ({
141
+ pid: p.pid,
142
+ cmd: p.args,
143
+ cpu: p.cpu,
144
+ mem: p.mem,
145
+ })),
146
+ });
147
+ }
148
+
149
+ return sessions;
150
+ }
151
+
152
+ /**
153
+ * Format seconds into a human readable string.
154
+ */
155
+ function formatDuration(seconds) {
156
+ if (seconds < 60) return `${seconds}s`;
157
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
158
+ const h = Math.floor(seconds / 3600);
159
+ const m = Math.floor((seconds % 3600) / 60);
160
+ return `${h}h ${m}m`;
161
+ }