@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.
- package/README.md +1 -1
- package/dist/device/device.d.ts +1 -0
- package/dist/device/device.js +13 -4
- package/dist/device/gateway-channel.d.ts +1 -0
- package/dist/device/gateway-channel.js +20 -5
- package/dist/device/gateway-tool-adapter.d.ts +2 -3
- package/dist/device/gateway-tool-adapter.js +121 -97
- package/dist/handlers/filesystem-handlers.js +53 -37
- package/dist/handlers/search-handlers.d.ts +1 -0
- package/dist/handlers/search-handlers.js +51 -1
- package/dist/mcp-device.d.ts +1 -1
- package/dist/mcp-device.js +1 -1
- package/dist/search-manager.d.ts +6 -0
- package/dist/search-manager.js +123 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +2 -2
- package/dist/terminal-manager.d.ts +9 -0
- package/dist/terminal-manager.js +127 -42
- package/dist/tool-dispatcher.d.ts +11 -0
- package/dist/tool-dispatcher.js +224 -0
- package/dist/tools/edit.js +72 -1
- package/dist/tools/filesystem.d.ts +1 -1
- package/dist/tools/filesystem.js +2 -2
- package/dist/tools/improved-process-tools.js +87 -108
- package/dist/tools/schemas.d.ts +94 -2
- package/dist/tools/schemas.js +20 -6
- package/dist/types.d.ts +7 -0
- package/dist/utils/output-budget.d.ts +6 -0
- package/dist/utils/output-budget.js +15 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/tools/edit.js
CHANGED
|
@@ -82,7 +82,26 @@ function getCharacterCodeData(expected, actual) {
|
|
|
82
82
|
diffLength: fullDiff.length
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
|
-
|
|
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>;
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
317
|
-
const
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (verbose_timing) {
|
|
450
|
-
outputEvents.
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
resolveOnce();
|
|
447
|
+
if (processState.isFinished) {
|
|
448
|
+
exitReason = 'process_finished';
|
|
449
|
+
return;
|
|
483
450
|
}
|
|
484
|
-
}
|
|
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
|