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,149 @@
1
+ // Attention inference — deterministic rules that flag sessions needing attention.
2
+ // Produces session.attention = { level: "ok"|"warn"|"alert", reasons: [] }
3
+ // Also computes the global "safe to leave" signal.
4
+
5
+ /**
6
+ * Add attention inference to all sessions. Mutates in place.
7
+ * Run after addProgressInference() and addStateInference().
8
+ */
9
+ export function addAttentionInference(sessions) {
10
+ for (const session of sessions) {
11
+ const reasons = [];
12
+ let level = 'ok';
13
+ const cpu = session.cpu || 0;
14
+ const hasFileProgress = session.repoActivity?.filesChangedLast2m > 0;
15
+ const elapsed = session.durationSeconds || 0;
16
+
17
+ // Rule 1: Waiting for input (shell command finished, prompt is up)
18
+ if (session.waitingForInput && session.commandState === 'completed') {
19
+ if (session.lastCommandExitCode !== 0 && session.lastCommandExitCode != null) {
20
+ reasons.push({
21
+ rule: 'failedCommand',
22
+ message: `Last command failed (exit ${session.lastCommandExitCode})`,
23
+ severity: 'alert',
24
+ });
25
+ } else {
26
+ reasons.push({
27
+ rule: 'waitingForInput',
28
+ message: 'Waiting for next command',
29
+ severity: 'warn',
30
+ });
31
+ }
32
+ }
33
+
34
+ // tmux output freshness — only meaningful when tmux data exists
35
+ const hasTmux = session.tmux && session.tmux.lastPaneOutputAt;
36
+ const tmuxOutputAge = hasTmux
37
+ ? (Date.now() - session.tmux.lastPaneOutputAt) / 1000
38
+ : null;
39
+ const hasFreshTmuxOutput = tmuxOutputAge !== null && tmuxOutputAge < 60;
40
+
41
+ // Rule 2: Possibly stuck — high CPU, no file output, long running
42
+ // If tmux shows fresh output, downgrade from alert to ok
43
+ const rs = session.runtimeState || '';
44
+ if (rs === 'stuck' || rs === 'possibly-stuck') {
45
+ if (hasFreshTmuxOutput) {
46
+ // tmux pane is active — override stuck signal, it's actually progressing
47
+ session.runtimeState = 'progressing';
48
+ } else {
49
+ // Only use tmux output staleness to escalate when tmux data exists
50
+ const noTmuxOutputLong = tmuxOutputAge !== null && tmuxOutputAge > 600; // 10 min
51
+ reasons.push({
52
+ rule: 'possiblyStuck',
53
+ message: rs === 'stuck'
54
+ ? 'Process appears stuck' + (hasTmux ? ' — no terminal output for ' + formatAge(tmuxOutputAge) : '')
55
+ : 'May be stuck — no output or file changes',
56
+ severity: (cpu > 5 && !hasFileProgress && noTmuxOutputLong) ? 'alert' : 'warn',
57
+ });
58
+ }
59
+ }
60
+
61
+ // Rule 2b: AI agent waiting for input (tmux-enhanced)
62
+ // ai-agent + commandState running + cpu < 1% + no repo activity + no tmux change for 3m
63
+ // Only apply tmux staleness check when tmux data exists; without tmux, use other signals
64
+ const tmuxStaleLong = tmuxOutputAge !== null && tmuxOutputAge > 180;
65
+ const noTmuxButIdle = tmuxOutputAge === null && cpu < 1 && elapsed > 300;
66
+ if (session.type === 'ai-agent' && session.commandState === 'running' && cpu < 1
67
+ && !hasFileProgress && (tmuxStaleLong || noTmuxButIdle) && !reasons.some(r => r.rule === 'possiblyStuck')) {
68
+ reasons.push({
69
+ rule: 'waitingForInput',
70
+ message: hasTmux
71
+ ? 'AI agent may be waiting for input — idle ' + formatAge(tmuxOutputAge)
72
+ : 'AI agent may be waiting for input — low CPU, no file changes',
73
+ severity: 'warn',
74
+ });
75
+ }
76
+
77
+ // Rule 3: Duplicate AI agents in same repo
78
+ if (session.type === 'ai-agent' && session.cwd) {
79
+ const sameRepoAgents = sessions.filter(
80
+ s => s.type === 'ai-agent' && s.cwd === session.cwd && s.id !== session.id
81
+ );
82
+ if (sameRepoAgents.length > 0) {
83
+ reasons.push({
84
+ rule: 'duplicateAgents',
85
+ message: `${sameRepoAgents.length + 1} AI agents in same repo`,
86
+ severity: 'warn',
87
+ });
88
+ }
89
+ }
90
+
91
+ // Rule 4: Long running with no progress signal
92
+ // Only count fresh tmux output as a positive signal; absence of tmux is not negative
93
+ if (elapsed > 1800 && !hasFileProgress && cpu < 2 && !session.isWrapped && !hasFreshTmuxOutput && (hasTmux || !session.tmux)) {
94
+ reasons.push({
95
+ rule: 'longRunningIdle',
96
+ message: 'Running 30+ min with no file activity' + (session.tmux ? ' or terminal output' : '') + ' or CPU',
97
+ severity: 'warn',
98
+ });
99
+ }
100
+
101
+ // Rule 5: Failed wrapped process
102
+ if (session.isWrapped && rs === 'failed') {
103
+ reasons.push({
104
+ rule: 'wrappedFailed',
105
+ message: 'Wrapped command failed',
106
+ severity: 'alert',
107
+ });
108
+ }
109
+
110
+ // Compute level from highest severity
111
+ for (const r of reasons) {
112
+ if (r.severity === 'alert') { level = 'alert'; break; }
113
+ if (r.severity === 'warn') level = 'warn';
114
+ }
115
+
116
+ session.attention = { level, reasons };
117
+ }
118
+
119
+ return sessions;
120
+ }
121
+
122
+ function formatAge(seconds) {
123
+ if (!isFinite(seconds)) return 'unknown';
124
+ if (seconds < 60) return Math.round(seconds) + 's';
125
+ if (seconds < 3600) return Math.round(seconds / 60) + 'm';
126
+ return Math.round(seconds / 3600) + 'h';
127
+ }
128
+
129
+ /**
130
+ * Compute global "safe to leave" summary from sessions.
131
+ * Returns { safeToLeave, needsAttentionCount, needsAttention[] }
132
+ */
133
+ export function computeSafeToLeave(sessions) {
134
+ const needsAttention = sessions.filter(
135
+ s => s.attention && s.attention.level !== 'ok'
136
+ ).map(s => ({
137
+ id: s.id,
138
+ title: s.title,
139
+ icon: s.icon,
140
+ level: s.attention.level,
141
+ reasons: s.attention.reasons.map(r => r.message),
142
+ }));
143
+
144
+ return {
145
+ safeToLeave: needsAttention.filter(s => s.level === 'alert').length === 0,
146
+ needsAttentionCount: needsAttention.length,
147
+ needsAttention,
148
+ };
149
+ }