@hcu-lab.me/mcp-device 1.0.8 → 1.0.10

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,224 @@
1
+ import * as handlers from './handlers/index.js';
2
+ import { getConfig, setConfigValue } from './tools/config.js';
3
+ import { getUsageStats } from './tools/usage.js';
4
+ import { getPrompts } from './tools/prompts.js';
5
+ import { trackToolCall } from './utils/trackTools.js';
6
+ import { usageTracker } from './utils/usageTracker.js';
7
+ import { toolHistory } from './utils/toolHistory.js';
8
+ import { capture } from './utils/capture.js';
9
+ import { currentCallIsRemote, setCurrentCallIsRemote, setCurrentRemoteClient, } from './server.js';
10
+ export async function dispatchToolCall(name, args, options = {}) {
11
+ const startTime = Date.now();
12
+ let telemetryData = { tool_name: name };
13
+ let result;
14
+ let isError = false;
15
+ try {
16
+ const isRemoteCall = Boolean(options.isRemote);
17
+ setCurrentCallIsRemote(isRemoteCall);
18
+ if (isRemoteCall) {
19
+ telemetryData.remote = String(options.isRemote);
20
+ const remoteClient = options.clientInfo && (options.clientInfo.name || options.clientInfo.version)
21
+ ? options.clientInfo
22
+ : { name: 'remote-unknown', version: 'unknown' };
23
+ setCurrentRemoteClient(remoteClient);
24
+ telemetryData.client_name = remoteClient.name;
25
+ telemetryData.client_version = remoteClient.version;
26
+ }
27
+ else {
28
+ setCurrentRemoteClient(null);
29
+ }
30
+ if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
31
+ telemetryData.set_config_value_key_name = args.key;
32
+ }
33
+ if (name === 'get_prompts' && args && typeof args === 'object') {
34
+ const promptArgs = args;
35
+ telemetryData.action = promptArgs.action;
36
+ if (promptArgs.category) {
37
+ telemetryData.category = promptArgs.category;
38
+ telemetryData.has_category_filter = true;
39
+ }
40
+ if (promptArgs.promptId) {
41
+ telemetryData.prompt_id = promptArgs.promptId;
42
+ }
43
+ }
44
+ trackToolCall(name, args);
45
+ switch (name) {
46
+ case 'get_config':
47
+ try {
48
+ result = await getConfig();
49
+ }
50
+ catch (error) {
51
+ capture('server_request_error', { message: `Error in get_config handler: ${error}` });
52
+ result = {
53
+ content: [{ type: 'text', text: 'Error: Failed to get configuration' }],
54
+ isError: true,
55
+ };
56
+ }
57
+ break;
58
+ case 'set_config_value':
59
+ try {
60
+ result = await setConfigValue(args);
61
+ }
62
+ catch (error) {
63
+ capture('server_request_error', { message: `Error in set_config_value handler: ${error}` });
64
+ result = {
65
+ content: [{ type: 'text', text: 'Error: Failed to set configuration value' }],
66
+ isError: true,
67
+ };
68
+ }
69
+ break;
70
+ case 'get_usage_stats':
71
+ try {
72
+ result = await getUsageStats();
73
+ }
74
+ catch (error) {
75
+ capture('server_request_error', { message: `Error in get_usage_stats handler: ${error}` });
76
+ result = {
77
+ content: [{ type: 'text', text: 'Error: Failed to get usage statistics' }],
78
+ isError: true,
79
+ };
80
+ }
81
+ break;
82
+ case 'get_prompts':
83
+ try {
84
+ result = await getPrompts(args || {});
85
+ if (args && typeof args === 'object' && !result.isError) {
86
+ const action = args.action;
87
+ try {
88
+ if (action === 'get_prompt' && args.promptId) {
89
+ const { loadPromptsData } = await import('./tools/prompts.js');
90
+ const promptsData = await loadPromptsData();
91
+ const prompt = promptsData.prompts.find(p => p.id === args.promptId);
92
+ if (prompt) {
93
+ await capture('server_get_prompt', {
94
+ prompt_id: prompt.id,
95
+ prompt_title: prompt.title,
96
+ category: prompt.categories[0] || 'uncategorized',
97
+ author: prompt.author,
98
+ verified: prompt.verified,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ catch (error) { }
104
+ }
105
+ const onboardingState = await usageTracker.getOnboardingState();
106
+ if (onboardingState.attemptsShown > 0 && !onboardingState.promptsUsed) {
107
+ await usageTracker.markOnboardingPromptsUsed();
108
+ }
109
+ }
110
+ catch (error) {
111
+ capture('server_request_error', { message: `Error in get_prompts handler: ${error}` });
112
+ result = {
113
+ content: [{ type: 'text', text: 'Error: Failed to retrieve prompts' }],
114
+ isError: true,
115
+ };
116
+ }
117
+ break;
118
+ case 'get_recent_tool_calls':
119
+ try {
120
+ result = await handlers.handleGetRecentToolCalls(args);
121
+ }
122
+ catch (error) {
123
+ capture('server_request_error', { message: `Error in get_recent_tool_calls handler: ${error}` });
124
+ result = {
125
+ content: [{ type: 'text', text: 'Error: Failed to get tool call history' }],
126
+ isError: true,
127
+ };
128
+ }
129
+ break;
130
+ case 'track_ui_event':
131
+ try {
132
+ result = await handlers.handleTrackUiEvent(args);
133
+ }
134
+ catch (error) {
135
+ capture('server_request_error', { message: `Error in track_ui_event handler: ${error}` });
136
+ result = {
137
+ content: [{ type: 'text', text: 'Error: Failed to track UI event' }],
138
+ isError: true,
139
+ };
140
+ }
141
+ break;
142
+ case 'start_process':
143
+ result = await handlers.handleStartProcess(args);
144
+ break;
145
+ case 'read_process_output':
146
+ result = await handlers.handleReadProcessOutput(args);
147
+ break;
148
+ case 'interact_with_process':
149
+ result = await handlers.handleInteractWithProcess(args);
150
+ break;
151
+ case 'force_terminate':
152
+ result = await handlers.handleForceTerminate(args);
153
+ break;
154
+ case 'list_sessions':
155
+ result = await handlers.handleListSessions();
156
+ break;
157
+ case 'list_processes':
158
+ result = await handlers.handleListProcesses();
159
+ break;
160
+ case 'kill_process':
161
+ result = await handlers.handleKillProcess(args);
162
+ break;
163
+ case 'read_file':
164
+ result = await handlers.handleReadFile(args);
165
+ break;
166
+ case 'read_multiple_files':
167
+ result = await handlers.handleReadMultipleFiles(args);
168
+ break;
169
+ case 'write_file':
170
+ result = await handlers.handleWriteFile(args);
171
+ break;
172
+ case 'write_pdf':
173
+ result = await handlers.handleWritePdf(args);
174
+ break;
175
+ case 'create_directory':
176
+ result = await handlers.handleCreateDirectory(args);
177
+ break;
178
+ case 'list_directory':
179
+ result = await handlers.handleListDirectory(args);
180
+ break;
181
+ case 'move_file':
182
+ result = await handlers.handleMoveFile(args);
183
+ break;
184
+ case 'start_search':
185
+ result = await handlers.handleStartSearch(args);
186
+ break;
187
+ case 'get_more_search_results':
188
+ result = await handlers.handleGetMoreSearchResults(args);
189
+ break;
190
+ case 'stop_search':
191
+ result = await handlers.handleStopSearch(args);
192
+ break;
193
+ case 'list_searches':
194
+ result = await handlers.handleListSearches();
195
+ break;
196
+ case 'get_file_info':
197
+ result = await handlers.handleGetFileInfo(args);
198
+ break;
199
+ case 'edit_block':
200
+ result = await handlers.handleEditBlock(args);
201
+ break;
202
+ default:
203
+ capture('server_unknown_tool', { name });
204
+ result = {
205
+ content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }],
206
+ isError: true,
207
+ };
208
+ }
209
+ const duration = Date.now() - startTime;
210
+ isError = !!result.isError;
211
+ const EXCLUDED_TOOLS = ['get_recent_tool_calls', 'track_ui_event'];
212
+ if (!currentCallIsRemote && process.env.MCP_DEVICE_REMOTE !== 'true' && !EXCLUDED_TOOLS.includes(name)) {
213
+ toolHistory.addCall(name, args, result, duration);
214
+ }
215
+ return result;
216
+ }
217
+ catch (error) {
218
+ capture('server_unhandled_tool_error', { name, error: error?.message || String(error) });
219
+ return {
220
+ content: [{ type: 'text', text: `Error executing ${name}: ${error?.message || String(error)}` }],
221
+ isError: true,
222
+ };
223
+ }
224
+ }
@@ -82,7 +82,26 @@ function getCharacterCodeData(expected, actual) {
82
82
  diffLength: fullDiff.length
83
83
  };
84
84
  }
85
- export async function performSearchReplace(filePath, block, expectedReplacements = 1, origin) {
85
+ const editChains = new Map();
86
+ async function withFileEditLock(filePath, operation) {
87
+ const resolved = path.resolve(filePath);
88
+ const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
89
+ const previous = editChains.get(key) ?? Promise.resolve();
90
+ let release;
91
+ const current = new Promise(resolve => { release = resolve; });
92
+ const tail = previous.then(() => current);
93
+ editChains.set(key, tail);
94
+ await previous;
95
+ try {
96
+ return await operation();
97
+ }
98
+ finally {
99
+ release();
100
+ if (editChains.get(key) === tail)
101
+ editChains.delete(key);
102
+ }
103
+ }
104
+ async function performSearchReplaceUnlocked(filePath, block, expectedReplacements = 1, origin) {
86
105
  // Get file extension for telemetry using path module
87
106
  const fileExtension = path.extname(filePath).toLowerCase();
88
107
  // Capture file extension and string sizes in telemetry without capturing the file path
@@ -288,6 +307,49 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
288
307
  }
289
308
  throw new Error("Unexpected error during search and replace operation.");
290
309
  }
310
+ export async function performSearchReplace(filePath, block, expectedReplacements = 1, origin) {
311
+ return withFileEditLock(filePath, () => performSearchReplaceUnlocked(filePath, block, expectedReplacements, origin));
312
+ }
313
+ async function performExactBatch(filePath, edits, origin) {
314
+ return withFileEditLock(filePath, async () => {
315
+ const validPath = await validatePath(filePath);
316
+ const original = await readFileInternal(validPath, 0, Number.MAX_SAFE_INTEGER);
317
+ const fileLineEnding = detectLineEnding(original);
318
+ let next = original;
319
+ for (const edit of edits) {
320
+ const search = normalizeLineEndings(edit.old_string, fileLineEnding);
321
+ const replacement = normalizeLineEndings(edit.new_string, fileLineEnding);
322
+ let count = 0;
323
+ let pos = next.indexOf(search);
324
+ while (pos !== -1) {
325
+ count++;
326
+ pos = next.indexOf(search, pos + Math.max(1, search.length));
327
+ }
328
+ if (count !== edit.expected_replacements) {
329
+ return createErrorResponse(`Expected ${edit.expected_replacements} occurrences but found ${count} for batched edit in ${filePath}. No changes were written.`);
330
+ }
331
+ next = count === 1
332
+ ? next.replace(search, replacement)
333
+ : next.split(search).join(replacement);
334
+ }
335
+ await writeFile(filePath, next);
336
+ const resolvedEditPath = resolveAbsolutePath(filePath);
337
+ return {
338
+ content: [{
339
+ type: "text",
340
+ text: `Successfully applied ${edits.length} exact edit(s) to ${filePath}`
341
+ }],
342
+ ...(origin === 'ui' ? {
343
+ structuredContent: {
344
+ fileName: path.basename(resolvedEditPath),
345
+ filePath: resolvedEditPath,
346
+ fileType: resolvePreviewFileType(resolvedEditPath),
347
+ ...await getDefaultEditorMetadata(resolvedEditPath),
348
+ },
349
+ } : {}),
350
+ };
351
+ });
352
+ }
291
353
  /**
292
354
  * Generates a character-level diff using standard {-removed-}{+added+} format
293
355
  * @param expected The string that was searched for
@@ -350,6 +412,15 @@ export async function handleEditBlock(args) {
350
412
  return createErrorResponse(errorMessage);
351
413
  }
352
414
  const hasEditRange = 'editRange' in handler && typeof handler.editRange === 'function';
415
+ // Compact hot path: multiple exact text replacements share one read/write.
416
+ // Structured handlers keep their existing editRange behavior and do not
417
+ // opt into this text-only batch contract.
418
+ if (parsed.edits) {
419
+ if (hasEditRange) {
420
+ return createErrorResponse(`Batched exact text edits are not supported for structured file ${parsed.file_path}; use the existing range/text edit contract for that file type.`);
421
+ }
422
+ return performExactBatch(parsed.file_path, parsed.edits, parsed.origin);
423
+ }
353
424
  // Path 1: Range rewrite (Excel, etc.) — range + content
354
425
  if (hasRange && hasContent) {
355
426
  // Parse content if it's a JSON string (AI often sends arrays as JSON strings)
@@ -57,7 +57,7 @@ export interface MultiFileResult {
57
57
  isPdf?: boolean;
58
58
  payload?: FileResultPayloads;
59
59
  }
60
- export declare function readMultipleFiles(paths: string[]): Promise<MultiFileResult[]>;
60
+ export declare function readMultipleFiles(paths: string[], options?: ReadOptions): Promise<MultiFileResult[]>;
61
61
  export declare function createDirectory(dirPath: string): Promise<void>;
62
62
  export declare function listDirectory(dirPath: string, depth?: number): Promise<string[]>;
63
63
  export declare function moveFile(sourcePath: string, destinationPath: string): Promise<void>;
@@ -563,11 +563,11 @@ export async function writeFile(filePath, content, mode = 'rewrite') {
563
563
  // Use handler to write the file
564
564
  await handler.write(validPath, content, mode);
565
565
  }
566
- export async function readMultipleFiles(paths) {
566
+ export async function readMultipleFiles(paths, options) {
567
567
  return Promise.all(paths.map(async (filePath) => {
568
568
  try {
569
569
  const validPath = await validatePath(filePath);
570
- const fileResult = await readFile(validPath);
570
+ const fileResult = await readFile(validPath, options);
571
571
  // Handle content conversion properly for images vs text
572
572
  let content;
573
573
  if (typeof fileResult.content === 'string') {
@@ -17,6 +17,24 @@ const mcpRoot = path.resolve(__dirname, '..', '..');
17
17
  // Track virtual Node sessions (PIDs that are actually Node fallback sessions)
18
18
  const virtualNodeSessions = new Map();
19
19
  let virtualPidCounter = -1000; // Use negative PIDs for virtual sessions
20
+ const processOperationTails = new Map();
21
+ async function acquireProcessOperation(pid) {
22
+ const previous = processOperationTails.get(pid) ?? Promise.resolve();
23
+ let release;
24
+ const current = new Promise((resolve) => { release = resolve; });
25
+ const tail = previous.catch(() => { }).then(() => current);
26
+ processOperationTails.set(pid, tail);
27
+ await previous.catch(() => { });
28
+ return () => {
29
+ release();
30
+ if (processOperationTails.get(pid) === tail) {
31
+ tail.finally(() => {
32
+ if (processOperationTails.get(pid) === tail)
33
+ processOperationTails.delete(pid);
34
+ });
35
+ }
36
+ };
37
+ }
20
38
  /**
21
39
  * Execute Node.js code via temp file (fallback when Python unavailable)
22
40
  * Creates temp .mjs file in MCP directory for ES module import access
@@ -152,13 +170,18 @@ export async function startProcess(args) {
152
170
  }
153
171
  }
154
172
  }
155
- const result = await terminalManager.executeCommand(commandToRun, parsed.data.timeout_ms, shellUsed, parsed.data.verbose_timing || false, workingDirectory);
173
+ const result = await terminalManager.executeCommand(commandToRun, parsed.data.background ? 0 : (parsed.data.yield_ms ?? parsed.data.timeout_ms), shellUsed, parsed.data.verbose_timing || false, workingDirectory);
156
174
  if (result.pid === -1) {
157
175
  return {
158
176
  content: [{ type: "text", text: result.output }],
159
177
  isError: true,
160
178
  };
161
179
  }
180
+ // Initial output returned here is already delivered to the consuming caller.
181
+ // Note: Only mark consumed for active running processes; completed processes retain baseline replayability.
182
+ if (terminalManager.getSession(result.pid)) {
183
+ terminalManager.markOutputConsumed(result.pid, result.deliveredCursor);
184
+ }
162
185
  // Analyze the process state to detect if it's waiting for input
163
186
  const processState = analyzeProcessState(result.output, result.pid);
164
187
  let statusMessage = '';
@@ -225,48 +248,15 @@ export async function readProcessOutput(args) {
225
248
  verbose_timing = false } = parsed.data;
226
249
  // Timing telemetry
227
250
  const startTime = Date.now();
228
- // For active sessions with no new output yet, optionally wait for output
251
+ // For active sessions with no unread bytes yet, wait on output/completion progress.
229
252
  const session = terminalManager.getSession(pid);
230
253
  if (session && offset === 0) {
231
- // Wait for new output to arrive (only for "new output" reads, not absolute/tail)
232
- const waitForOutput = () => {
233
- return new Promise((resolve) => {
234
- // Check if there's already new output
235
- const currentLines = terminalManager.getOutputLineCount(pid) || 0;
236
- if (currentLines > session.lastReadIndex) {
237
- resolve();
238
- return;
239
- }
240
- let resolved = false;
241
- let interval = null;
242
- let timeout = null;
243
- const cleanup = () => {
244
- if (interval)
245
- clearInterval(interval);
246
- if (timeout)
247
- clearTimeout(timeout);
248
- };
249
- const resolveOnce = () => {
250
- if (resolved)
251
- return;
252
- resolved = true;
253
- cleanup();
254
- resolve();
255
- };
256
- // Poll for new output
257
- interval = setInterval(() => {
258
- const newLineCount = terminalManager.getOutputLineCount(pid) || 0;
259
- if (newLineCount > session.lastReadIndex) {
260
- resolveOnce();
261
- }
262
- }, 50);
263
- // Timeout
264
- timeout = setTimeout(() => {
265
- resolveOnce();
266
- }, timeout_ms);
267
- });
268
- };
269
- await waitForOutput();
254
+ const currentLine = session.outputLines[session.lastReadIndex];
255
+ const hasUnread = currentLine !== undefined && session.lastReadOffset < currentLine.length
256
+ || session.outputLines.length - 1 > session.lastReadIndex;
257
+ if (!hasUnread) {
258
+ await terminalManager.waitForOutputProgress(pid, session.outputVersion, timeout_ms);
259
+ }
270
260
  }
271
261
  // Read output with pagination
272
262
  const result = terminalManager.readOutputPaginated(pid, offset, length);
@@ -313,9 +303,8 @@ export async function readProcessOutput(args) {
313
303
  processStateMessage = `\n✅ Process completed with exit code ${result.exitCode}${runtimeStr}`;
314
304
  }
315
305
  else if (session) {
316
- // Analyze state for running processes
317
- const fullOutput = session.outputLines.join('\n');
318
- const processState = analyzeProcessState(fullOutput, pid);
306
+ // Analyze only the newly returned/bounded output; never rescan retained history here.
307
+ const processState = analyzeProcessState(output, pid);
319
308
  if (processState.isWaitingForInput) {
320
309
  processStateMessage = `\n🔄 ${formatProcessStateMessage(processState, pid)}`;
321
310
  }
@@ -350,9 +339,12 @@ export async function interactWithProcess(args) {
350
339
  };
351
340
  }
352
341
  const { pid, input, timeout_ms = 8000, wait_for_prompt = true, verbose_timing = false } = parsed.data;
353
- // Get config for output line limit
342
+ // Get config for output line limit and cap a single blocking interaction below client ceilings.
354
343
  const config = await configManager.getConfig();
355
344
  const maxOutputLines = config.fileReadLineLimit ?? 1000;
345
+ const configuredWaitCap = Number(await configManager.getValue('maxProcessWaitMs'));
346
+ const waitCapMs = Number.isFinite(configuredWaitCap) && configuredWaitCap > 0 ? configuredWaitCap : 180000;
347
+ const effectiveTimeoutMs = Math.min(Math.max(0, timeout_ms), waitCapMs);
356
348
  // Check if this is a virtual Node session (node:local)
357
349
  if (virtualNodeSessions.has(pid)) {
358
350
  const session = virtualNodeSessions.get(pid);
@@ -371,6 +363,7 @@ export async function interactWithProcess(args) {
371
363
  let lastOutputTime;
372
364
  const outputEvents = [];
373
365
  let exitReason = 'timeout';
366
+ const releaseOperation = await acquireProcessOperation(pid);
374
367
  try {
375
368
  capture('server_interact_with_process', {
376
369
  pid: pid,
@@ -417,72 +410,55 @@ export async function interactWithProcess(args) {
417
410
  let earlyExit = false;
418
411
  // Quick prompt patterns for immediate detection
419
412
  const quickPromptPatterns = />>>\s*$|>\s*$|\$\s*$|#\s*$/;
420
- const waitForResponse = () => {
421
- return new Promise((resolve) => {
422
- let resolved = false;
423
- let attempts = 0;
424
- const pollIntervalMs = 50; // Poll every 50ms for faster response
425
- const maxAttempts = Math.ceil(timeout_ms / pollIntervalMs);
426
- let interval = null;
427
- let lastOutputLength = 0; // Track output length to detect new output
428
- let resolveOnce = () => {
429
- if (resolved)
430
- return;
431
- resolved = true;
432
- if (interval)
433
- clearInterval(interval);
434
- resolve();
435
- };
436
- // Fast-polling check - check every 50ms for quick responses
437
- interval = setInterval(() => {
438
- if (resolved)
439
- return;
440
- // Use snapshot-based reading to handle REPL prompt line appending
441
- const newOutput = outputSnapshot
442
- ? terminalManager.getOutputSinceSnapshot(pid, outputSnapshot)
443
- : terminalManager.getNewOutput(pid);
444
- if (newOutput && newOutput.length > lastOutputLength) {
445
- const now = Date.now();
446
- if (!firstOutputTime)
447
- firstOutputTime = now;
448
- lastOutputTime = now;
449
- if (verbose_timing) {
450
- outputEvents.push({
451
- timestamp: now,
452
- deltaMs: now - startTime,
453
- source: 'periodic_poll',
454
- length: newOutput.length - lastOutputLength,
455
- snippet: newOutput.slice(lastOutputLength, lastOutputLength + 50).replace(/\n/g, '\\n')
456
- });
457
- }
458
- output = newOutput; // Replace with full output since snapshot
459
- lastOutputLength = newOutput.length;
460
- // Analyze current state
461
- processState = analyzeProcessState(output, pid);
462
- // Exit early if we detect the process is waiting for input
463
- if (processState.isWaitingForInput) {
464
- earlyExit = true;
465
- exitReason = 'early_exit_periodic_check';
466
- if (verbose_timing && outputEvents.length > 0) {
467
- outputEvents[outputEvents.length - 1].matchedPattern = 'periodic_check';
468
- }
469
- resolveOnce();
470
- return;
471
- }
472
- // Also exit if process finished
473
- if (processState.isFinished) {
474
- exitReason = 'process_finished';
475
- resolveOnce();
476
- return;
413
+ const waitForResponse = async () => {
414
+ const deadline = Date.now() + effectiveTimeoutMs;
415
+ let lastOutputLength = 0;
416
+ while (Date.now() < deadline) {
417
+ const active = terminalManager.getSession(pid);
418
+ const version = active?.outputVersion ?? -1;
419
+ const newOutput = outputSnapshot
420
+ ? terminalManager.getOutputSinceSnapshot(pid, outputSnapshot)
421
+ : terminalManager.getNewOutput(pid);
422
+ if (newOutput && newOutput.length > lastOutputLength) {
423
+ const now = Date.now();
424
+ if (!firstOutputTime)
425
+ firstOutputTime = now;
426
+ lastOutputTime = now;
427
+ if (verbose_timing) {
428
+ outputEvents.push({
429
+ timestamp: now,
430
+ deltaMs: now - startTime,
431
+ source: 'output_event',
432
+ length: newOutput.length - lastOutputLength,
433
+ snippet: newOutput.slice(lastOutputLength, lastOutputLength + 50).replace(/\n/g, '\\n')
434
+ });
435
+ }
436
+ output = newOutput;
437
+ lastOutputLength = newOutput.length;
438
+ processState = analyzeProcessState(output, pid);
439
+ if (processState.isWaitingForInput) {
440
+ earlyExit = true;
441
+ exitReason = 'early_exit_periodic_check';
442
+ if (verbose_timing && outputEvents.length > 0) {
443
+ outputEvents[outputEvents.length - 1].matchedPattern = 'output_event';
477
444
  }
445
+ return;
478
446
  }
479
- attempts++;
480
- if (attempts >= maxAttempts) {
481
- exitReason = 'timeout';
482
- resolveOnce();
447
+ if (processState.isFinished) {
448
+ exitReason = 'process_finished';
449
+ return;
483
450
  }
484
- }, pollIntervalMs);
485
- });
451
+ }
452
+ const remaining = deadline - Date.now();
453
+ if (remaining <= 0)
454
+ break;
455
+ if (!active) {
456
+ exitReason = 'process_finished';
457
+ return;
458
+ }
459
+ await terminalManager.waitForOutputProgress(pid, version, remaining);
460
+ }
461
+ exitReason = 'timeout';
486
462
  };
487
463
  await waitForResponse();
488
464
  // Clean and format output
@@ -569,6 +545,9 @@ export async function interactWithProcess(args) {
569
545
  isError: true,
570
546
  };
571
547
  }
548
+ finally {
549
+ releaseOperation();
550
+ }
572
551
  }
573
552
  /**
574
553
  * Force terminate a process