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