@hcu-lab.me/mcp-device 1.0.9 → 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.
@@ -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
@@ -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
- }, timeoutMs);
418
+ }, effectiveWaitMs);
419
+ let processExitCode = null;
392
420
  childProcess.on('exit', (code) => {
393
- if (childProcess.pid) {
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
- output,
415
- isBlocked: false
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, // Completed sessions don't track read position
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
- const fullOutput = session.outputLines.join('\n');
593
- return {
594
- // Absolute since process start (includes evicted output), so the
595
- // offset stays valid even if the cap evicts lines between
596
- // snapshot and read.
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 fullOutput = outputLines.join('\n');
628
- const newChars = evictedChars + fullOutput.length - snapshotTotalChars;
629
- if (newChars <= 0) {
630
- return ''; // No new output
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 fullOutput.substring(Math.max(0, fullOutput.length - newChars));
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>;