@probelabs/probe 0.6.0-rc263 → 0.6.0-rc265

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.
@@ -2771,6 +2771,7 @@ Follow these instructions carefully:
2771
2771
  * For rewriting entire functions/classes/methods, use the symbol parameter instead (no exact text matching needed).
2772
2772
  * For editing specific lines from search/extract output, use start_line (and optionally end_line) with the line numbers shown in the output.${this.hashLines ? ' Line references include content hashes (e.g. "42:ab") for integrity verification.' : ''}
2773
2773
  * For editing inside large functions: first use extract with the symbol target (e.g. "file.js#myFunction") to see the function with line numbers${this.hashLines ? ' and hashes' : ''}, then use start_line/end_line to surgically edit specific lines within it.
2774
+ * IMPORTANT: Keep old_string as small as possible — include only the lines you need to change plus minimal context for uniqueness. For replacing large blocks (10+ lines), prefer line-targeted editing with start_line/end_line to constrain scope.
2774
2775
  - Use 'create' for new files or complete file rewrites.
2775
2776
  - If an edit fails, read the error message — it tells you exactly how to fix the call and retry.
2776
2777
  - The system tracks which files you've seen via search/extract. If you try to edit a file you haven't read, or one that changed since you last read it, the edit will fail with instructions to re-read first. Always use extract before editing to ensure you have current file content.` : ''}
@@ -8,6 +8,191 @@ import { resolve, join } from 'path';
8
8
  import { existsSync } from 'fs';
9
9
  import { parseCommandForExecution, isComplexCommand } from './bashCommandUtils.js';
10
10
 
11
+ // ─── Interactive Command Detection ─────────────────────────────────────────
12
+
13
+ /**
14
+ * Split a command string by shell operators (&&, ||, |, ;) while respecting quotes.
15
+ * Used for interactive command detection in complex pipelines.
16
+ * @param {string} command - Command string to split
17
+ * @returns {string[]} Array of individual command strings
18
+ */
19
+ function splitCommandComponents(command) {
20
+ const parts = [];
21
+ let current = '';
22
+ let inQuote = false;
23
+ let quoteChar = '';
24
+
25
+ for (let i = 0; i < command.length; i++) {
26
+ const c = command[i];
27
+ const next = command[i + 1] || '';
28
+
29
+ // Handle escape sequences
30
+ if (c === '\\' && !inQuote) {
31
+ current += c + next;
32
+ i++;
33
+ continue;
34
+ }
35
+ if (inQuote && quoteChar === '"' && c === '\\' && next) {
36
+ current += c + next;
37
+ i++;
38
+ continue;
39
+ }
40
+
41
+ // Track quotes
42
+ if (!inQuote && (c === '"' || c === "'")) {
43
+ inQuote = true;
44
+ quoteChar = c;
45
+ current += c;
46
+ continue;
47
+ }
48
+ if (inQuote && c === quoteChar) {
49
+ inQuote = false;
50
+ current += c;
51
+ continue;
52
+ }
53
+
54
+ // Split on operators outside quotes
55
+ if (!inQuote) {
56
+ if ((c === '&' && next === '&') || (c === '|' && next === '|')) {
57
+ if (current.trim()) parts.push(current.trim());
58
+ current = '';
59
+ i++;
60
+ continue;
61
+ }
62
+ if (c === '|' || c === ';') {
63
+ if (current.trim()) parts.push(current.trim());
64
+ current = '';
65
+ continue;
66
+ }
67
+ }
68
+
69
+ current += c;
70
+ }
71
+ if (current.trim()) parts.push(current.trim());
72
+ return parts;
73
+ }
74
+
75
+ /**
76
+ * Check a single (non-compound) command for interactive behavior.
77
+ * Strips leading env-var assignments (e.g. GIT_EDITOR=true) before checking.
78
+ *
79
+ * @param {string} command - Single command string
80
+ * @returns {string|null} Error message with suggestion, or null if not interactive
81
+ */
82
+ function checkSingleCommandInteractive(command) {
83
+ let effective = command.trim();
84
+
85
+ // Strip leading VAR=VALUE prefixes (e.g. "GIT_EDITOR=true git rebase --continue")
86
+ while (/^\w+=\S*\s/.test(effective)) {
87
+ effective = effective.replace(/^\w+=\S*\s+/, '');
88
+ }
89
+
90
+ const parts = effective.split(/\s+/);
91
+ const base = parts[0];
92
+ const args = parts.slice(1);
93
+
94
+ // ── Interactive editors ──
95
+ if (['vi', 'vim', 'nvim', 'nano', 'emacs', 'pico', 'joe', 'mcedit'].includes(base)) {
96
+ return `'${base}' is an interactive editor and cannot run without a terminal. Use non-interactive file manipulation commands instead.`;
97
+ }
98
+
99
+ // ── Interactive pagers ──
100
+ if (['less', 'more'].includes(base)) {
101
+ return `'${base}' is an interactive pager. Use 'cat', 'head', or 'tail' instead.`;
102
+ }
103
+
104
+ // ── Git commands that open an editor ──
105
+ if (base === 'git') {
106
+ const sub = args[0];
107
+
108
+ // git commit without -m / --message / -C / -c / --fixup / --squash / --no-edit
109
+ if (sub === 'commit') {
110
+ const hasNonInteractiveFlag = args.some(a =>
111
+ a === '-m' || a.startsWith('--message') ||
112
+ a === '-C' || a === '-c' ||
113
+ a.startsWith('--fixup') || a.startsWith('--squash') ||
114
+ a === '--allow-empty-message' || a === '--no-edit'
115
+ );
116
+ if (!hasNonInteractiveFlag) {
117
+ return "Interactive command: 'git commit' opens an editor for the commit message. Use 'git commit -m \"your message\"' instead.";
118
+ }
119
+ }
120
+
121
+ // git rebase --continue / --skip (opens editor for commit message)
122
+ if (sub === 'rebase' && (args.includes('--continue') || args.includes('--skip'))) {
123
+ return "Interactive command: 'git rebase --continue' opens an editor. Set environment variable GIT_EDITOR=true to accept default messages, e.g. pass env: {GIT_EDITOR: 'true'} or prepend GIT_EDITOR=true to the command.";
124
+ }
125
+
126
+ // git rebase -i / --interactive
127
+ if (sub === 'rebase' && (args.includes('-i') || args.includes('--interactive'))) {
128
+ return "Interactive command: 'git rebase -i' requires an interactive editor. Interactive rebase cannot run without a terminal.";
129
+ }
130
+
131
+ // git merge without --no-edit / --no-commit / --ff-only
132
+ if (sub === 'merge' && !args.includes('--no-edit') && !args.includes('--no-commit') && !args.includes('--ff-only')) {
133
+ return "Interactive command: 'git merge' may open an editor for the merge commit message. Add '--no-edit' to accept the default message.";
134
+ }
135
+
136
+ // git cherry-pick without --no-edit
137
+ if (sub === 'cherry-pick' && !args.includes('--no-edit')) {
138
+ return "Interactive command: 'git cherry-pick' may open an editor. Add '--no-edit' to accept the default message.";
139
+ }
140
+
141
+ // git revert without --no-edit
142
+ if (sub === 'revert' && !args.includes('--no-edit')) {
143
+ return "Interactive command: 'git revert' opens an editor. Add '--no-edit' to accept the default message.";
144
+ }
145
+
146
+ // git tag -a without -m
147
+ if (sub === 'tag' && args.includes('-a') && !args.some(a => a === '-m' || a.startsWith('--message'))) {
148
+ return "Interactive command: 'git tag -a' opens an editor for the tag message. Use 'git tag -a <name> -m \"message\"' instead.";
149
+ }
150
+
151
+ // git add -i / --interactive / -p / --patch
152
+ if (sub === 'add' && (args.includes('-i') || args.includes('--interactive') || args.includes('-p') || args.includes('--patch'))) {
153
+ return "Interactive command: 'git add -i/-p' requires interactive input. Use 'git add <files>' to stage specific files instead.";
154
+ }
155
+ }
156
+
157
+ // ── Interactive REPLs (no arguments = interactive mode) ──
158
+ if (['python', 'python3', 'node', 'irb', 'ghci', 'lua', 'R', 'ruby'].includes(base) && args.length === 0) {
159
+ return `Interactive command: '${base}' without arguments starts an interactive REPL. Provide a script file or use '-c'/'--eval' for inline code.`;
160
+ }
161
+
162
+ // ── Database clients without query flag ──
163
+ if (base === 'mysql' && !args.some(a => a === '-e' || a.startsWith('--execute'))) {
164
+ return "Interactive command: 'mysql' without -e flag starts an interactive session. Use 'mysql -e \"SQL QUERY\"' instead.";
165
+ }
166
+ if (base === 'psql' && !args.some(a => a === '-c' || a.startsWith('--command') || a === '-f' || a.startsWith('--file'))) {
167
+ return "Interactive command: 'psql' without -c flag starts an interactive session. Use 'psql -c \"SQL QUERY\"' instead.";
168
+ }
169
+
170
+ // ── Interactive TUI tools ──
171
+ if (['top', 'htop', 'btop', 'nmon'].includes(base)) {
172
+ return `Interactive command: '${base}' is an interactive TUI tool. Use 'ps aux' or 'top -b -n 1' for non-interactive process listing.`;
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Check if a command (simple or complex) would require interactive TTY input.
180
+ * For complex commands (with &&, ||, |, ;), checks each component individually.
181
+ *
182
+ * @param {string} command - Full command string
183
+ * @returns {string|null} Error message with suggestion for non-interactive alternative, or null if OK
184
+ */
185
+ export function checkInteractiveCommand(command) {
186
+ if (!command || typeof command !== 'string') return null;
187
+
188
+ const components = splitCommandComponents(command.trim());
189
+ for (const component of components) {
190
+ const result = checkSingleCommandInteractive(component);
191
+ if (result) return result;
192
+ }
193
+ return null;
194
+ }
195
+
11
196
  /**
12
197
  * Execute a bash command with security controls
13
198
  * @param {string} command - Command to execute
@@ -50,6 +235,26 @@ export async function executeBashCommand(command, options = {}) {
50
235
 
51
236
  const startTime = Date.now();
52
237
 
238
+ // Check for interactive commands that would hang without a TTY
239
+ const interactiveError = checkInteractiveCommand(command);
240
+ if (interactiveError) {
241
+ if (debug) {
242
+ console.log(`[BashExecutor] Blocked interactive command: "${command}"`);
243
+ console.log(`[BashExecutor] Reason: ${interactiveError}`);
244
+ }
245
+ return {
246
+ success: false,
247
+ error: interactiveError,
248
+ stdout: '',
249
+ stderr: interactiveError,
250
+ exitCode: 1,
251
+ command,
252
+ workingDirectory: cwd,
253
+ duration: 0,
254
+ interactive: true
255
+ };
256
+ }
257
+
53
258
  if (debug) {
54
259
  console.log(`[BashExecutor] Executing command: "${command}"`);
55
260
  console.log(`[BashExecutor] Working directory: "${cwd}"`);
@@ -57,11 +262,16 @@ export async function executeBashCommand(command, options = {}) {
57
262
  }
58
263
 
59
264
  return new Promise((resolve, reject) => {
60
- // Create environment
265
+ // Create environment with non-interactive safety defaults.
266
+ // These prevent commands from opening editors or TTY prompts
267
+ // when stdin is not available (which would cause hangs).
61
268
  const processEnv = {
62
269
  ...process.env,
63
270
  ...env
64
271
  };
272
+ // Only set defaults if not already provided by user config
273
+ if (!processEnv.GIT_EDITOR) processEnv.GIT_EDITOR = 'true';
274
+ if (!processEnv.GIT_TERMINAL_PROMPT) processEnv.GIT_TERMINAL_PROMPT = '0';
65
275
 
66
276
  // Check if this is a complex command (contains pipes, operators, etc.)
67
277
  const isComplex = isComplexCommand(command);
@@ -97,12 +307,17 @@ export async function executeBashCommand(command, options = {}) {
97
307
  useShell = false;
98
308
  }
99
309
 
100
- // Spawn the process
310
+ // Spawn the process in a new session (detached: true → setsid on Linux).
311
+ // This detaches the child from the parent's controlling terminal, making
312
+ // /dev/tty unavailable. Any program that tries to open an interactive
313
+ // editor or TTY prompt (e.g. vim from git rebase) will get ENXIO and
314
+ // fail immediately instead of hanging forever.
101
315
  const child = spawn(cmd, cmdArgs, {
102
316
  cwd,
103
317
  env: processEnv,
104
318
  stdio: ['ignore', 'pipe', 'pipe'], // stdin ignored, capture stdout/stderr
105
319
  shell: useShell, // false for security
320
+ detached: true, // new session — no controlling terminal
106
321
  windowsHide: true
107
322
  });
108
323
 
@@ -111,17 +326,28 @@ export async function executeBashCommand(command, options = {}) {
111
326
  let killed = false;
112
327
  let timeoutHandle;
113
328
 
329
+ // Helper: kill the entire process group (negative PID) so that
330
+ // sub-processes spawned by the command (e.g. an editor) are also killed.
331
+ // Falls back to killing just the child if process.kill fails.
332
+ const killProcessGroup = (signal) => {
333
+ try {
334
+ if (child.pid) process.kill(-child.pid, signal);
335
+ } catch {
336
+ try { child.kill(signal); } catch { /* already dead */ }
337
+ }
338
+ };
339
+
114
340
  // Set timeout
115
341
  if (timeout > 0) {
116
342
  timeoutHandle = setTimeout(() => {
117
343
  if (!killed) {
118
344
  killed = true;
119
- child.kill('SIGTERM');
120
-
345
+ killProcessGroup('SIGTERM');
346
+
121
347
  // Force kill after 5 seconds if still running
122
348
  setTimeout(() => {
123
349
  if (child.exitCode === null) {
124
- child.kill('SIGKILL');
350
+ killProcessGroup('SIGKILL');
125
351
  }
126
352
  }, 5000);
127
353
  }
@@ -137,7 +363,7 @@ export async function executeBashCommand(command, options = {}) {
137
363
  // Buffer overflow
138
364
  if (!killed) {
139
365
  killed = true;
140
- child.kill('SIGTERM');
366
+ killProcessGroup('SIGTERM');
141
367
  }
142
368
  }
143
369
  });
@@ -151,7 +377,7 @@ export async function executeBashCommand(command, options = {}) {
151
377
  // Buffer overflow
152
378
  if (!killed) {
153
379
  killed = true;
154
- child.kill('SIGTERM');
380
+ killProcessGroup('SIGTERM');
155
381
  }
156
382
  }
157
383
  });