@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.
- package/dist/handlers/filesystem-handlers.js +2 -1
- package/dist/remote-device/remote-channel.d.ts +14 -0
- package/dist/remote-device/remote-channel.js +107 -28
- package/dist/server.d.ts +5 -1
- package/dist/server.js +65 -14
- package/dist/terminal-manager.d.ts +18 -0
- package/dist/terminal-manager.js +84 -18
- package/dist/tools/edit.js +4 -3
- package/dist/tools/fuzzySearch.d.ts +10 -20
- package/dist/tools/fuzzySearch.js +66 -105
- package/dist/tools/fuzzySearchCore.d.ts +52 -0
- package/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/tools/improved-process-tools.js +8 -1
- package/dist/tools/schemas.d.ts +20 -0
- package/dist/tools/schemas.js +45 -2
- package/dist/types.d.ts +3 -1
- package/dist/ui/file-preview/preview-runtime.js +5 -5
- package/dist/ui/file-preview/src/directory-controller.js +3 -3
- package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
- package/dist/ui/file-preview/src/markdown/controller.js +3 -1
- package/dist/ui/file-preview/src/panel-actions.js +2 -2
- package/dist/utils/capture.js +13 -7
- package/dist/utils/unsupportedParams.d.ts +24 -0
- package/dist/utils/unsupportedParams.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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 (
|
|
1125
|
-
// add remote flag
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
package/dist/terminal-manager.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/tools/edit.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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>;
|