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,96 @@
1
+ // tmux context merge — matches tmux panes to sessions, attaches metadata.
2
+ // Runs as a two-pass integration: first merge panes (metadata only),
3
+ // then after activity sampling, attach lastPaneOutputAt.
4
+
5
+ /**
6
+ * Merge tmux pane context into sessions. Mutates in place.
7
+ *
8
+ * Matching heuristic (priority order):
9
+ * 1) Session process PID matches pane.panePid (or panePid is ancestor)
10
+ * 2) Session cwd === pane.panePath
11
+ * 3) Session repo matches last segment of panePath (weak)
12
+ *
13
+ * Each paneId is assigned to at most one session (first best match wins).
14
+ */
15
+ export function mergeTmuxContext(sessions, panes, paneActivity) {
16
+ if (!panes || panes.length === 0) return sessions;
17
+
18
+ const assigned = new Set();
19
+
20
+ // Pass 1: PID match (strongest signal)
21
+ for (const session of sessions) {
22
+ if (session.tmux) continue; // already matched in a prior pass
23
+ const pids = new Set(session.processes.map(p => p.pid));
24
+ const ppids = new Set(session.processes.map(p => p.ppid).filter(Boolean));
25
+
26
+ for (const pane of panes) {
27
+ if (assigned.has(pane.paneId)) continue;
28
+ if (pids.has(pane.panePid) || ppids.has(pane.panePid)) {
29
+ attachTmux(session, pane, paneActivity);
30
+ assigned.add(pane.paneId);
31
+ break;
32
+ }
33
+ }
34
+ }
35
+
36
+ // Pass 2: cwd match
37
+ for (const session of sessions) {
38
+ if (session.tmux) continue;
39
+ if (!session.cwd) continue;
40
+
41
+ for (const pane of panes) {
42
+ if (assigned.has(pane.paneId)) continue;
43
+ if (pane.panePath && pane.panePath === session.cwd) {
44
+ attachTmux(session, pane, paneActivity);
45
+ assigned.add(pane.paneId);
46
+ break;
47
+ }
48
+ }
49
+ }
50
+
51
+ // Pass 3: repo name match (weak — last path segment)
52
+ for (const session of sessions) {
53
+ if (session.tmux) continue;
54
+ if (!session.repo) continue;
55
+
56
+ const repoLower = session.repo.toLowerCase();
57
+
58
+ for (const pane of panes) {
59
+ if (assigned.has(pane.paneId)) continue;
60
+ if (!pane.panePath) continue;
61
+
62
+ const lastSeg = pane.panePath.split('/').filter(Boolean).pop()?.toLowerCase();
63
+ if (lastSeg && lastSeg === repoLower) {
64
+ attachTmux(session, pane, paneActivity);
65
+ assigned.add(pane.paneId);
66
+ break;
67
+ }
68
+ }
69
+ }
70
+
71
+ return sessions;
72
+ }
73
+
74
+ function attachTmux(session, pane, paneActivity) {
75
+ const activity = paneActivity?.get(pane.paneId);
76
+
77
+ session.tmux = {
78
+ paneId: pane.paneId,
79
+ windowId: pane.windowId,
80
+ sessionName: pane.sessionName,
81
+ panePid: pane.panePid,
82
+ paneTty: pane.paneTty,
83
+ panePath: pane.panePath,
84
+ paneActive: pane.paneActive,
85
+ lastPaneOutputAt: activity?.lastChangedAt || null,
86
+ paneOutputChangedNow: activity?.changedNow || false,
87
+ };
88
+
89
+ // Update lastProgressAt if pane output is more recent
90
+ if (activity?.lastChangedAt) {
91
+ const existing = session.lastProgressAt ? new Date(session.lastProgressAt).getTime() : 0;
92
+ if (activity.lastChangedAt > existing) {
93
+ session.lastProgressAt = new Date(activity.lastChangedAt).toISOString();
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,181 @@
1
+ // Shell hook script generator — emits shell code for zsh/bash
2
+ // that posts command lifecycle events to the local agent.
3
+
4
+ const AGENT_URL = 'http://127.0.0.1:7331/api/shell/event';
5
+
6
+ /**
7
+ * Generate the shell hook script for the given shell type.
8
+ */
9
+ export function generateHookScript(shell = 'zsh') {
10
+ if (shell === 'bash') return bashScript();
11
+ return zshScript();
12
+ }
13
+
14
+ function zshScript() {
15
+ return `
16
+ # RuntimeInspector shell integration (zsh)
17
+ # Posts command start/end events to the local agent.
18
+ # Non-blocking; fails silently if agent is not running.
19
+
20
+ __runtimeinspector_json_escape() {
21
+ # Properly escape a string for JSON: backslash, double quote, and control chars
22
+ local s="\$1"
23
+ s="\${s//\\\\/\\\\\\\\}" # \\ -> \\\\
24
+ s="\${s//\\"/\\\\\\"}" # " -> \\"
25
+ s="\${s//$'\\n'/\\\\n}" # newline -> \\n
26
+ s="\${s//$'\\r'/\\\\r}" # carriage return -> \\r
27
+ s="\${s//$'\\t'/\\\\t}" # tab -> \\t
28
+ printf '%s' "\$s"
29
+ }
30
+
31
+ __runtimeinspector_post_event() {
32
+ local event_type="\$1"
33
+ local payload="\$2"
34
+ local _tty
35
+ _tty="$(tty 2>/dev/null || echo unknown)"
36
+ local _ts="\$(date +%s000)"
37
+ local _cwd
38
+ _cwd="\$(__runtimeinspector_json_escape "\$(pwd)")"
39
+ local _host
40
+ _host="\$(__runtimeinspector_json_escape "\${HOST}")"
41
+ local _user
42
+ _user="\$(__runtimeinspector_json_escape "\${USER}")"
43
+ local _json="{\\"type\\":\\"\${event_type}\\",\\"shellType\\":\\"zsh\\",\\"tty\\":\\"\${_tty}\\",\\"shellPid\\":$$,\\"cwd\\":\\"\${_cwd}\\",\\"timestamp\\":\${_ts},\\"username\\":\\"\${_user}\\",\\"host\\":\\"\${_host}\\",\${payload}}"
44
+
45
+ # Non-blocking POST with 150ms timeout; discard output
46
+ if command -v curl >/dev/null 2>&1; then
47
+ curl -s -o /dev/null --max-time 0.15 -X POST \\
48
+ -H "Content-Type: application/json" \\
49
+ -d "\${_json}" \\
50
+ "${AGENT_URL}" 2>/dev/null &!
51
+ fi
52
+ }
53
+
54
+ # --- preexec: fires BEFORE a command runs ---
55
+ __runtimeinspector_preexec() {
56
+ __runtimeinspector_cmd_start="\$(date +%s000)"
57
+ # Properly escape command for JSON safety
58
+ local _safe_cmd
59
+ _safe_cmd="\$(__runtimeinspector_json_escape "\$1")"
60
+ __runtimeinspector_post_event "command_start" "\\"command\\":\\"\${_safe_cmd}\\""
61
+ }
62
+
63
+ # --- precmd: fires AFTER a command completes, before next prompt ---
64
+ __runtimeinspector_precmd() {
65
+ local _exit_code=\$?
66
+ # Only fire if we actually tracked a command start
67
+ if [[ -n "\${__runtimeinspector_cmd_start:-}" ]]; then
68
+ __runtimeinspector_post_event "command_end" "\\"exitCode\\":\${_exit_code}"
69
+ unset __runtimeinspector_cmd_start
70
+ fi
71
+ }
72
+
73
+ # Chain into existing hooks without clobbering
74
+ autoload -Uz add-zsh-hook 2>/dev/null
75
+ if (( \${+functions[add-zsh-hook]} )); then
76
+ add-zsh-hook preexec __runtimeinspector_preexec
77
+ add-zsh-hook precmd __runtimeinspector_precmd
78
+ else
79
+ # Fallback: manual chaining
80
+ if [[ -n "\${preexec_functions+x}" ]]; then
81
+ preexec_functions+=(__runtimeinspector_preexec)
82
+ else
83
+ preexec_functions=(__runtimeinspector_preexec)
84
+ fi
85
+ if [[ -n "\${precmd_functions+x}" ]]; then
86
+ precmd_functions+=(__runtimeinspector_precmd)
87
+ else
88
+ precmd_functions=(__runtimeinspector_precmd)
89
+ fi
90
+ fi
91
+ `;
92
+ }
93
+
94
+ function bashScript() {
95
+ return `
96
+ # RuntimeInspector shell integration (bash)
97
+ # Posts command start/end events to the local agent.
98
+ # Non-blocking; fails silently if agent is not running.
99
+
100
+ __runtimeinspector_json_escape() {
101
+ # Properly escape a string for JSON: backslash, double quote, and control chars
102
+ local s="\$1"
103
+ s="\${s//\\\\/\\\\\\\\}" # \\ -> \\\\
104
+ s="\${s//\\"/\\\\\\"}" # " -> \\"
105
+ s="\${s//$'\\n'/\\\\n}" # newline -> \\n
106
+ s="\${s//$'\\r'/\\\\r}" # carriage return -> \\r
107
+ s="\${s//$'\\t'/\\\\t}" # tab -> \\t
108
+ printf '%s' "\$s"
109
+ }
110
+
111
+ __runtimeinspector_post_event() {
112
+ local event_type="\$1"
113
+ local payload="\$2"
114
+ local _tty
115
+ _tty="$(tty 2>/dev/null || echo unknown)"
116
+ local _ts="\$(date +%s000)"
117
+ local _cwd
118
+ _cwd="\$(__runtimeinspector_json_escape "\$(pwd)")"
119
+ local _host
120
+ _host="\$(__runtimeinspector_json_escape "\${HOSTNAME}")"
121
+ local _user
122
+ _user="\$(__runtimeinspector_json_escape "\${USER}")"
123
+ local _json="{\\"type\\":\\"\${event_type}\\",\\"shellType\\":\\"bash\\",\\"tty\\":\\"\${_tty}\\",\\"shellPid\\":$$,\\"cwd\\":\\"\${_cwd}\\",\\"timestamp\\":\${_ts},\\"username\\":\\"\${_user}\\",\\"host\\":\\"\${_host}\\",\${payload}}"
124
+
125
+ if command -v curl >/dev/null 2>&1; then
126
+ curl -s -o /dev/null --max-time 0.15 -X POST \\
127
+ -H "Content-Type: application/json" \\
128
+ -d "\${_json}" \\
129
+ "${AGENT_URL}" 2>/dev/null &
130
+ disown 2>/dev/null
131
+ fi
132
+ }
133
+
134
+ # --- DEBUG trap: fires before each command ---
135
+ __runtimeinspector_debug_trap() {
136
+ # Only capture top-level commands (not subshells, PROMPT_COMMAND, etc.)
137
+ if [[ -n "\${COMP_LINE:-}" ]]; then return; fi
138
+ if [[ "\${BASH_COMMAND}" == "\${PROMPT_COMMAND}" ]]; then return; fi
139
+ if [[ "\${BASH_COMMAND}" == __runtimeinspector_* ]]; then return; fi
140
+
141
+ __runtimeinspector_cmd_start="\$(date +%s000)"
142
+ local _safe_cmd
143
+ _safe_cmd="\$(__runtimeinspector_json_escape "\${BASH_COMMAND}")"
144
+ __runtimeinspector_post_event "command_start" "\\"command\\":\\"\${_safe_cmd}\\""
145
+ }
146
+
147
+ # --- PROMPT_COMMAND: fires after each command, before prompt ---
148
+ __runtimeinspector_prompt_cmd() {
149
+ local _exit_code=\$?
150
+ if [[ -n "\${__runtimeinspector_cmd_start:-}" ]]; then
151
+ __runtimeinspector_post_event "command_end" "\\"exitCode\\":\${_exit_code}"
152
+ unset __runtimeinspector_cmd_start
153
+ fi
154
+ }
155
+
156
+ # Chain DEBUG trap (preserve existing)
157
+ if [[ -z "\$(trap -p DEBUG)" ]] || ! trap -p DEBUG | grep -q __runtimeinspector; then
158
+ trap '__runtimeinspector_debug_trap; \${__runtimeinspector_prev_debug_trap:-:}' DEBUG
159
+ fi
160
+
161
+ # Chain PROMPT_COMMAND (preserve existing)
162
+ if [[ -n "\${PROMPT_COMMAND}" ]]; then
163
+ PROMPT_COMMAND="__runtimeinspector_prompt_cmd;\${PROMPT_COMMAND}"
164
+ else
165
+ PROMPT_COMMAND="__runtimeinspector_prompt_cmd"
166
+ fi
167
+ `;
168
+ }
169
+
170
+ /**
171
+ * The marker block that goes into rc files.
172
+ */
173
+ export function rcBlock(shell = 'zsh') {
174
+ const cmd = `runtimeinspector init --shell ${shell}`;
175
+ return `# >>> runtimeinspector >>>
176
+ eval "$(${cmd})"
177
+ # <<< runtimeinspector <<<`;
178
+ }
179
+
180
+ export const RC_MARKER_START = '# >>> runtimeinspector >>>';
181
+ export const RC_MARKER_END = '# <<< runtimeinspector <<<';
@@ -0,0 +1,85 @@
1
+ // Shell setup — edits rc file to add RuntimeInspector integration.
2
+ // Idempotent, creates backups, never deletes.
3
+
4
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { rcBlock, RC_MARKER_START } from './hooks.js';
7
+
8
+ /**
9
+ * Detect the user's shell and return { shell, rcPath }.
10
+ */
11
+ export function detectShell(overrideShell, overrideRc) {
12
+ const shell = overrideShell || (process.env.SHELL || '').split('/').pop() || 'zsh';
13
+ const home = process.env.HOME;
14
+
15
+ if (!home) {
16
+ throw new Error('Cannot determine HOME directory');
17
+ }
18
+
19
+ let rcPath;
20
+ if (overrideRc) {
21
+ rcPath = overrideRc;
22
+ } else if (shell === 'bash') {
23
+ // Prefer .bashrc; fall back to .bash_profile if .bashrc doesn't exist
24
+ const bashrc = join(home, '.bashrc');
25
+ const bashProfile = join(home, '.bash_profile');
26
+ if (existsSync(bashrc)) {
27
+ rcPath = bashrc;
28
+ } else if (existsSync(bashProfile)) {
29
+ rcPath = bashProfile;
30
+ } else {
31
+ rcPath = bashrc; // will create
32
+ }
33
+ } else {
34
+ rcPath = join(home, '.zshrc');
35
+ }
36
+
37
+ const SUPPORTED_SHELLS = new Set(['zsh', 'bash']);
38
+ const normalizedShell = SUPPORTED_SHELLS.has(shell) ? shell : null;
39
+ if (!normalizedShell) {
40
+ throw new Error(`Unsupported shell: "${shell}". RuntimeInspector supports zsh and bash.`);
41
+ }
42
+ return { shell: normalizedShell, rcPath };
43
+ }
44
+
45
+ /**
46
+ * Install the integration block into the rc file.
47
+ * Returns { status: 'installed' | 'already' | 'error', message: string }
48
+ */
49
+ export function installRcBlock({ shell, rcPath, yes = false }) {
50
+ // Safety: canonicalize path to prevent traversal (e.g. /tmp/../../etc/...)
51
+ const resolvedPath = resolve(rcPath);
52
+ const home = process.env.HOME;
53
+ if (home && !resolvedPath.startsWith(resolve(home))) {
54
+ return { status: 'error', message: `Refusing to edit file outside HOME: ${resolvedPath}` };
55
+ }
56
+ rcPath = resolvedPath;
57
+
58
+ // Read existing content (or empty if file doesn't exist)
59
+ let existing = '';
60
+ if (existsSync(rcPath)) {
61
+ existing = readFileSync(rcPath, 'utf-8');
62
+ }
63
+
64
+ // Check if block already present
65
+ if (existing.includes(RC_MARKER_START)) {
66
+ return { status: 'already', message: `RuntimeInspector integration already installed in ${rcPath}` };
67
+ }
68
+
69
+ // Create backup
70
+ if (existsSync(rcPath)) {
71
+ const timestamp = Date.now();
72
+ const backupPath = `${rcPath}.runtimeinspector.bak.${timestamp}`;
73
+ copyFileSync(rcPath, backupPath);
74
+ }
75
+
76
+ // Append block
77
+ const block = rcBlock(shell);
78
+ const newContent = existing.trimEnd() + '\n\n' + block + '\n';
79
+ writeFileSync(rcPath, newContent, 'utf-8');
80
+
81
+ return {
82
+ status: 'installed',
83
+ message: `RuntimeInspector integration installed in ${rcPath}`,
84
+ };
85
+ }
@@ -0,0 +1,34 @@
1
+ // Ring buffer for captured output lines
2
+
3
+ export class OutputBuffer {
4
+ constructor(maxLines = 300) {
5
+ this.maxLines = maxLines;
6
+ this.lines = [];
7
+ }
8
+
9
+ push(text, stream = 'stdout') {
10
+ const timestamp = new Date().toISOString();
11
+ const parts = text.toString().split('\n');
12
+ // Last element after split on trailing newline is empty — skip it
13
+ for (const part of parts) {
14
+ if (part === '' && parts.indexOf(part) === parts.length - 1) continue;
15
+ this.lines.push({ text: part, stream, timestamp });
16
+ }
17
+ // Trim to max
18
+ if (this.lines.length > this.maxLines) {
19
+ this.lines = this.lines.slice(this.lines.length - this.maxLines);
20
+ }
21
+ }
22
+
23
+ getRecent(n = 20) {
24
+ return this.lines.slice(-n);
25
+ }
26
+
27
+ get length() {
28
+ return this.lines.length;
29
+ }
30
+
31
+ toJSON() {
32
+ return this.lines;
33
+ }
34
+ }
@@ -0,0 +1,149 @@
1
+ // Wrapper runner — spawns a child process, captures output, reports to agent
2
+
3
+ import { spawn, execFile } from 'node:child_process';
4
+ import { request } from 'node:http';
5
+ import { promisify } from 'node:util';
6
+ import { OutputBuffer } from './buffer.js';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ const AGENT_URL = 'http://localhost:7331/api/wrapper/report';
11
+ const REPORT_INTERVAL = 2000;
12
+
13
+ function inferState(child, buffer, startedAt, cpuPercent) {
14
+ if (child.exitCode !== null) {
15
+ return child.exitCode === 0 ? 'done' : 'failed';
16
+ }
17
+
18
+ const now = Date.now();
19
+ const age = now - startedAt;
20
+ const lastLine = buffer.lines[buffer.lines.length - 1];
21
+ const lastOutputAge = lastLine ? now - new Date(lastLine.timestamp).getTime() : age;
22
+
23
+ if (age < 5000) return 'starting';
24
+ if (lastOutputAge < 10000) return 'progressing';
25
+ if (lastOutputAge > 60000 && cpuPercent > 5) return 'stuck';
26
+ if (lastOutputAge > 30000 && cpuPercent < 1) return 'idle';
27
+ if (cpuPercent > 1) return 'working';
28
+ return 'progressing';
29
+ }
30
+
31
+ function sendReport(payload) {
32
+ return new Promise((resolve) => {
33
+ const body = JSON.stringify(payload);
34
+ const url = new URL(AGENT_URL);
35
+ const req = request(
36
+ {
37
+ hostname: url.hostname,
38
+ port: url.port,
39
+ path: url.pathname,
40
+ method: 'POST',
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ 'Content-Length': Buffer.byteLength(body),
44
+ },
45
+ timeout: 2000,
46
+ },
47
+ (res) => {
48
+ res.resume();
49
+ resolve(true);
50
+ }
51
+ );
52
+ req.on('error', () => resolve(false));
53
+ req.on('timeout', () => { req.destroy(); resolve(false); });
54
+ req.end(body);
55
+ });
56
+ }
57
+
58
+ // Simple CPU % via ps for a single PID (async, non-blocking)
59
+ async function readCpu(pid) {
60
+ try {
61
+ const { stdout } = await execFileAsync(
62
+ 'ps', ['-o', '%cpu=', '-p', String(pid)],
63
+ { encoding: 'utf-8', timeout: 1000 }
64
+ );
65
+ return parseFloat(stdout.trim()) || 0;
66
+ } catch {
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ export async function runWrapped(cmdArgs) {
72
+ if (!cmdArgs || cmdArgs.length === 0) {
73
+ console.error('Usage: runtime-inspector run -- <command> [args...]');
74
+ process.exit(1);
75
+ }
76
+
77
+ const [cmd, ...args] = cmdArgs;
78
+ const buffer = new OutputBuffer(300);
79
+ const startedAt = Date.now();
80
+ let lastOutputAt = null;
81
+ let exitCode = null;
82
+ let cpuPercent = 0;
83
+
84
+ const child = spawn(cmd, args, {
85
+ stdio: ['inherit', 'pipe', 'pipe'],
86
+ env: { ...process.env, FORCE_COLOR: '1' },
87
+ });
88
+
89
+ child.stdout.on('data', (chunk) => {
90
+ process.stdout.write(chunk);
91
+ buffer.push(chunk.toString(), 'stdout');
92
+ lastOutputAt = new Date().toISOString();
93
+ });
94
+
95
+ child.stderr.on('data', (chunk) => {
96
+ process.stderr.write(chunk);
97
+ buffer.push(chunk.toString(), 'stderr');
98
+ lastOutputAt = new Date().toISOString();
99
+ });
100
+
101
+ // Report loop
102
+ const reportTimer = setInterval(async () => {
103
+ cpuPercent = await readCpu(child.pid);
104
+ const state = inferState(child, buffer, startedAt, cpuPercent);
105
+ await sendReport({
106
+ pid: child.pid,
107
+ cmd: [cmd, ...args].join(' '),
108
+ state,
109
+ recentOutput: buffer.getRecent(30),
110
+ lastOutputAt,
111
+ startedAt: new Date(startedAt).toISOString(),
112
+ exitCode,
113
+ lineCount: buffer.length,
114
+ });
115
+ }, REPORT_INTERVAL);
116
+
117
+ // Forward signals to child
118
+ const forwardSignal = (sig) => {
119
+ try { child.kill(sig); } catch {}
120
+ };
121
+ process.on('SIGINT', () => forwardSignal('SIGINT'));
122
+ process.on('SIGTERM', () => forwardSignal('SIGTERM'));
123
+
124
+ child.on('error', (err) => {
125
+ console.error(`Failed to start command: ${err.message}`);
126
+ clearInterval(reportTimer);
127
+ process.exit(1);
128
+ });
129
+
130
+ child.on('close', async (code) => {
131
+ exitCode = code;
132
+ clearInterval(reportTimer);
133
+
134
+ // Send final report
135
+ const state = code === 0 ? 'done' : 'failed';
136
+ await sendReport({
137
+ pid: child.pid,
138
+ cmd: [cmd, ...args].join(' '),
139
+ state,
140
+ recentOutput: buffer.getRecent(30),
141
+ lastOutputAt,
142
+ startedAt: new Date(startedAt).toISOString(),
143
+ exitCode: code,
144
+ lineCount: buffer.length,
145
+ });
146
+
147
+ process.exit(code ?? 1);
148
+ });
149
+ }