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