@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { searchManager } from '../search-manager.js';
|
|
2
|
-
import { StartSearchArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema } from '../tools/schemas.js';
|
|
2
|
+
import { StartSearchArgsSchema, SearchOnceArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema } from '../tools/schemas.js';
|
|
3
3
|
import { capture } from '../utils/capture.js';
|
|
4
|
+
import { takeUtf8Budget } from '../utils/output-budget.js';
|
|
4
5
|
/**
|
|
5
6
|
* Handle start_search command
|
|
6
7
|
*/
|
|
@@ -66,6 +67,55 @@ export async function handleStartSearch(args) {
|
|
|
66
67
|
};
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
export async function handleSearchOnce(args) {
|
|
71
|
+
const parsed = SearchOnceArgsSchema.safeParse(args);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: `Invalid arguments for search_once: ${parsed.error}` }],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const maxResults = parsed.data.maxResults ?? 100;
|
|
80
|
+
const result = await searchManager.searchOnce({
|
|
81
|
+
rootPath: parsed.data.path,
|
|
82
|
+
pattern: parsed.data.pattern,
|
|
83
|
+
searchType: parsed.data.searchType,
|
|
84
|
+
filePattern: parsed.data.filePattern,
|
|
85
|
+
ignoreCase: parsed.data.ignoreCase,
|
|
86
|
+
maxResults,
|
|
87
|
+
includeHidden: parsed.data.includeHidden,
|
|
88
|
+
contextLines: parsed.data.contextLines,
|
|
89
|
+
timeout: parsed.data.timeout_ms,
|
|
90
|
+
earlyTermination: parsed.data.earlyTermination,
|
|
91
|
+
literalSearch: parsed.data.literalSearch,
|
|
92
|
+
}, maxResults, parsed.data.maxBytes);
|
|
93
|
+
let body = '';
|
|
94
|
+
for (const item of result.results) {
|
|
95
|
+
body += item.type === 'content'
|
|
96
|
+
? `${item.file}:${item.line ?? 0} - ${item.match ?? ''}\n`
|
|
97
|
+
: `${item.file}\n`;
|
|
98
|
+
}
|
|
99
|
+
if (!body)
|
|
100
|
+
body = 'No matches found.\n';
|
|
101
|
+
const status = `[search_once truncated=${result.truncated} timedOut=${result.timedOut} results=${result.results.length} runtimeMs=${result.runtime}]`;
|
|
102
|
+
const bodyBudget = Math.max(0, parsed.data.maxBytes - Buffer.byteLength(status, 'utf8') - 1);
|
|
103
|
+
const bounded = takeUtf8Budget(body, bodyBudget);
|
|
104
|
+
const truncated = result.truncated || bounded.truncated;
|
|
105
|
+
const finalStatus = `[search_once truncated=${truncated} timedOut=${result.timedOut} results=${result.results.length} runtimeMs=${result.runtime}]`;
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: `${bounded.text}${finalStatus}` }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
112
|
+
capture('search_once_error', { error: errorMessage });
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: `Error running one-shot search: ${errorMessage}` }],
|
|
115
|
+
isError: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
69
119
|
/**
|
|
70
120
|
* Handle get_more_search_results command
|
|
71
121
|
*/
|
package/dist/mcp-device.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import './bootstrap.js';
|
package/dist/mcp-device.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import './bootstrap.js';
|
|
2
3
|
// MCP Device publishes only the authenticated device runtime surface.
|
|
3
4
|
if (process.argv[2] !== 'remote')
|
|
4
5
|
process.argv.splice(2, 0, 'remote');
|
|
5
6
|
const { runRemote } = await import('./npm-scripts/remote.js');
|
|
6
7
|
await runRemote();
|
|
7
|
-
export {};
|
package/dist/search-manager.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface SearchSessionOptions {
|
|
|
51
51
|
totalResults: number;
|
|
52
52
|
runtime: number;
|
|
53
53
|
}>;
|
|
54
|
+
searchOnce(options: SearchSessionOptions, maxResults?: number, maxBytes?: number): Promise<{
|
|
55
|
+
results: SearchResult[];
|
|
56
|
+
runtime: number;
|
|
57
|
+
truncated: boolean;
|
|
58
|
+
timedOut: boolean;
|
|
59
|
+
}>;
|
|
54
60
|
/**
|
|
55
61
|
* Read search results with offset-based pagination (like read_file)
|
|
56
62
|
* Supports both range reading and tail behavior
|
package/dist/search-manager.js
CHANGED
|
@@ -142,6 +142,129 @@ import PizZip from 'pizzip';
|
|
|
142
142
|
runtime: Date.now() - session.startTime
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
|
+
async searchOnce(options, maxResults = 100, maxBytes = 256 * 1024) {
|
|
146
|
+
const startTime = Date.now();
|
|
147
|
+
const validPath = await validatePath(options.rootPath);
|
|
148
|
+
const args = this.buildRipgrepArgs({
|
|
149
|
+
...options,
|
|
150
|
+
rootPath: validPath,
|
|
151
|
+
maxResults: maxResults + 1,
|
|
152
|
+
});
|
|
153
|
+
const rgPath = await getRipgrepPath();
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const child = spawn(rgPath, args, { windowsHide: true });
|
|
156
|
+
const results = [];
|
|
157
|
+
const pendingContext = [];
|
|
158
|
+
let buffered = '';
|
|
159
|
+
let capturedBytes = 0;
|
|
160
|
+
let matchCount = 0;
|
|
161
|
+
let truncated = false;
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
let settled = false;
|
|
164
|
+
let timer;
|
|
165
|
+
const stopForTruncation = () => {
|
|
166
|
+
truncated = true;
|
|
167
|
+
if (!child.killed)
|
|
168
|
+
child.kill('SIGTERM');
|
|
169
|
+
};
|
|
170
|
+
const flushPendingContext = (reservedBytes = 0) => {
|
|
171
|
+
let dropped = false;
|
|
172
|
+
for (const item of pendingContext) {
|
|
173
|
+
if (capturedBytes + item.bytes + reservedBytes > maxBytes) {
|
|
174
|
+
dropped = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
results.push(item.result);
|
|
178
|
+
capturedBytes += item.bytes;
|
|
179
|
+
}
|
|
180
|
+
pendingContext.length = 0;
|
|
181
|
+
return dropped;
|
|
182
|
+
};
|
|
183
|
+
const finish = () => {
|
|
184
|
+
if (settled)
|
|
185
|
+
return;
|
|
186
|
+
if (pendingContext.length > 0 && matchCount > 0) {
|
|
187
|
+
if (flushPendingContext())
|
|
188
|
+
truncated = true;
|
|
189
|
+
}
|
|
190
|
+
settled = true;
|
|
191
|
+
if (timer)
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
resolve({
|
|
194
|
+
results,
|
|
195
|
+
runtime: Date.now() - startTime,
|
|
196
|
+
truncated,
|
|
197
|
+
timedOut,
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
const fail = (error) => {
|
|
201
|
+
if (settled)
|
|
202
|
+
return;
|
|
203
|
+
settled = true;
|
|
204
|
+
if (timer)
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
reject(error);
|
|
207
|
+
};
|
|
208
|
+
const acceptLine = (line) => {
|
|
209
|
+
if (!line.trim() || truncated)
|
|
210
|
+
return;
|
|
211
|
+
const result = this.parseLine(line, options.searchType);
|
|
212
|
+
if (!result)
|
|
213
|
+
return;
|
|
214
|
+
const resultBytes = Buffer.byteLength(`${result.file}\n${result.match ?? ''}\n`, 'utf8');
|
|
215
|
+
const isContext = options.searchType === 'content' && line.includes('"type":"context"');
|
|
216
|
+
if (isContext) {
|
|
217
|
+
pendingContext.push({ result, bytes: resultBytes });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (matchCount >= maxResults) {
|
|
221
|
+
pendingContext.length = 0;
|
|
222
|
+
stopForTruncation();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (capturedBytes + resultBytes > maxBytes) {
|
|
226
|
+
pendingContext.length = 0;
|
|
227
|
+
stopForTruncation();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const droppedContext = flushPendingContext(resultBytes);
|
|
231
|
+
results.push(result);
|
|
232
|
+
capturedBytes += resultBytes;
|
|
233
|
+
matchCount++;
|
|
234
|
+
if (droppedContext) {
|
|
235
|
+
stopForTruncation();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
child.stdout?.on('data', (data) => {
|
|
239
|
+
buffered += data.toString();
|
|
240
|
+
const lines = buffered.split(/\r?\n/);
|
|
241
|
+
buffered = lines.pop() ?? '';
|
|
242
|
+
for (const line of lines)
|
|
243
|
+
acceptLine(line);
|
|
244
|
+
});
|
|
245
|
+
let stderr = '';
|
|
246
|
+
child.stderr?.on('data', (data) => {
|
|
247
|
+
stderr += data.toString();
|
|
248
|
+
});
|
|
249
|
+
child.once('error', fail);
|
|
250
|
+
child.once('close', code => {
|
|
251
|
+
if (buffered)
|
|
252
|
+
acceptLine(buffered);
|
|
253
|
+
if (code && code !== 1 && !truncated && !timedOut) {
|
|
254
|
+
fail(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
finish();
|
|
258
|
+
});
|
|
259
|
+
const timeoutMs = options.timeout ?? 5000;
|
|
260
|
+
timer = setTimeout(() => {
|
|
261
|
+
timedOut = true;
|
|
262
|
+
truncated = true;
|
|
263
|
+
if (!child.killed)
|
|
264
|
+
child.kill('SIGTERM');
|
|
265
|
+
}, timeoutMs);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
145
268
|
/**
|
|
146
269
|
* Read search results with offset-based pagination (like read_file)
|
|
147
270
|
* Supports both range reading and tail behavior
|
package/dist/server.d.ts
CHANGED
|
@@ -39,8 +39,21 @@ declare let currentClient: {
|
|
|
39
39
|
version: string;
|
|
40
40
|
};
|
|
41
41
|
declare let currentCallIsRemote: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Set whether the current tool call is from a remote device.
|
|
44
|
+
* Called once per tool call by the CallTool handler.
|
|
45
|
+
*/
|
|
46
|
+
export declare function setCurrentCallIsRemote(isRemote: boolean): void;
|
|
42
47
|
declare let currentRemoteClient: {
|
|
43
48
|
name?: string;
|
|
44
49
|
version?: string;
|
|
45
50
|
} | null;
|
|
51
|
+
/**
|
|
52
|
+
* Set the remote caller's client for the current tool call (null when local).
|
|
53
|
+
* Called once per tool call by the CallTool handler.
|
|
54
|
+
*/
|
|
55
|
+
export declare function setCurrentRemoteClient(clientInfo: {
|
|
56
|
+
name?: string;
|
|
57
|
+
version?: string;
|
|
58
|
+
} | null): void;
|
|
46
59
|
export { currentClient, currentCallIsRemote, currentRemoteClient };
|
package/dist/server.js
CHANGED
|
@@ -83,7 +83,7 @@ let currentCallIsRemote = false;
|
|
|
83
83
|
* Set whether the current tool call is from a remote device.
|
|
84
84
|
* Called once per tool call by the CallTool handler.
|
|
85
85
|
*/
|
|
86
|
-
function setCurrentCallIsRemote(isRemote) {
|
|
86
|
+
export function setCurrentCallIsRemote(isRemote) {
|
|
87
87
|
currentCallIsRemote = isRemote;
|
|
88
88
|
}
|
|
89
89
|
// The remote caller's client for the in-flight tool call (e.g. openai-mcp,
|
|
@@ -96,7 +96,7 @@ let currentRemoteClient = null;
|
|
|
96
96
|
* Set the remote caller's client for the current tool call (null when local).
|
|
97
97
|
* Called once per tool call by the CallTool handler.
|
|
98
98
|
*/
|
|
99
|
-
function setCurrentRemoteClient(clientInfo) {
|
|
99
|
+
export function setCurrentRemoteClient(clientInfo) {
|
|
100
100
|
currentRemoteClient = clientInfo;
|
|
101
101
|
}
|
|
102
102
|
/**
|
|
@@ -7,6 +7,9 @@ interface CompletedSession {
|
|
|
7
7
|
endTime: Date;
|
|
8
8
|
evictedLines: number;
|
|
9
9
|
evictedChars: number;
|
|
10
|
+
lastReadIndex: number;
|
|
11
|
+
lastReadOffset: number;
|
|
12
|
+
bufferedChars: number;
|
|
10
13
|
}
|
|
11
14
|
/**
|
|
12
15
|
* Output buffering caps. Without a cap, a process emitting enough output makes
|
|
@@ -43,6 +46,8 @@ export declare class TerminalManager {
|
|
|
43
46
|
* Append text to a session's line buffer
|
|
44
47
|
* Handles partial lines and newline splitting
|
|
45
48
|
*/
|
|
49
|
+
private notifyOutputProgress;
|
|
50
|
+
waitForOutputProgress(pid: number, sinceVersion: number, timeoutMs: number): Promise<void>;
|
|
46
51
|
private appendToLineBuffer;
|
|
47
52
|
/**
|
|
48
53
|
* Read process output with pagination (like file reading)
|
|
@@ -51,6 +56,10 @@ export declare class TerminalManager {
|
|
|
51
56
|
* @param length Max lines to return
|
|
52
57
|
* @param updateReadIndex Whether to update lastReadIndex (default: true for offset=0)
|
|
53
58
|
*/
|
|
59
|
+
markOutputConsumed(pid: number, cursor?: {
|
|
60
|
+
lineIndex: number;
|
|
61
|
+
lineOffset: number;
|
|
62
|
+
}): void;
|
|
54
63
|
readOutputPaginated(pid: number, offset?: number, length?: number): PaginatedOutputResult | null;
|
|
55
64
|
/**
|
|
56
65
|
* Internal helper to read from a line buffer with offset/length
|
package/dist/terminal-manager.js
CHANGED
|
@@ -129,6 +129,9 @@ export class TerminalManager {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
async executeCommand(command, timeoutMs = DEFAULT_COMMAND_TIMEOUT, shell, collectTiming = false, workingDirectory) {
|
|
132
|
+
const configuredWaitCap = Number(await configManager.getValue('maxProcessWaitMs'));
|
|
133
|
+
const waitCapMs = Number.isFinite(configuredWaitCap) && configuredWaitCap > 0 ? configuredWaitCap : 180000;
|
|
134
|
+
const effectiveWaitMs = Math.min(Math.max(0, timeoutMs), waitCapMs);
|
|
132
135
|
// Get the shell from config if not specified
|
|
133
136
|
let shellToUse = shell;
|
|
134
137
|
if (!shellToUse) {
|
|
@@ -229,11 +232,31 @@ export class TerminalManager {
|
|
|
229
232
|
isBlocked: false
|
|
230
233
|
};
|
|
231
234
|
}
|
|
235
|
+
// PID collision / reuse stopgap (1.0.10 P2.1):
|
|
236
|
+
// If a session with this PID already exists:
|
|
237
|
+
// - Check active sessions: if still live, reject with an explicit error to prevent aliasing.
|
|
238
|
+
// - Check completed sessions: if stale completed session exists, evict it cleanly.
|
|
239
|
+
if (this.sessions.has(childProcess.pid)) {
|
|
240
|
+
try {
|
|
241
|
+
childProcess.kill();
|
|
242
|
+
}
|
|
243
|
+
catch (killErr) {
|
|
244
|
+
console.error(`Failed to kill conflicting child process ${childProcess.pid}:`, killErr);
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`PID collision detected: process ${childProcess.pid} is already running under an active session.`);
|
|
247
|
+
}
|
|
248
|
+
if (this.completedSessions.has(childProcess.pid)) {
|
|
249
|
+
console.warn(`[PID Collision] Evicting stale completed session for PID ${childProcess.pid}`);
|
|
250
|
+
this.completedSessions.delete(childProcess.pid);
|
|
251
|
+
}
|
|
232
252
|
const session = {
|
|
233
253
|
pid: childProcess.pid,
|
|
234
254
|
process: childProcess,
|
|
235
255
|
outputLines: [], // Line-based buffer
|
|
236
256
|
lastReadIndex: 0, // Track where "new" output starts
|
|
257
|
+
lastReadOffset: 0, // Character offset inside a partially consumed line
|
|
258
|
+
outputVersion: 0,
|
|
259
|
+
outputWaiters: new Set(),
|
|
237
260
|
isBlocked: false,
|
|
238
261
|
startTime: new Date(),
|
|
239
262
|
bufferedChars: 0,
|
|
@@ -256,6 +279,10 @@ export class TerminalManager {
|
|
|
256
279
|
if (resolved)
|
|
257
280
|
return;
|
|
258
281
|
resolved = true;
|
|
282
|
+
if (session.outputLines.length > 0) {
|
|
283
|
+
const lineIndex = session.outputLines.length - 1;
|
|
284
|
+
result.deliveredCursor = { lineIndex, lineOffset: session.outputLines[lineIndex].length };
|
|
285
|
+
}
|
|
259
286
|
if (periodicCheck)
|
|
260
287
|
clearInterval(periodicCheck);
|
|
261
288
|
// Add timing info if requested
|
|
@@ -388,32 +415,35 @@ export class TerminalManager {
|
|
|
388
415
|
output,
|
|
389
416
|
isBlocked: true
|
|
390
417
|
});
|
|
391
|
-
},
|
|
418
|
+
}, effectiveWaitMs);
|
|
419
|
+
let processExitCode = null;
|
|
392
420
|
childProcess.on('exit', (code) => {
|
|
393
|
-
|
|
394
|
-
// Store completed session before removing active session
|
|
395
|
-
this.completedSessions.set(childProcess.pid, {
|
|
396
|
-
pid: childProcess.pid,
|
|
397
|
-
outputLines: [...session.outputLines], // Copy line buffer
|
|
398
|
-
exitCode: code,
|
|
399
|
-
startTime: session.startTime,
|
|
400
|
-
endTime: new Date(),
|
|
401
|
-
evictedLines: session.evictedLines,
|
|
402
|
-
evictedChars: session.evictedChars
|
|
403
|
-
});
|
|
404
|
-
// Keep only last 100 completed sessions
|
|
405
|
-
if (this.completedSessions.size > 100) {
|
|
406
|
-
const oldestKey = Array.from(this.completedSessions.keys())[0];
|
|
407
|
-
this.completedSessions.delete(oldestKey);
|
|
408
|
-
}
|
|
409
|
-
this.sessions.delete(childProcess.pid);
|
|
410
|
-
}
|
|
421
|
+
processExitCode = code;
|
|
411
422
|
exitReason = 'process_exit';
|
|
412
|
-
resolveOnce({
|
|
423
|
+
resolveOnce({ pid: childProcess.pid, output, isBlocked: false });
|
|
424
|
+
});
|
|
425
|
+
// Finalize only after stdio has closed so fast-exit stdout/stderr cannot be lost.
|
|
426
|
+
childProcess.on('close', (code) => {
|
|
427
|
+
if (!childProcess.pid)
|
|
428
|
+
return;
|
|
429
|
+
this.completedSessions.set(childProcess.pid, {
|
|
413
430
|
pid: childProcess.pid,
|
|
414
|
-
|
|
415
|
-
|
|
431
|
+
outputLines: [...session.outputLines],
|
|
432
|
+
exitCode: processExitCode ?? code,
|
|
433
|
+
startTime: session.startTime,
|
|
434
|
+
endTime: new Date(),
|
|
435
|
+
evictedLines: session.evictedLines,
|
|
436
|
+
evictedChars: session.evictedChars,
|
|
437
|
+
lastReadIndex: session.lastReadIndex,
|
|
438
|
+
lastReadOffset: session.lastReadOffset,
|
|
439
|
+
bufferedChars: session.bufferedChars
|
|
416
440
|
});
|
|
441
|
+
if (this.completedSessions.size > 100) {
|
|
442
|
+
const oldestKey = Array.from(this.completedSessions.keys())[0];
|
|
443
|
+
this.completedSessions.delete(oldestKey);
|
|
444
|
+
}
|
|
445
|
+
this.notifyOutputProgress(session);
|
|
446
|
+
this.sessions.delete(childProcess.pid);
|
|
417
447
|
});
|
|
418
448
|
});
|
|
419
449
|
}
|
|
@@ -421,6 +451,27 @@ export class TerminalManager {
|
|
|
421
451
|
* Append text to a session's line buffer
|
|
422
452
|
* Handles partial lines and newline splitting
|
|
423
453
|
*/
|
|
454
|
+
notifyOutputProgress(session) {
|
|
455
|
+
session.outputVersion++;
|
|
456
|
+
for (const waiter of session.outputWaiters)
|
|
457
|
+
waiter();
|
|
458
|
+
session.outputWaiters.clear();
|
|
459
|
+
}
|
|
460
|
+
async waitForOutputProgress(pid, sinceVersion, timeoutMs) {
|
|
461
|
+
const session = this.sessions.get(pid);
|
|
462
|
+
if (!session || session.outputVersion !== sinceVersion)
|
|
463
|
+
return;
|
|
464
|
+
await new Promise((resolve) => {
|
|
465
|
+
let timer;
|
|
466
|
+
const done = () => {
|
|
467
|
+
session.outputWaiters.delete(done);
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
resolve();
|
|
470
|
+
};
|
|
471
|
+
session.outputWaiters.add(done);
|
|
472
|
+
timer = setTimeout(done, timeoutMs);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
424
475
|
appendToLineBuffer(session, text) {
|
|
425
476
|
if (!text)
|
|
426
477
|
return;
|
|
@@ -469,6 +520,7 @@ export class TerminalManager {
|
|
|
469
520
|
if (session.lastReadIndex > 0)
|
|
470
521
|
session.lastReadIndex--;
|
|
471
522
|
}
|
|
523
|
+
this.notifyOutputProgress(session);
|
|
472
524
|
}
|
|
473
525
|
/**
|
|
474
526
|
* Read process output with pagination (like file reading)
|
|
@@ -477,9 +529,33 @@ export class TerminalManager {
|
|
|
477
529
|
* @param length Max lines to return
|
|
478
530
|
* @param updateReadIndex Whether to update lastReadIndex (default: true for offset=0)
|
|
479
531
|
*/
|
|
532
|
+
markOutputConsumed(pid, cursor) {
|
|
533
|
+
if (!cursor)
|
|
534
|
+
return;
|
|
535
|
+
const session = this.sessions.get(pid);
|
|
536
|
+
if (session) {
|
|
537
|
+
session.lastReadIndex = Math.min(cursor.lineIndex, Math.max(0, session.outputLines.length - 1));
|
|
538
|
+
session.lastReadOffset = cursor.lineOffset;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const completed = this.completedSessions.get(pid);
|
|
542
|
+
if (completed) {
|
|
543
|
+
completed.lastReadIndex = Math.min(cursor.lineIndex, Math.max(0, completed.outputLines.length - 1));
|
|
544
|
+
completed.lastReadOffset = cursor.lineOffset;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
480
547
|
readOutputPaginated(pid, offset = 0, length = 1000) {
|
|
481
548
|
// First check active sessions
|
|
482
549
|
const session = this.sessions.get(pid);
|
|
550
|
+
if (session && offset === 0 && session.outputLines.length > 0) {
|
|
551
|
+
const start = Math.min(session.lastReadIndex, session.outputLines.length - 1);
|
|
552
|
+
const first = session.outputLines[start].slice(session.lastReadOffset);
|
|
553
|
+
const tail = session.outputLines.slice(start + 1, start + length);
|
|
554
|
+
const lines = first || tail.length === 0 ? [first, ...tail] : tail;
|
|
555
|
+
session.lastReadIndex = Math.min(start + lines.length - 1, session.outputLines.length - 1);
|
|
556
|
+
session.lastReadOffset = session.outputLines[session.lastReadIndex].length;
|
|
557
|
+
return { lines, totalLines: session.outputLines.length, readFrom: start, readCount: lines.length, remaining: Math.max(0, session.outputLines.length - start - lines.length), isComplete: false, evictedLines: session.evictedLines };
|
|
558
|
+
}
|
|
483
559
|
if (session) {
|
|
484
560
|
const result = this.readFromLineBuffer(session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined);
|
|
485
561
|
result.evictedLines = session.evictedLines;
|
|
@@ -489,7 +565,7 @@ export class TerminalManager {
|
|
|
489
565
|
const completedSession = this.completedSessions.get(pid);
|
|
490
566
|
if (completedSession) {
|
|
491
567
|
const runtimeMs = completedSession.endTime.getTime() - completedSession.startTime.getTime();
|
|
492
|
-
const result = this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, //
|
|
568
|
+
const result = this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, // Protected baseline: completed sessions read from beginning and remain replayable
|
|
493
569
|
() => { }, // No-op for completed sessions
|
|
494
570
|
true, completedSession.exitCode, runtimeMs);
|
|
495
571
|
result.evictedLines = completedSession.evictedLines;
|
|
@@ -588,17 +664,12 @@ export class TerminalManager {
|
|
|
588
664
|
*/
|
|
589
665
|
captureOutputSnapshot(pid) {
|
|
590
666
|
const session = this.sessions.get(pid);
|
|
591
|
-
if (session)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
totalChars: session.evictedChars + fullOutput.length,
|
|
598
|
-
lineCount: session.evictedLines + session.outputLines.length
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
return null;
|
|
667
|
+
if (!session)
|
|
668
|
+
return null;
|
|
669
|
+
return {
|
|
670
|
+
totalChars: session.evictedChars + session.bufferedChars,
|
|
671
|
+
lineCount: session.evictedLines + session.outputLines.length
|
|
672
|
+
};
|
|
602
673
|
}
|
|
603
674
|
/**
|
|
604
675
|
* Get output that appeared since a snapshot was taken.
|
|
@@ -609,12 +680,12 @@ export class TerminalManager {
|
|
|
609
680
|
// Check active session first
|
|
610
681
|
const session = this.sessions.get(pid);
|
|
611
682
|
if (session) {
|
|
612
|
-
return TerminalManager.outputSinceSnapshot(session.outputLines, session.evictedChars, snapshot.totalChars);
|
|
683
|
+
return TerminalManager.outputSinceSnapshot(session.outputLines, session.bufferedChars, session.evictedChars, snapshot.totalChars);
|
|
613
684
|
}
|
|
614
685
|
// Fallback to completed sessions - process may have finished between snapshot and poll
|
|
615
686
|
const completedSession = this.completedSessions.get(pid);
|
|
616
687
|
if (completedSession) {
|
|
617
|
-
return TerminalManager.outputSinceSnapshot(completedSession.outputLines, completedSession.evictedChars, snapshot.totalChars);
|
|
688
|
+
return TerminalManager.outputSinceSnapshot(completedSession.outputLines, completedSession.bufferedChars, completedSession.evictedChars, snapshot.totalChars);
|
|
618
689
|
}
|
|
619
690
|
return null;
|
|
620
691
|
}
|
|
@@ -623,13 +694,27 @@ export class TerminalManager {
|
|
|
623
694
|
* If eviction dropped part of the unseen output, returns what the buffer
|
|
624
695
|
* still holds — the oldest unseen chars are lost to the cap.
|
|
625
696
|
*/
|
|
626
|
-
static outputSinceSnapshot(outputLines, evictedChars, snapshotTotalChars) {
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
697
|
+
static outputSinceSnapshot(outputLines, bufferedChars, evictedChars, snapshotTotalChars) {
|
|
698
|
+
const newChars = evictedChars + bufferedChars - snapshotTotalChars;
|
|
699
|
+
if (newChars <= 0)
|
|
700
|
+
return '';
|
|
701
|
+
let remaining = newChars;
|
|
702
|
+
const parts = [];
|
|
703
|
+
for (let i = outputLines.length - 1; i >= 0 && remaining > 0; i--) {
|
|
704
|
+
const line = outputLines[i];
|
|
705
|
+
const separator = i < outputLines.length - 1 ? 1 : 0;
|
|
706
|
+
const available = line.length + separator;
|
|
707
|
+
if (remaining >= available) {
|
|
708
|
+
parts.push((separator ? '\n' : '') + line);
|
|
709
|
+
remaining -= available;
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
const suffixLength = Math.max(0, remaining - separator);
|
|
713
|
+
parts.push((separator ? '\n' : '') + line.slice(Math.max(0, line.length - suffixLength)));
|
|
714
|
+
remaining = 0;
|
|
715
|
+
}
|
|
631
716
|
}
|
|
632
|
-
return
|
|
717
|
+
return parts.reverse().join('');
|
|
633
718
|
}
|
|
634
719
|
/**
|
|
635
720
|
* Get a session by PID
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ServerResult } from './types.js';
|
|
2
|
+
export interface DispatchToolOptions {
|
|
3
|
+
isRemote?: boolean;
|
|
4
|
+
clientInfo?: {
|
|
5
|
+
name?: string;
|
|
6
|
+
version?: string;
|
|
7
|
+
};
|
|
8
|
+
origin?: string;
|
|
9
|
+
metadata?: any;
|
|
10
|
+
}
|
|
11
|
+
export declare function dispatchToolCall(name: string, args: any, options?: DispatchToolOptions): Promise<ServerResult>;
|