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,117 @@
1
+ // Process scanner — collects OS process information
2
+ import { execFile } from 'child_process';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ /**
8
+ * Scan all running processes and return structured data.
9
+ * Uses `ps` on macOS/Linux. Does NOT look up cwd here (too slow for all procs).
10
+ */
11
+ export async function scanProcesses() {
12
+ try {
13
+ const { stdout: raw } = await execFileAsync(
14
+ 'ps', ['-eo', 'pid,ppid,pcpu,pmem,etime,comm,args'],
15
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, timeout: 5000 }
16
+ );
17
+
18
+ const lines = raw.trim().split('\n').slice(1); // skip header
19
+ const processes = [];
20
+
21
+ for (const line of lines) {
22
+ const parsed = parsePsLine(line);
23
+ if (parsed) processes.push(parsed);
24
+ }
25
+
26
+ return processes;
27
+ } catch (err) {
28
+ console.error('[scanner] Failed to scan processes:', err.message);
29
+ return [];
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Parse a single line of ps output.
35
+ */
36
+ function parsePsLine(line) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) return null;
39
+
40
+ // Match: PID PPID %CPU %MEM ELAPSED COMM ARGS...
41
+ const match = trimmed.match(
42
+ /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(.*)$/
43
+ );
44
+ if (!match) return null;
45
+
46
+ const [, pid, ppid, cpu, mem, etime, comm, args] = match;
47
+
48
+ return {
49
+ pid: parseInt(pid, 10),
50
+ ppid: parseInt(ppid, 10),
51
+ cpu: parseFloat(cpu),
52
+ mem: parseFloat(mem),
53
+ elapsed: etime.trim(),
54
+ elapsedSeconds: parseElapsed(etime.trim()),
55
+ comm: comm.trim(),
56
+ args: args.trim(),
57
+ cwd: null, // populated lazily by grouper for interesting pids only
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Parse ps elapsed time format (e.g., "01:23:45", "12:34", "3-01:23:45") to seconds.
63
+ */
64
+ function parseElapsed(etime) {
65
+ const parts = etime.replace(/-/g, ':').split(':').map(Number);
66
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
67
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
68
+ if (parts.length === 4) return parts[0] * 86400 + parts[1] * 3600 + parts[2] * 60 + parts[3];
69
+ return 0;
70
+ }
71
+
72
+ /**
73
+ * Best-effort cwd lookup via lsof (macOS/Linux). Only call for specific pids.
74
+ */
75
+ const cwdCache = new Map();
76
+ const CWD_CACHE_TTL = 30000;
77
+
78
+ /**
79
+ * Evict cwdCache entries for PIDs no longer in the live process list.
80
+ */
81
+ export function evictStaleCwdEntries(livePids) {
82
+ for (const pid of cwdCache.keys()) {
83
+ if (!livePids.has(pid)) {
84
+ cwdCache.delete(pid);
85
+ }
86
+ }
87
+ }
88
+
89
+ export async function getCwd(pid) {
90
+ const now = Date.now();
91
+ const cached = cwdCache.get(pid);
92
+ if (cached && now - cached.time < CWD_CACHE_TTL) return cached.value;
93
+
94
+ const safePid = parseInt(pid, 10);
95
+ if (isNaN(safePid) || safePid <= 0) return null;
96
+
97
+ try {
98
+ const { stdout: result } = await execFileAsync(
99
+ 'lsof', ['-a', '-p', String(safePid), '-d', 'cwd', '-Fn'],
100
+ { encoding: 'utf-8', timeout: 1000 }
101
+ );
102
+ // Output format: p<pid>\nfcwd\nn<path>
103
+ const lines = result.trim().split('\n');
104
+ let cwd = null;
105
+ for (const line of lines) {
106
+ if (line.startsWith('n/')) {
107
+ cwd = line.slice(1);
108
+ break;
109
+ }
110
+ }
111
+ cwdCache.set(pid, { value: cwd, time: now });
112
+ return cwd;
113
+ } catch {
114
+ cwdCache.set(pid, { value: null, time: now });
115
+ return null;
116
+ }
117
+ }
@@ -0,0 +1,115 @@
1
+ // Shell event bus — stores command lifecycle events from shell hooks.
2
+ // In-memory ring buffer per TTY. No persistence.
3
+
4
+ const MAX_EVENTS_PER_TTY = 200;
5
+
6
+ // Map<tty, event[]>
7
+ const eventStore = new Map();
8
+
9
+ /**
10
+ * Validate and ingest a shell event.
11
+ * Returns { ok: true } or { error: string }.
12
+ */
13
+ export function ingestShellEvent(event) {
14
+ if (!event || !event.type) {
15
+ return { error: 'missing event type' };
16
+ }
17
+ if (!event.tty && !event.shellPid) {
18
+ return { error: 'missing tty or shellPid' };
19
+ }
20
+
21
+ // Normalize
22
+ const key = event.tty || `pid-${event.shellPid}`;
23
+ const stored = {
24
+ type: event.type,
25
+ shellType: event.shellType || 'unknown',
26
+ tty: event.tty || null,
27
+ shellPid: event.shellPid || null,
28
+ cwd: event.cwd || null,
29
+ command: event.command || null,
30
+ exitCode: event.exitCode ?? null,
31
+ timestamp: event.timestamp || Date.now(),
32
+ username: event.username || null,
33
+ host: event.host || null,
34
+ };
35
+
36
+ if (!eventStore.has(key)) {
37
+ eventStore.set(key, []);
38
+ }
39
+
40
+ const buffer = eventStore.get(key);
41
+ buffer.push(stored);
42
+
43
+ // Ring buffer trim
44
+ if (buffer.length > MAX_EVENTS_PER_TTY) {
45
+ buffer.splice(0, buffer.length - MAX_EVENTS_PER_TTY);
46
+ }
47
+
48
+ return { ok: true };
49
+ }
50
+
51
+ /**
52
+ * Get a snapshot of all shell events, grouped by TTY.
53
+ */
54
+ export function getShellEventsSnapshot() {
55
+ const result = {};
56
+ for (const [tty, events] of eventStore) {
57
+ result[tty] = events.slice(-50); // last 50 per tty for API response
58
+ }
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Get summary stats for shell events.
64
+ */
65
+ export function getShellEventStats() {
66
+ let totalEvents = 0;
67
+ let activeTtys = 0;
68
+ const now = Date.now();
69
+
70
+ for (const [, events] of eventStore) {
71
+ totalEvents += events.length;
72
+ // Active if any event in last 5 minutes
73
+ const latest = events[events.length - 1];
74
+ if (latest && now - latest.timestamp < 5 * 60 * 1000) {
75
+ activeTtys++;
76
+ }
77
+ }
78
+
79
+ return { totalEvents, totalTtys: eventStore.size, activeTtys };
80
+ }
81
+
82
+ /**
83
+ * Evict TTY entries with no events in the last 30 minutes.
84
+ */
85
+ const STALE_TTY_THRESHOLD = 30 * 60 * 1000;
86
+
87
+ export function evictStaleShellEvents() {
88
+ const now = Date.now();
89
+ for (const [tty, events] of eventStore) {
90
+ const latest = events[events.length - 1];
91
+ if (!latest || now - latest.timestamp > STALE_TTY_THRESHOLD) {
92
+ eventStore.delete(tty);
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Register shell event routes on the Express app.
99
+ */
100
+ export function registerShellEventRoutes(app) {
101
+ app.post('/api/shell/event', (req, res) => {
102
+ const result = ingestShellEvent(req.body);
103
+ if (result.error) {
104
+ return res.status(400).json(result);
105
+ }
106
+ res.json(result);
107
+ });
108
+
109
+ app.get('/api/shell/events', (req, res) => {
110
+ res.json({
111
+ events: getShellEventsSnapshot(),
112
+ stats: getShellEventStats(),
113
+ });
114
+ });
115
+ }
@@ -0,0 +1,103 @@
1
+ // Shell context merge — enriches sessions with shell event data.
2
+ // Matches shell events to sessions by cwd or TTY, attaches
3
+ // lastCommand, commandState, waitingForInput, etc.
4
+
5
+ import { getShellEventsSnapshot } from './shellEvents.js';
6
+
7
+ /**
8
+ * Merge shell context into sessions. Mutates in place.
9
+ * Called after mergeRepoActivity() and before mergeWrappedData().
10
+ */
11
+ export function mergeShellContext(sessions) {
12
+ const snapshot = getShellEventsSnapshot();
13
+ const ttyKeys = Object.keys(snapshot);
14
+
15
+ if (ttyKeys.length === 0) return sessions;
16
+
17
+ for (const session of sessions) {
18
+ const cwd = session.cwd;
19
+ if (!cwd) continue;
20
+
21
+ // Find the best-matching TTY for this session:
22
+ // 1. Match by cwd in recent events
23
+ // 2. Match by shellPid appearing in session process tree
24
+ let bestTty = null;
25
+ let bestEvents = null;
26
+ let bestScore = 0;
27
+
28
+ for (const ttyKey of ttyKeys) {
29
+ const events = snapshot[ttyKey];
30
+ if (!events || events.length === 0) continue;
31
+
32
+ let score = 0;
33
+
34
+ // Check if any event's cwd matches session cwd
35
+ for (let i = events.length - 1; i >= Math.max(0, events.length - 10); i--) {
36
+ if (events[i].cwd && events[i].cwd === cwd) {
37
+ score += 10;
38
+ break;
39
+ }
40
+ }
41
+
42
+ // Check if shellPid is in the session's process tree
43
+ const shellPid = events[0]?.shellPid;
44
+ if (shellPid && session.processes.some(p => p.pid === shellPid || p.ppid === shellPid)) {
45
+ score += 20;
46
+ }
47
+
48
+ if (score > bestScore) {
49
+ bestScore = score;
50
+ bestTty = ttyKey;
51
+ bestEvents = events;
52
+ }
53
+ }
54
+
55
+ if (!bestEvents || bestScore === 0) continue;
56
+
57
+ // Extract shell context from matched events
58
+ const lastEvent = bestEvents[bestEvents.length - 1];
59
+ const lastStartEvent = findLastOfType(bestEvents, 'command_start');
60
+ const lastEndEvent = findLastOfType(bestEvents, 'command_end');
61
+
62
+ session.tty = bestTty;
63
+ session.shellPid = lastEvent.shellPid;
64
+
65
+ if (lastStartEvent) {
66
+ session.lastCommand = lastStartEvent.command;
67
+ session.lastCommandAt = lastStartEvent.timestamp;
68
+
69
+ // Determine command state
70
+ if (lastEndEvent && lastEndEvent.timestamp > lastStartEvent.timestamp) {
71
+ // Command finished
72
+ session.commandState = 'completed';
73
+ session.lastCommandExitCode = lastEndEvent.exitCode;
74
+ session.waitingForInput = true;
75
+ session.lastInputAt = lastEndEvent.timestamp;
76
+ } else {
77
+ // Command is still running
78
+ session.commandState = 'running';
79
+ session.waitingForInput = false;
80
+ }
81
+ } else if (lastEndEvent) {
82
+ session.commandState = 'completed';
83
+ session.lastCommandExitCode = lastEndEvent.exitCode;
84
+ session.waitingForInput = true;
85
+ session.lastInputAt = lastEndEvent.timestamp;
86
+ }
87
+
88
+ // Count recent commands (last 5 minutes)
89
+ const fiveMinAgo = Date.now() - 5 * 60 * 1000;
90
+ session.recentCommandCount = bestEvents.filter(
91
+ e => e.type === 'command_start' && e.timestamp > fiveMinAgo
92
+ ).length;
93
+ }
94
+
95
+ return sessions;
96
+ }
97
+
98
+ function findLastOfType(events, type) {
99
+ for (let i = events.length - 1; i >= 0; i--) {
100
+ if (events[i].type === type) return events[i];
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,72 @@
1
+ // Runtime state inference for sessions (wrapped and non-wrapped)
2
+
3
+ /**
4
+ * Infer runtime state for a session.
5
+ * For wrapped sessions, the wrapper provides the state directly.
6
+ * For non-wrapped sessions, use CPU/status heuristics.
7
+ *
8
+ * States: starting, progressing, idle, stuck, done, failed, working
9
+ */
10
+ export function inferState(session) {
11
+ // Wrapped sessions already carry state from the wrapper report
12
+ if (session.isWrapped && session.runtimeState) {
13
+ return session.runtimeState;
14
+ }
15
+
16
+ // Non-wrapped: heuristic from CPU + status flags
17
+ const cpu = session.cpu || 0;
18
+ const elapsed = session.durationSeconds || session.elapsedSeconds || 0;
19
+
20
+ if (elapsed < 5) return 'starting';
21
+ if (session.status?.includes('idle') && cpu < 1) return 'idle';
22
+ if (session.status?.includes('high-cpu') && cpu > 50) return 'working';
23
+ if (cpu > 5) return 'progressing';
24
+ if (cpu < 0.1 && elapsed > 300) return 'idle';
25
+ if (cpu >= 0.1) return 'progressing';
26
+
27
+ return 'progressing'; // default for active sessions
28
+ }
29
+
30
+ /**
31
+ * Simple ETA heuristic for common tasks.
32
+ * Returns a string like "~2m remaining" or null.
33
+ */
34
+ export function estimateEta(session) {
35
+ if (!session.isWrapped) return null;
36
+ if (!session.runtimeState || session.runtimeState === 'done' || session.runtimeState === 'failed') return null;
37
+
38
+ const cmd = session.wrappedCmd || '';
39
+ const lineCount = session.wrappedLineCount || 0;
40
+
41
+ // npm install: rough estimate ~50 lines/min
42
+ if (/npm\s+(install|ci|i)\b/.test(cmd) && lineCount > 5) {
43
+ // Most npm installs are 50-200 lines. Very rough.
44
+ const elapsed = (Date.now() - new Date(session.wrappedStartedAt).getTime()) / 1000;
45
+ const rate = lineCount / Math.max(elapsed, 1); // lines per second
46
+ if (rate > 0 && lineCount < 200) {
47
+ const remaining = Math.round((200 - lineCount) / rate);
48
+ if (remaining > 2 && remaining < 300) {
49
+ return `~${Math.ceil(remaining / 60)}m remaining`;
50
+ }
51
+ }
52
+ }
53
+
54
+ // Build/dev servers: no ETA
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Add state inference to all sessions. Mutates in place.
60
+ */
61
+ export function addStateInference(sessions) {
62
+ for (const session of sessions) {
63
+ if (!session.runtimeState) {
64
+ const state = inferState(session);
65
+ if (state) session.runtimeState = state;
66
+ }
67
+ if (!session.eta) {
68
+ session.eta = estimateEta(session);
69
+ }
70
+ }
71
+ return sessions;
72
+ }
@@ -0,0 +1,210 @@
1
+ // tmux integration — snapshot collector + output-activity sampling.
2
+ // Optional: silently disables if tmux is not installed or no server running.
3
+ // Privacy-first: only stores sha1 hashes of pane output, never the text itself.
4
+
5
+ import { spawnSync, execFile } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { promisify } from 'node:util';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ const MAX_PANES_TO_SAMPLE = 20;
12
+ const SAMPLE_COOLDOWN_MS = 5_000; // min 5s between full sampling rounds
13
+ const PANE_CACHE_TTL = 5 * 60 * 1000; // evict entries for disappeared panes after 5m
14
+
15
+ // ---- Availability check ----
16
+
17
+ let _tmuxChecked = false;
18
+ let _tmuxAvailable = false;
19
+
20
+ export function isTmuxAvailable() {
21
+ if (_tmuxChecked) return _tmuxAvailable;
22
+ _tmuxChecked = true;
23
+
24
+ try {
25
+ const r = spawnSync('tmux', ['-V'], { timeout: 1000, encoding: 'utf-8', stdio: 'pipe' });
26
+ if (r.status !== 0 && r.status !== null) {
27
+ _tmuxAvailable = false;
28
+ return false;
29
+ }
30
+ // Also check if a tmux server is actually running
31
+ const s = spawnSync('tmux', ['list-sessions'], { timeout: 1000, encoding: 'utf-8', stdio: 'pipe' });
32
+ _tmuxAvailable = s.status === 0;
33
+ } catch {
34
+ _tmuxAvailable = false;
35
+ }
36
+ return _tmuxAvailable;
37
+ }
38
+
39
+ /**
40
+ * Re-check tmux availability (call periodically in case user starts/stops tmux).
41
+ */
42
+ export function recheckTmux() {
43
+ _tmuxChecked = false;
44
+ return isTmuxAvailable();
45
+ }
46
+
47
+ // ---- Pane listing ----
48
+
49
+ const PANE_FORMAT = '#{pane_id}\t#{window_id}\t#{session_name}\t#{pane_pid}\t#{pane_tty}\t#{pane_current_path}\t#{?pane_active,1,0}';
50
+
51
+ /**
52
+ * List all tmux panes. Returns [] if tmux unavailable.
53
+ */
54
+ export async function listPanes() {
55
+ if (!isTmuxAvailable()) return [];
56
+
57
+ try {
58
+ const { stdout } = await execFileAsync(
59
+ 'tmux', ['list-panes', '-a', '-F', PANE_FORMAT],
60
+ { timeout: 2000, encoding: 'utf-8' }
61
+ );
62
+
63
+ if (!stdout) return [];
64
+
65
+ const panes = [];
66
+ for (const line of stdout.trim().split('\n')) {
67
+ if (!line) continue;
68
+ const parts = line.split('\t');
69
+ if (parts.length < 7) continue;
70
+
71
+ panes.push({
72
+ paneId: parts[0], // e.g. %0
73
+ windowId: parts[1], // e.g. @0
74
+ sessionName: parts[2],
75
+ panePid: parseInt(parts[3], 10) || 0,
76
+ paneTty: parts[4], // e.g. /dev/ttys003
77
+ panePath: parts[5],
78
+ paneActive: parts[6] === '1',
79
+ });
80
+ }
81
+
82
+ return panes;
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ // ---- Output activity sampling (hash-based, privacy-first) ----
89
+
90
+ // Map<paneId, { lastHash, lastChangedAt, lastCheckedAt }>
91
+ const paneHashCache = new Map();
92
+ let lastSampleTime = 0;
93
+
94
+ /**
95
+ * Compute sha256 hash of the last 50 lines of a pane's visible output.
96
+ * Returns null on failure (pane gone, tmux error, timeout).
97
+ */
98
+ export async function getPaneTailHash(paneId) {
99
+ if (!isTmuxAvailable()) return null;
100
+
101
+ try {
102
+ const { stdout } = await execFileAsync(
103
+ 'tmux', ['capture-pane', '-p', '-t', paneId, '-S', '-50'],
104
+ { timeout: 500, encoding: 'utf-8' }
105
+ );
106
+
107
+ if (stdout == null) return null;
108
+
109
+ // Hash the output — never store the text
110
+ return createHash('sha256').update(stdout).digest('hex');
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Sample pane activity for the given pane IDs.
118
+ * Returns Map<paneId, { lastChangedAt, changedNow, lastCheckedAt }>.
119
+ *
120
+ * Rate-limited: will return cached results if called within SAMPLE_COOLDOWN_MS.
121
+ * Caps at MAX_PANES_TO_SAMPLE panes per round.
122
+ */
123
+ export async function samplePaneActivity(paneIds) {
124
+ const now = Date.now();
125
+ const result = new Map();
126
+
127
+ // Rate limit: skip if sampled recently
128
+ const shouldSample = (now - lastSampleTime) >= SAMPLE_COOLDOWN_MS;
129
+
130
+ // Only sample a bounded set
131
+ const toSample = paneIds.slice(0, MAX_PANES_TO_SAMPLE);
132
+
133
+ if (shouldSample) {
134
+ // Hash all panes concurrently instead of sequentially
135
+ const hashResults = await Promise.all(
136
+ toSample.map(async (paneId) => ({
137
+ paneId,
138
+ hash: await getPaneTailHash(paneId),
139
+ }))
140
+ );
141
+
142
+ for (const { paneId, hash } of hashResults) {
143
+ const cached = paneHashCache.get(paneId);
144
+
145
+ if (hash !== null) {
146
+ const changedNow = cached ? (hash !== cached.lastHash) : true;
147
+ const entry = {
148
+ lastHash: hash,
149
+ lastChangedAt: changedNow ? now : (cached?.lastChangedAt || now),
150
+ lastCheckedAt: now,
151
+ };
152
+ paneHashCache.set(paneId, entry);
153
+ result.set(paneId, {
154
+ lastChangedAt: entry.lastChangedAt,
155
+ changedNow,
156
+ lastCheckedAt: now,
157
+ });
158
+ } else if (cached) {
159
+ result.set(paneId, {
160
+ lastChangedAt: cached.lastChangedAt,
161
+ changedNow: false,
162
+ lastCheckedAt: cached.lastCheckedAt,
163
+ });
164
+ }
165
+ }
166
+
167
+ lastSampleTime = now;
168
+ cleanStaleCacheEntries(paneIds, now);
169
+ } else {
170
+ // Return cached results for all panes
171
+ for (const paneId of toSample) {
172
+ const cached = paneHashCache.get(paneId);
173
+ if (cached) {
174
+ result.set(paneId, {
175
+ lastChangedAt: cached.lastChangedAt,
176
+ changedNow: false,
177
+ lastCheckedAt: cached.lastCheckedAt,
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ return result;
184
+ }
185
+
186
+ /**
187
+ * Evict cache entries for panes that no longer appear in current pane list.
188
+ */
189
+ function cleanStaleCacheEntries(activePaneIds, now) {
190
+ const activeSet = new Set(activePaneIds);
191
+ for (const [paneId, entry] of paneHashCache) {
192
+ if (!activeSet.has(paneId) && (now - entry.lastCheckedAt) > PANE_CACHE_TTL) {
193
+ paneHashCache.delete(paneId);
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Register tmux debug endpoint.
200
+ */
201
+ export function registerTmuxRoutes(app) {
202
+ app.get('/api/tmux/panes', async (req, res) => {
203
+ const available = isTmuxAvailable();
204
+ if (!available) {
205
+ return res.json({ ok: true, available: false, panes: [] });
206
+ }
207
+ const panes = await listPanes();
208
+ res.json({ ok: true, available: true, panes });
209
+ });
210
+ }