@wonderwhy-er/desktop-commander 0.2.42 → 0.2.43

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.
@@ -135,8 +135,9 @@ export async function handleReadFile(args) {
135
135
  fileType: 'image',
136
136
  sourceTool: 'read_file',
137
137
  ...await getDefaultEditorMetadata(resolvedFilePath),
138
+ // Image bytes for the preview widget, carried once. Deduped from the
139
+ // old `content` + `imageData` pair down to this single `content` field.
138
140
  content: imageData,
139
- imageData,
140
141
  mimeType: fileResult.mimeType
141
142
  }
142
143
  };
@@ -20,6 +20,8 @@ export declare class RemoteChannel {
20
20
  private onToolCall;
21
21
  private lastDeviceStatus;
22
22
  private lastChannelState;
23
+ private reconnectAttempt;
24
+ private isRecreatingChannel;
23
25
  private _user;
24
26
  get user(): User | null;
25
27
  initialize(url: string, key: string): void;
@@ -50,10 +52,22 @@ export declare class RemoteChannel {
50
52
  * This is used for both initial subscription and recreation after socket reconnects.
51
53
  */
52
54
  private createChannel;
55
+ /**
56
+ * Compact connection state for logs — e.g. "socket=open(1) ch=errored attempt=3".
57
+ * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
58
+ * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
59
+ */
60
+ private connState;
53
61
  /**
54
62
  * Check if channel is connected, recreate if not.
55
63
  */
56
64
  private checkConnectionHealth;
65
+ /**
66
+ * Run an async op but reject if it doesn't settle within `ms`, so a hung await
67
+ * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
68
+ * closeWithTimeout() in desktop-commander-integration.ts.
69
+ */
70
+ private withTimeout;
57
71
  /**
58
72
  * Recreate the channel by destroying old one and creating fresh instance.
59
73
  */
@@ -1,6 +1,9 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import { captureRemote } from '../utils/capture.js';
3
3
  const HEARTBEAT_INTERVAL = 15000;
4
+ // Cap a single channel recreate so a hung await can't pin the re-entrancy guard
5
+ // true (which would silently disable the connection watchdog).
6
+ const RECREATE_TIMEOUT_MS = 30000;
4
7
  export class RemoteChannel {
5
8
  constructor() {
6
9
  this.client = null;
@@ -14,6 +17,9 @@ export class RemoteChannel {
14
17
  this.lastDeviceStatus = 'offline';
15
18
  // Track last channel state for debug logging
16
19
  this.lastChannelState = null;
20
+ // Reconnect diagnostics + guard (see connState() / recreateChannel())
21
+ this.reconnectAttempt = 0; // recreateChannel() attempts since last success
22
+ this.isRecreatingChannel = false; // a recreate is in flight (re-entrancy guard)
17
23
  this._user = null;
18
24
  }
19
25
  get user() { return this._user; }
@@ -128,7 +134,7 @@ export class RemoteChannel {
128
134
  console.debug('[DEBUG] Calling createChannel()');
129
135
  // ! Ignore silently in Initialization to reconnect after
130
136
  await this.createChannel().catch((error) => {
131
- console.debug('[DEBUG] Failed to create channel, will retry after socket reconnect', error);
137
+ console.debug(`[DEBUG] Failed to create channel, will retry after socket reconnect: ${error?.message || error} — ${this.connState()}`);
132
138
  });
133
139
  }
134
140
  else {
@@ -162,9 +168,11 @@ export class RemoteChannel {
162
168
  })
163
169
  .subscribe((status, err) => {
164
170
  // Debug: Log all subscription status events
165
- console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + err + ')' : ''}`);
171
+ console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + (err?.message || err) + ')' : ''} — ${this.connState()}`);
166
172
  if (status === 'SUBSCRIBED') {
167
- console.log('✅ Channel subscribed');
173
+ const recovered = this.reconnectAttempt;
174
+ this.reconnectAttempt = 0;
175
+ console.log(`✅ Channel subscribed${recovered > 0 ? ` (recovered after ${recovered} attempt${recovered === 1 ? '' : 's'})` : ''}`);
168
176
  // Update device status on successful connection
169
177
  if (this.deviceId) {
170
178
  this.setOnlineStatus(this.deviceId, 'online').catch(e => {
@@ -174,20 +182,43 @@ export class RemoteChannel {
174
182
  resolve();
175
183
  }
176
184
  else if (status === 'CHANNEL_ERROR') {
177
- // console.error('❌ Channel subscription failed:', err);
185
+ // CHANNEL_ERROR is the only status carrying a real error message.
186
+ console.error(`❌ Channel error: ${err?.message || 'unknown'} — ${this.connState()}`);
178
187
  this.setOnlineStatus(this.deviceId, 'offline');
179
- captureRemote('remote_channel_subscription_error', { error: err || 'Channel error' }).catch(() => { });
188
+ captureRemote('remote_channel_subscription_error', { error: err?.message || 'Channel error' }).catch(() => { });
180
189
  reject(err || new Error('Failed to initialize tool call channel subscription'));
181
190
  }
182
191
  else if (status === 'TIMED_OUT') {
183
- console.error('⏱️ Channel subscription timed out, Reconnecting...');
192
+ console.error(`⏱️ Channel subscription timed out, Reconnecting... — ${this.connState()}`);
184
193
  this.setOnlineStatus(this.deviceId, 'offline');
185
- captureRemote('remote_channel_subscription_timeout', {}).catch(() => { });
194
+ captureRemote('remote_channel_subscription_timeout', { attempt: this.reconnectAttempt }).catch(() => { });
186
195
  reject(new Error('Tool call channel subscription timed out'));
187
196
  }
197
+ else if (status === 'CLOSED') {
198
+ // Settle the promise so an in-flight recreateChannel() can't await
199
+ // forever (which would wedge the re-entrancy guard / watchdog), and
200
+ // mark the device offline like the other degraded states.
201
+ console.warn(`⚠️ Channel closed — ${this.connState()}`);
202
+ this.setOnlineStatus(this.deviceId, 'offline');
203
+ reject(new Error('Tool call channel closed during subscribe'));
204
+ }
188
205
  });
189
206
  });
190
207
  }
208
+ /**
209
+ * Compact connection state for logs — e.g. "socket=open(1) ch=errored attempt=3".
210
+ * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
211
+ * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
212
+ */
213
+ connState() {
214
+ let socket = '?';
215
+ try {
216
+ const rt = this.client?.realtime;
217
+ socket = `${rt?.connectionState?.() ?? '?'}(${rt?.conn?.readyState ?? '-'})`;
218
+ }
219
+ catch { /* best effort */ }
220
+ return `socket=${socket} ch=${this.channel?.state ?? '-'} attempt=${this.reconnectAttempt}`;
221
+ }
191
222
  /**
192
223
  * Check if channel is connected, recreate if not.
193
224
  */
@@ -198,41 +229,89 @@ export class RemoteChannel {
198
229
  const state = this.channel.state;
199
230
  // Debug: Log current channel state (only if changed)
200
231
  if (!this.lastChannelState || this.lastChannelState !== state) {
201
- console.debug(`[DEBUG] channel state: ${state}`);
232
+ console.debug(`[DEBUG] channel state: ${state} — ${this.connState()}`);
202
233
  this.lastChannelState = state;
203
234
  }
204
- // Aggressive health check: Only 'joined' is considered healthy
205
- // Any other state (joining, leaving, closed, errored, etc.) triggers recreation
206
- if (state !== 'joined') {
207
- captureRemote('remote_channel_state_health', { state });
208
- console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating...`);
209
- this.recreateChannel();
235
+ // 'joined' = healthy, 'joining' = transitional — let realtime-js's own rejoin
236
+ // backoff converge instead of tearing the channel down mid-join. (FIX: previously
237
+ // recreated on every non-joined state, which amputated that backoff.)
238
+ if (state === 'joined' || state === 'joining')
239
+ return;
240
+ // Unhealthy: closed, errored, leaving — recreate
241
+ captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt });
242
+ console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... — ${this.connState()}`);
243
+ this.recreateChannel();
244
+ }
245
+ /**
246
+ * Run an async op but reject if it doesn't settle within `ms`, so a hung await
247
+ * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
248
+ * closeWithTimeout() in desktop-commander-integration.ts.
249
+ */
250
+ async withTimeout(op, ms, name) {
251
+ let timer;
252
+ try {
253
+ return await Promise.race([
254
+ op(),
255
+ new Promise((_, reject) => {
256
+ timer = setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms);
257
+ }),
258
+ ]);
259
+ }
260
+ finally {
261
+ if (timer)
262
+ clearTimeout(timer);
210
263
  }
211
264
  }
212
265
  /**
213
266
  * Recreate the channel by destroying old one and creating fresh instance.
214
267
  */
215
- recreateChannel() {
268
+ async recreateChannel() {
216
269
  if (!this.client || !this.user?.id || !this.onToolCall) {
217
270
  console.warn('Cannot recreate channel - missing parameters');
218
271
  console.debug('[DEBUG] recreateChannel() aborted - missing prerequisites');
219
272
  return;
220
273
  }
221
- // Destroy old channel
222
- if (this.channel) {
223
- console.debug('[DEBUG] Destroying old channel');
224
- this.client.removeChannel(this.channel);
225
- this.channel = null;
274
+ // FIX: re-entrancy guard so a 10s health tick can't stack a second recreate
275
+ // on top of an in-flight one.
276
+ if (this.isRecreatingChannel) {
277
+ console.debug('[DEBUG] recreateChannel() skipped - already in progress');
278
+ return;
226
279
  }
280
+ this.isRecreatingChannel = true;
281
+ this.reconnectAttempt++;
227
282
  // Create fresh channel
228
- console.log('🔄 Recreating channel...');
229
- console.debug('[DEBUG] Calling createChannel() for recreation');
230
- this.createChannel().catch(err => {
231
- captureRemote('remote_channel_recreate_error', { err });
232
- console.debug('[DEBUG] Channel recreation failed:', err.message);
233
- // TODO: enable only for debug mode
234
- // console.error('Failed to recreate channel:', err);
235
- });
283
+ console.log(`🔄 Recreating channel... (attempt ${this.reconnectAttempt}) — ${this.connState()}`);
284
+ try {
285
+ // Cap the whole recreate: a never-settling await (e.g. a subscribe that only
286
+ // ever emits CLOSED) must not pin isRecreatingChannel=true and silently disable
287
+ // the 10s watchdog. On timeout we reject -> catch -> finally clears the guard.
288
+ await this.withTimeout(async () => {
289
+ // Destroy old channel — AWAIT it so the channel registry empties before we
290
+ // rebuild. (The un-awaited version raced the synchronous new-channel push, so
291
+ // realtime-js never tore the socket down and a half-open one got reused.)
292
+ if (this.channel) {
293
+ console.debug('[DEBUG] Destroying old channel');
294
+ await this.client.removeChannel(this.channel);
295
+ this.channel = null;
296
+ }
297
+ // FIX (core): force a brand-new WebSocket. After idle / wifi-loss the socket can
298
+ // be HALF-OPEN (readyState OPEN but dead); reusing it made every join TIME_OUT
299
+ // forever. disconnect() drops it so the next subscribe() dials a fresh one.
300
+ try {
301
+ await this.client.realtime?.disconnect?.();
302
+ }
303
+ catch { /* best effort */ }
304
+ console.debug('[DEBUG] Calling createChannel() for recreation');
305
+ await this.createChannel();
306
+ }, RECREATE_TIMEOUT_MS, 'recreateChannel');
307
+ }
308
+ catch (err) {
309
+ captureRemote('remote_channel_recreate_error', { errMsg: err?.message, attempt: this.reconnectAttempt });
310
+ console.debug(`[DEBUG] Channel recreation failed: ${err?.message} — ${this.connState()}`);
311
+ }
312
+ finally {
313
+ this.isRecreatingChannel = false;
314
+ }
236
315
  }
237
316
  async markCallExecuting(callId) {
238
317
  if (!this.client)
package/dist/server.d.ts CHANGED
@@ -39,4 +39,8 @@ declare let currentClient: {
39
39
  version: string;
40
40
  };
41
41
  declare let currentCallIsRemote: boolean;
42
- export { currentClient, currentCallIsRemote };
42
+ declare let currentRemoteClient: {
43
+ name?: string;
44
+ version?: string;
45
+ } | null;
46
+ export { currentClient, currentCallIsRemote, currentRemoteClient };
package/dist/server.js CHANGED
@@ -8,7 +8,8 @@ const OS_GUIDANCE = getOSSpecificGuidance(SYSTEM_INFO);
8
8
  const DEV_TOOL_GUIDANCE = getDevelopmentToolGuidance(SYSTEM_INFO);
9
9
  const PATH_GUIDANCE = `IMPORTANT: ${getPathGuidance(SYSTEM_INFO)} Relative paths may fail as they depend on the current working directory. Tilde paths (~/...) might not work in all contexts. Unless the user explicitly asks for relative paths, use absolute paths.`;
10
10
  const CMD_PREFIX_DESCRIPTION = `This command can be referenced as "DC: ..." or "use Desktop Commander to ..." in your instructions.`;
11
- import { StartProcessArgsSchema, ReadProcessOutputArgsSchema, InteractWithProcessArgsSchema, ForceTerminateArgsSchema, ListSessionsArgsSchema, KillProcessArgsSchema, ReadFileArgsSchema, ReadMultipleFilesArgsSchema, WriteFileArgsSchema, CreateDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, GetConfigArgsSchema, SetConfigValueArgsSchema, ListProcessesArgsSchema, EditBlockArgsSchema, GetUsageStatsArgsSchema, GiveFeedbackArgsSchema, StartSearchArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema, ListSearchesArgsSchema, GetPromptsArgsSchema, GetRecentToolCallsArgsSchema, WritePdfArgsSchema, } from './tools/schemas.js';
11
+ import { StartProcessArgsSchema, ReadProcessOutputArgsSchema, InteractWithProcessArgsSchema, ForceTerminateArgsSchema, ListSessionsArgsSchema, KillProcessArgsSchema, ReadFileArgsSchema, ReadMultipleFilesArgsSchema, WriteFileArgsSchema, CreateDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, GetConfigArgsSchema, SetConfigValueArgsSchema, ListProcessesArgsSchema, EditBlockArgsSchema, GetUsageStatsArgsSchema, GiveFeedbackArgsSchema, StartSearchArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema, ListSearchesArgsSchema, GetPromptsArgsSchema, GetRecentToolCallsArgsSchema, WritePdfArgsSchema, toolArgSchemas, } from './tools/schemas.js';
12
+ import { detectUnsupportedParams, getSupportedParams, buildUnsupportedParamsWarning, } from './utils/unsupportedParams.js';
12
13
  import { getConfig, setConfigValue } from './tools/config.js';
13
14
  import { getUsageStats } from './tools/usage.js';
14
15
  import { giveFeedbackToDesktopCommander } from './tools/feedback.js';
@@ -86,6 +87,19 @@ let currentCallIsRemote = false;
86
87
  function setCurrentCallIsRemote(isRemote) {
87
88
  currentCallIsRemote = isRemote;
88
89
  }
90
+ // The remote caller's client for the in-flight tool call (e.g. openai-mcp,
91
+ // claude-ai). Set per CallTool when the call is remote; null for local calls.
92
+ // Mirrors currentCallIsRemote so telemetry attributes remote events to the
93
+ // actual remote client instead of the device's own currentClient (which stays
94
+ // LOCAL and must not be polluted by remote callers).
95
+ let currentRemoteClient = null;
96
+ /**
97
+ * Set the remote caller's client for the current tool call (null when local).
98
+ * Called once per tool call by the CallTool handler.
99
+ */
100
+ function setCurrentRemoteClient(clientInfo) {
101
+ currentRemoteClient = clientInfo;
102
+ }
89
103
  /**
90
104
  * Unified way to update client information
91
105
  */
@@ -147,7 +161,7 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
147
161
  }
148
162
  });
149
163
  // Export current client info for access by other modules
150
- export { currentClient, currentCallIsRemote };
164
+ export { currentClient, currentCallIsRemote, currentRemoteClient };
151
165
  deferLog('info', 'Setting up request handlers...');
152
166
  /**
153
167
  * Check if a tool should be included based on current client
@@ -1121,23 +1135,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1121
1135
  // this call carries the remote marker in _meta.
1122
1136
  const isRemoteCall = !!(metadata && typeof metadata === 'object' && metadata.remote);
1123
1137
  setCurrentCallIsRemote(isRemoteCall);
1124
- if (metadata && typeof metadata === 'object') {
1125
- // add remote flag if present (convert to string for telemetry)
1126
- if (metadata.remote) {
1127
- telemetryData.remote = String(metadata.remote);
1128
- }
1129
- // Dynamically update client info if provided in _meta
1130
- // To use in capture later
1131
- if (metadata.clientInfo) {
1132
- await updateCurrentClient(metadata.clientInfo);
1133
- telemetryData.client_name = metadata.clientInfo.name;
1134
- telemetryData.client_version = metadata.clientInfo.version;
1135
- }
1138
+ if (isRemoteCall) {
1139
+ // add remote flag (convert to string for telemetry)
1140
+ telemetryData.remote = String(metadata.remote);
1141
+ // Remote calls carry the originating MCP client (e.g. openai-mcp,
1142
+ // claude-ai) in _meta.clientInfo. Attribute this call to that remote
1143
+ // client NOT the device's own currentClient. Fall back to a sentinel
1144
+ // when it's absent so the call is visibly remote-but-unattributed
1145
+ // rather than masquerading as the local device client. We deliberately
1146
+ // do NOT call updateCurrentClient here: currentClient tracks the LOCAL
1147
+ // client and must not be polluted (nor its transport reconfigured) by
1148
+ // remote callers.
1149
+ const remoteClient = metadata.clientInfo && (metadata.clientInfo.name || metadata.clientInfo.version)
1150
+ ? metadata.clientInfo
1151
+ : { name: 'remote-unknown', version: 'unknown' };
1152
+ setCurrentRemoteClient(remoteClient);
1153
+ telemetryData.client_name = remoteClient.name;
1154
+ telemetryData.client_version = remoteClient.version;
1155
+ }
1156
+ else {
1157
+ // Local call — clear any remote attribution left by a prior call.
1158
+ setCurrentRemoteClient(null);
1136
1159
  }
1137
1160
  if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
1138
1161
  telemetryData.set_config_value_key_name = args.key;
1139
1162
  telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
1140
1163
  }
1164
+ // Attribute file-surface tool calls to the file-preview UI vs the LLM.
1165
+ // The UI passes origin:'ui' when it fires these tools to refresh/navigate
1166
+ // (pagination, folder expansion, link resolution, in-preview edits); the
1167
+ // first read_file that opens a file comes from the LLM and is recorded as
1168
+ // 'llm'. Lets us separate UI-refresh churn from genuine model-driven reads.
1169
+ if ((name === 'read_file' || name === 'write_file' || name === 'edit_block' || name === 'list_directory') &&
1170
+ args && typeof args === 'object') {
1171
+ telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
1172
+ }
1141
1173
  if (name === 'get_prompts' && args && typeof args === 'object') {
1142
1174
  const promptArgs = args;
1143
1175
  telemetryData.action = promptArgs.action;
@@ -1435,6 +1467,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1435
1467
  // Check if should prompt about Docker environment
1436
1468
  result = await processDockerPrompt(result, name);
1437
1469
  }
1470
+ // If the caller sent parameters this tool does not support, Zod silently
1471
+ // strips them. Prepend a corrective warning so the model knows they were
1472
+ // ignored and which parameters are actually supported.
1473
+ try {
1474
+ const argSchema = toolArgSchemas[name];
1475
+ if (argSchema && result && Array.isArray(result.content)) {
1476
+ const unsupported = detectUnsupportedParams(args, argSchema);
1477
+ if (unsupported.length > 0) {
1478
+ const warning = buildUnsupportedParamsWarning(name, unsupported, getSupportedParams(argSchema));
1479
+ result.content = [
1480
+ { type: "text", text: warning },
1481
+ ...result.content,
1482
+ ];
1483
+ }
1484
+ }
1485
+ }
1486
+ catch {
1487
+ // Never let the advisory warning break an otherwise-successful call.
1488
+ }
1438
1489
  return result;
1439
1490
  }
1440
1491
  catch (error) {
@@ -5,7 +5,18 @@ interface CompletedSession {
5
5
  exitCode: number | null;
6
6
  startTime: Date;
7
7
  endTime: Date;
8
+ evictedLines: number;
9
+ evictedChars: number;
8
10
  }
11
+ /**
12
+ * Output buffering caps. Without a cap, a process emitting enough output makes
13
+ * string concatenation throw "RangeError: Invalid string length" at V8's max
14
+ * string size (~536M chars) inside a stdout 'data' handler — an uncaught
15
+ * exception that kills the whole server (index.ts exits on uncaughtException).
16
+ * The cap also bounds the join() cost in snapshot reads and the periodic
17
+ * process-state scan, both of which are O(total output).
18
+ */
19
+ export declare const MAX_BUFFERED_OUTPUT_CHARS: number;
9
20
  export interface PaginatedOutputResult {
10
21
  lines: string[];
11
22
  totalLines: number;
@@ -15,6 +26,7 @@ export interface PaginatedOutputResult {
15
26
  isComplete: boolean;
16
27
  exitCode?: number | null;
17
28
  runtimeMs?: number;
29
+ evictedLines?: number;
18
30
  }
19
31
  export declare class TerminalManager {
20
32
  private sessions;
@@ -72,6 +84,12 @@ export declare class TerminalManager {
72
84
  totalChars: number;
73
85
  lineCount: number;
74
86
  }): string | null;
87
+ /**
88
+ * New output since a snapshot, in absolute (since process start) offsets.
89
+ * If eviction dropped part of the unseen output, returns what the buffer
90
+ * still holds — the oldest unseen chars are lost to the cap.
91
+ */
92
+ private static outputSinceSnapshot;
75
93
  /**
76
94
  * Get a session by PID
77
95
  * @param pid Process ID
@@ -32,6 +32,17 @@ function getRepairedPathExt() {
32
32
  }
33
33
  return current;
34
34
  }
35
+ /**
36
+ * Output buffering caps. Without a cap, a process emitting enough output makes
37
+ * string concatenation throw "RangeError: Invalid string length" at V8's max
38
+ * string size (~536M chars) inside a stdout 'data' handler — an uncaught
39
+ * exception that kills the whole server (index.ts exits on uncaughtException).
40
+ * The cap also bounds the join() cost in snapshot reads and the periodic
41
+ * process-state scan, both of which are O(total output).
42
+ */
43
+ export const MAX_BUFFERED_OUTPUT_CHARS = 50 * 1024 * 1024; // per session; oldest lines evicted first
44
+ const MAX_LINE_CHARS = 1024 * 1024; // force-split longer lines so eviction can work
45
+ const MAX_WAIT_OUTPUT_CHARS = 2 * 1024 * 1024; // start_process wait buffer (prompt/state detection)
35
46
  /**
36
47
  * Get the appropriate spawn configuration for a given shell
37
48
  * This handles login shell flags for different shell types
@@ -207,7 +218,10 @@ export class TerminalManager {
207
218
  outputLines: [], // Line-based buffer
208
219
  lastReadIndex: 0, // Track where "new" output starts
209
220
  isBlocked: false,
210
- startTime: new Date()
221
+ startTime: new Date(),
222
+ bufferedChars: 0,
223
+ evictedLines: 0,
224
+ evictedChars: 0
211
225
  };
212
226
  this.sessions.set(childProcess.pid, session);
213
227
  // Timing telemetry
@@ -249,7 +263,14 @@ export class TerminalManager {
249
263
  if (!firstOutputTime)
250
264
  firstOutputTime = now;
251
265
  lastOutputTime = now;
252
- output += text;
266
+ // `output` only feeds the wait-phase result and prompt/state detection,
267
+ // so stop growing it once resolved and keep only a bounded tail.
268
+ if (!resolved) {
269
+ output += text;
270
+ if (output.length > MAX_WAIT_OUTPUT_CHARS) {
271
+ output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
272
+ }
273
+ }
253
274
  // Append to line-based buffer
254
275
  this.appendToLineBuffer(session, text);
255
276
  // Record output event if collecting timing
@@ -282,7 +303,12 @@ export class TerminalManager {
282
303
  if (!firstOutputTime)
283
304
  firstOutputTime = now;
284
305
  lastOutputTime = now;
285
- output += text;
306
+ if (!resolved) {
307
+ output += text;
308
+ if (output.length > MAX_WAIT_OUTPUT_CHARS) {
309
+ output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
310
+ }
311
+ }
286
312
  // Append to line-based buffer
287
313
  this.appendToLineBuffer(session, text);
288
314
  // Record output event if collecting timing
@@ -329,7 +355,9 @@ export class TerminalManager {
329
355
  outputLines: [...session.outputLines], // Copy line buffer
330
356
  exitCode: code,
331
357
  startTime: session.startTime,
332
- endTime: new Date()
358
+ endTime: new Date(),
359
+ evictedLines: session.evictedLines,
360
+ evictedChars: session.evictedChars
333
361
  });
334
362
  // Keep only last 100 completed sessions
335
363
  if (this.completedSessions.size > 100) {
@@ -373,6 +401,32 @@ export class TerminalManager {
373
401
  session.outputLines.push(line);
374
402
  }
375
403
  }
404
+ // Appended text contributes exactly its length to the joined buffer
405
+ // (its newlines become the join separators).
406
+ session.bufferedChars += text.length;
407
+ // A process printing without newlines grows a single line forever, which
408
+ // eviction can't bound — force-split so no line exceeds MAX_LINE_CHARS.
409
+ // Each inserted break adds one separator to the joined length.
410
+ let lastIndex = session.outputLines.length - 1;
411
+ while (session.outputLines[lastIndex].length > MAX_LINE_CHARS) {
412
+ const overlong = session.outputLines[lastIndex];
413
+ session.outputLines[lastIndex] = overlong.slice(0, MAX_LINE_CHARS);
414
+ session.outputLines.push(overlong.slice(MAX_LINE_CHARS));
415
+ session.bufferedChars += 1;
416
+ lastIndex++;
417
+ }
418
+ // Enforce the per-session cap by evicting the oldest lines. Keeps the
419
+ // buffer far below V8's max string length so concatenation and join()
420
+ // can never throw "Invalid string length" and kill the server.
421
+ while (session.bufferedChars > MAX_BUFFERED_OUTPUT_CHARS && session.outputLines.length > 1) {
422
+ const dropped = session.outputLines.shift();
423
+ const droppedJoinedChars = dropped.length + 1; // +1 for its join separator
424
+ session.bufferedChars -= droppedJoinedChars;
425
+ session.evictedChars += droppedJoinedChars;
426
+ session.evictedLines++;
427
+ if (session.lastReadIndex > 0)
428
+ session.lastReadIndex--;
429
+ }
376
430
  }
377
431
  /**
378
432
  * Read process output with pagination (like file reading)
@@ -385,15 +439,19 @@ export class TerminalManager {
385
439
  // First check active sessions
386
440
  const session = this.sessions.get(pid);
387
441
  if (session) {
388
- return this.readFromLineBuffer(session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined);
442
+ const result = this.readFromLineBuffer(session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined);
443
+ result.evictedLines = session.evictedLines;
444
+ return result;
389
445
  }
390
446
  // Then check completed sessions
391
447
  const completedSession = this.completedSessions.get(pid);
392
448
  if (completedSession) {
393
449
  const runtimeMs = completedSession.endTime.getTime() - completedSession.startTime.getTime();
394
- return this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, // Completed sessions don't track read position
450
+ const result = this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, // Completed sessions don't track read position
395
451
  () => { }, // No-op for completed sessions
396
452
  true, completedSession.exitCode, runtimeMs);
453
+ result.evictedLines = completedSession.evictedLines;
454
+ return result;
397
455
  }
398
456
  return null;
399
457
  }
@@ -491,8 +549,11 @@ export class TerminalManager {
491
549
  if (session) {
492
550
  const fullOutput = session.outputLines.join('\n');
493
551
  return {
494
- totalChars: fullOutput.length,
495
- lineCount: session.outputLines.length
552
+ // Absolute since process start (includes evicted output), so the
553
+ // offset stays valid even if the cap evicts lines between
554
+ // snapshot and read.
555
+ totalChars: session.evictedChars + fullOutput.length,
556
+ lineCount: session.evictedLines + session.outputLines.length
496
557
  };
497
558
  }
498
559
  return null;
@@ -506,23 +567,28 @@ export class TerminalManager {
506
567
  // Check active session first
507
568
  const session = this.sessions.get(pid);
508
569
  if (session) {
509
- const fullOutput = session.outputLines.join('\n');
510
- if (fullOutput.length <= snapshot.totalChars) {
511
- return ''; // No new output
512
- }
513
- return fullOutput.substring(snapshot.totalChars);
570
+ return TerminalManager.outputSinceSnapshot(session.outputLines, session.evictedChars, snapshot.totalChars);
514
571
  }
515
572
  // Fallback to completed sessions - process may have finished between snapshot and poll
516
573
  const completedSession = this.completedSessions.get(pid);
517
574
  if (completedSession) {
518
- const fullOutput = completedSession.outputLines.join('\n');
519
- if (fullOutput.length <= snapshot.totalChars) {
520
- return ''; // No new output
521
- }
522
- return fullOutput.substring(snapshot.totalChars);
575
+ return TerminalManager.outputSinceSnapshot(completedSession.outputLines, completedSession.evictedChars, snapshot.totalChars);
523
576
  }
524
577
  return null;
525
578
  }
579
+ /**
580
+ * New output since a snapshot, in absolute (since process start) offsets.
581
+ * If eviction dropped part of the unseen output, returns what the buffer
582
+ * still holds — the oldest unseen chars are lost to the cap.
583
+ */
584
+ static outputSinceSnapshot(outputLines, evictedChars, snapshotTotalChars) {
585
+ const fullOutput = outputLines.join('\n');
586
+ const newChars = evictedChars + fullOutput.length - snapshotTotalChars;
587
+ if (newChars <= 0) {
588
+ return ''; // No new output
589
+ }
590
+ return fullOutput.substring(Math.max(0, fullOutput.length - newChars));
591
+ }
526
592
  /**
527
593
  * Get a session by PID
528
594
  * @param pid Process ID
@@ -15,7 +15,7 @@
15
15
  * 3. Unify the editRange() signature to handle both text search/replace and structured edits
16
16
  */
17
17
  import { getDefaultEditorMetadata, writeFile, readFileInternal, validatePath } from './filesystem.js';
18
- import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js';
18
+ import { runFuzzySearchInWorker, getSimilarityRatio } from './fuzzySearch.js';
19
19
  import { capture } from '../utils/capture.js';
20
20
  import { createErrorResponse } from '../error-handlers.js';
21
21
  import { EditBlockArgsSchema } from "./schemas.js";
@@ -202,8 +202,9 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
202
202
  if (count === 0) {
203
203
  // Track fuzzy search time
204
204
  const startTime = performance.now();
205
- // Perform fuzzy search
206
- const fuzzyResult = recursiveFuzzyIndexOf(content, block.search);
205
+ // Perform fuzzy search in a worker thread so the main event loop stays
206
+ // responsive to pings and parallel tool calls during the scan
207
+ const fuzzyResult = await runFuzzySearchInWorker(content, block.search);
207
208
  const similarity = getSimilarityRatio(block.search, fuzzyResult.value);
208
209
  // Calculate execution time in milliseconds
209
210
  const executionTime = performance.now() - startTime;
@@ -1,22 +1,12 @@
1
+ import type { FuzzyMatch } from './fuzzySearchCore.js';
2
+ export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js';
3
+ /** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */
4
+ export declare const FUZZY_SEARCH_TIMEOUT_MS = 30000;
1
5
  /**
2
- * Recursively finds the closest match to a query string within text using fuzzy matching
3
- * @param text The text to search within
4
- * @param query The query string to find
5
- * @param start Start index in the text (default: 0)
6
- * @param end End index in the text (default: text.length)
7
- * @param parentDistance Best distance found so far (default: Infinity)
8
- * @returns Object with start and end indices, matched value, and Levenshtein distance
6
+ * Runs the fuzzy search in a Worker thread so the main MCP event loop stays
7
+ * responsive to pings and other tool calls during heavy scans. Rejects if the
8
+ * scan exceeds timeoutMs, terminating the worker so it doesn't linger in the
9
+ * background. Search metrics come back with the result and are captured here,
10
+ * on the main thread, where the client identity is initialized.
9
11
  */
10
- export declare function recursiveFuzzyIndexOf(text: string, query: string, start?: number, end?: number | null, parentDistance?: number, depth?: number): {
11
- start: number;
12
- end: number;
13
- value: string;
14
- distance: number;
15
- };
16
- /**
17
- * Calculates the similarity ratio between two strings
18
- * @param a First string
19
- * @param b Second string
20
- * @returns Similarity ratio (0-1)
21
- */
22
- export declare function getSimilarityRatio(a: string, b: string): number;
12
+ export declare function runFuzzySearchInWorker(text: string, query: string, timeoutMs?: number): Promise<FuzzyMatch>;