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,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
|
+
}
|