@wonderwhy-er/desktop-commander 0.2.42 → 0.2.44

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.
Files changed (53) hide show
  1. package/dist/bootstrap.d.ts +1 -0
  2. package/dist/bootstrap.js +22 -0
  3. package/dist/config-manager.d.ts +30 -1
  4. package/dist/config-manager.js +55 -8
  5. package/dist/handlers/filesystem-handlers.js +86 -52
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +3 -0
  8. package/dist/remote-device/desktop-commander-integration.js +8 -2
  9. package/dist/remote-device/remote-channel.d.ts +15 -0
  10. package/dist/remote-device/remote-channel.js +136 -27
  11. package/dist/server.d.ts +5 -1
  12. package/dist/server.js +118 -27
  13. package/dist/terminal-manager.d.ts +18 -0
  14. package/dist/terminal-manager.js +84 -18
  15. package/dist/tools/edit.d.ts +1 -1
  16. package/dist/tools/edit.js +34 -26
  17. package/dist/tools/filesystem.d.ts +1 -0
  18. package/dist/tools/filesystem.js +23 -13
  19. package/dist/tools/fuzzySearch.d.ts +10 -20
  20. package/dist/tools/fuzzySearch.js +66 -105
  21. package/dist/tools/fuzzySearchCore.d.ts +52 -0
  22. package/dist/tools/fuzzySearchCore.js +125 -0
  23. package/dist/tools/improved-process-tools.js +8 -1
  24. package/dist/tools/schemas.d.ts +33 -1
  25. package/dist/tools/schemas.js +61 -3
  26. package/dist/types.d.ts +3 -1
  27. package/dist/ui/config-editor/config-editor-runtime.js +49 -49
  28. package/dist/ui/config-editor/src/app.js +2 -11
  29. package/dist/ui/file-preview/preview-runtime.js +147 -146
  30. package/dist/ui/file-preview/src/app.js +172 -53
  31. package/dist/ui/file-preview/src/directory-controller.js +4 -4
  32. package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
  33. package/dist/ui/file-preview/src/markdown/controller.js +4 -1
  34. package/dist/ui/file-preview/src/panel-actions.js +4 -4
  35. package/dist/ui/file-preview/src/payload-utils.js +23 -7
  36. package/dist/utils/capture.d.ts +2 -0
  37. package/dist/utils/capture.js +32 -7
  38. package/dist/utils/files/base.d.ts +2 -0
  39. package/dist/utils/files/image.js +1 -1
  40. package/dist/utils/files/text.js +24 -19
  41. package/dist/utils/open-browser.d.ts +1 -1
  42. package/dist/utils/open-browser.js +5 -2
  43. package/dist/utils/unsupportedParams.d.ts +24 -0
  44. package/dist/utils/unsupportedParams.js +66 -0
  45. package/dist/utils/usageTracker.d.ts +5 -1
  46. package/dist/utils/usageTracker.js +14 -3
  47. package/dist/utils/welcome-onboarding.d.ts +1 -1
  48. package/dist/utils/welcome-onboarding.js +2 -2
  49. package/dist/utils/withTimeout.d.ts +17 -0
  50. package/dist/utils/withTimeout.js +33 -0
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +5 -1
@@ -1,6 +1,17 @@
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;
7
+ // Max time the channel may sit CONTINUOUSLY in 'joining' before we force a recreate.
8
+ // 'joining' is normally healthy (we let realtime-js's rejoin backoff converge), but on a
9
+ // HALF-OPEN socket (readyState OPEN yet dead) realtime-js parks the channel in 'joining'
10
+ // forever and never reconnects the socket — the device then wedges offline silently with
11
+ // no recreate firing. realtime-js's join push times out in ~10s, so a genuine join
12
+ // resolves/errors well within this window; 3 health ticks of unbroken 'joining' means the
13
+ // state machine has stalled and only a fresh socket (via recreate) recovers it.
14
+ const JOINING_WEDGE_TIMEOUT_MS = 30000;
4
15
  export class RemoteChannel {
5
16
  constructor() {
6
17
  this.client = null;
@@ -14,6 +25,10 @@ export class RemoteChannel {
14
25
  this.lastDeviceStatus = 'offline';
15
26
  // Track last channel state for debug logging
16
27
  this.lastChannelState = null;
28
+ // Reconnect diagnostics + guard (see connState() / recreateChannel())
29
+ this.reconnectAttempt = 0; // recreateChannel() attempts since last success
30
+ this.isRecreatingChannel = false; // a recreate is in flight (re-entrancy guard)
31
+ this.joiningSince = null; // ts the channel entered an unbroken 'joining' run; null when not joining
17
32
  this._user = null;
18
33
  }
19
34
  get user() { return this._user; }
@@ -128,7 +143,7 @@ export class RemoteChannel {
128
143
  console.debug('[DEBUG] Calling createChannel()');
129
144
  // ! Ignore silently in Initialization to reconnect after
130
145
  await this.createChannel().catch((error) => {
131
- console.debug('[DEBUG] Failed to create channel, will retry after socket reconnect', error);
146
+ console.debug(`[DEBUG] Failed to create channel, will retry after socket reconnect: ${error?.message || error} — ${this.connState()}`);
132
147
  });
133
148
  }
134
149
  else {
@@ -162,9 +177,11 @@ export class RemoteChannel {
162
177
  })
163
178
  .subscribe((status, err) => {
164
179
  // Debug: Log all subscription status events
165
- console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + err + ')' : ''}`);
180
+ console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + (err?.message || err) + ')' : ''} — ${this.connState()}`);
166
181
  if (status === 'SUBSCRIBED') {
167
- console.log('✅ Channel subscribed');
182
+ const recovered = this.reconnectAttempt;
183
+ this.reconnectAttempt = 0;
184
+ console.log(`✅ Channel subscribed${recovered > 0 ? ` (recovered after ${recovered} attempt${recovered === 1 ? '' : 's'})` : ''}`);
168
185
  // Update device status on successful connection
169
186
  if (this.deviceId) {
170
187
  this.setOnlineStatus(this.deviceId, 'online').catch(e => {
@@ -174,20 +191,43 @@ export class RemoteChannel {
174
191
  resolve();
175
192
  }
176
193
  else if (status === 'CHANNEL_ERROR') {
177
- // console.error('❌ Channel subscription failed:', err);
194
+ // CHANNEL_ERROR is the only status carrying a real error message.
195
+ console.error(`❌ Channel error: ${err?.message || 'unknown'} — ${this.connState()}`);
178
196
  this.setOnlineStatus(this.deviceId, 'offline');
179
- captureRemote('remote_channel_subscription_error', { error: err || 'Channel error' }).catch(() => { });
197
+ captureRemote('remote_channel_subscription_error', { error: err?.message || 'Channel error' }).catch(() => { });
180
198
  reject(err || new Error('Failed to initialize tool call channel subscription'));
181
199
  }
182
200
  else if (status === 'TIMED_OUT') {
183
- console.error('⏱️ Channel subscription timed out, Reconnecting...');
201
+ console.error(`⏱️ Channel subscription timed out, Reconnecting... — ${this.connState()}`);
184
202
  this.setOnlineStatus(this.deviceId, 'offline');
185
- captureRemote('remote_channel_subscription_timeout', {}).catch(() => { });
203
+ captureRemote('remote_channel_subscription_timeout', { attempt: this.reconnectAttempt }).catch(() => { });
186
204
  reject(new Error('Tool call channel subscription timed out'));
187
205
  }
206
+ else if (status === 'CLOSED') {
207
+ // Settle the promise so an in-flight recreateChannel() can't await
208
+ // forever (which would wedge the re-entrancy guard / watchdog), and
209
+ // mark the device offline like the other degraded states.
210
+ console.warn(`⚠️ Channel closed — ${this.connState()}`);
211
+ this.setOnlineStatus(this.deviceId, 'offline');
212
+ reject(new Error('Tool call channel closed during subscribe'));
213
+ }
188
214
  });
189
215
  });
190
216
  }
217
+ /**
218
+ * Compact connection state for logs — e.g. "socket=open(1) ch=errored attempt=3".
219
+ * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
220
+ * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
221
+ */
222
+ connState() {
223
+ let socket = '?';
224
+ try {
225
+ const rt = this.client?.realtime;
226
+ socket = `${rt?.connectionState?.() ?? '?'}(${rt?.conn?.readyState ?? '-'})`;
227
+ }
228
+ catch { /* best effort */ }
229
+ return `socket=${socket} ch=${this.channel?.state ?? '-'} attempt=${this.reconnectAttempt}`;
230
+ }
191
231
  /**
192
232
  * Check if channel is connected, recreate if not.
193
233
  */
@@ -198,41 +238,110 @@ export class RemoteChannel {
198
238
  const state = this.channel.state;
199
239
  // Debug: Log current channel state (only if changed)
200
240
  if (!this.lastChannelState || this.lastChannelState !== state) {
201
- console.debug(`[DEBUG] channel state: ${state}`);
241
+ console.debug(`[DEBUG] channel state: ${state} — ${this.connState()}`);
202
242
  this.lastChannelState = state;
203
243
  }
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...`);
244
+ // 'joined' = healthy. Clear the joining-overstay timer.
245
+ if (state === 'joined') {
246
+ this.joiningSince = null;
247
+ return;
248
+ }
249
+ // 'joining' = transitional — normally let realtime-js's own rejoin backoff converge
250
+ // instead of tearing the channel down mid-join (recreating on every non-joined state
251
+ // amputates that backoff). BUT bound it: on a half-open socket realtime-js can park
252
+ // the channel in 'joining' indefinitely without ever reconnecting the socket, so the
253
+ // recreate below would never fire and the device wedges offline silently. If 'joining'
254
+ // overstays JOINING_WEDGE_TIMEOUT_MS unbroken, force a recreate — the only path that
255
+ // disconnect()s the dead socket. (connState() in the log shows the half-open socket.)
256
+ if (state === 'joining') {
257
+ const now = Date.now();
258
+ if (this.joiningSince === null)
259
+ this.joiningSince = now;
260
+ const stuckMs = now - this.joiningSince;
261
+ if (stuckMs < JOINING_WEDGE_TIMEOUT_MS)
262
+ return;
263
+ console.debug(`[DEBUG] ⚠️ Channel stuck 'joining' ${Math.round(stuckMs / 1000)}s - forcing recreate — ${this.connState()}`);
264
+ captureRemote('remote_channel_joining_wedge', { stuckMs, attempt: this.reconnectAttempt });
265
+ this.joiningSince = null;
209
266
  this.recreateChannel();
267
+ return;
268
+ }
269
+ // Unhealthy: closed, errored, leaving — recreate
270
+ this.joiningSince = null;
271
+ captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt });
272
+ console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... — ${this.connState()}`);
273
+ this.recreateChannel();
274
+ }
275
+ /**
276
+ * Run an async op but reject if it doesn't settle within `ms`, so a hung await
277
+ * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
278
+ * closeWithTimeout() in desktop-commander-integration.ts.
279
+ */
280
+ async withTimeout(op, ms, name) {
281
+ let timer;
282
+ try {
283
+ return await Promise.race([
284
+ op(),
285
+ new Promise((_, reject) => {
286
+ timer = setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms);
287
+ }),
288
+ ]);
289
+ }
290
+ finally {
291
+ if (timer)
292
+ clearTimeout(timer);
210
293
  }
211
294
  }
212
295
  /**
213
296
  * Recreate the channel by destroying old one and creating fresh instance.
214
297
  */
215
- recreateChannel() {
298
+ async recreateChannel() {
216
299
  if (!this.client || !this.user?.id || !this.onToolCall) {
217
300
  console.warn('Cannot recreate channel - missing parameters');
218
301
  console.debug('[DEBUG] recreateChannel() aborted - missing prerequisites');
219
302
  return;
220
303
  }
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;
304
+ // FIX: re-entrancy guard so a 10s health tick can't stack a second recreate
305
+ // on top of an in-flight one.
306
+ if (this.isRecreatingChannel) {
307
+ console.debug('[DEBUG] recreateChannel() skipped - already in progress');
308
+ return;
226
309
  }
310
+ this.isRecreatingChannel = true;
311
+ this.reconnectAttempt++;
227
312
  // 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
- });
313
+ console.log(`🔄 Recreating channel... (attempt ${this.reconnectAttempt}) — ${this.connState()}`);
314
+ try {
315
+ // Cap the whole recreate: a never-settling await (e.g. a subscribe that only
316
+ // ever emits CLOSED) must not pin isRecreatingChannel=true and silently disable
317
+ // the 10s watchdog. On timeout we reject -> catch -> finally clears the guard.
318
+ await this.withTimeout(async () => {
319
+ // Destroy old channel — AWAIT it so the channel registry empties before we
320
+ // rebuild. (The un-awaited version raced the synchronous new-channel push, so
321
+ // realtime-js never tore the socket down and a half-open one got reused.)
322
+ if (this.channel) {
323
+ console.debug('[DEBUG] Destroying old channel');
324
+ await this.client.removeChannel(this.channel);
325
+ this.channel = null;
326
+ }
327
+ // FIX (core): force a brand-new WebSocket. After idle / wifi-loss the socket can
328
+ // be HALF-OPEN (readyState OPEN but dead); reusing it made every join TIME_OUT
329
+ // forever. disconnect() drops it so the next subscribe() dials a fresh one.
330
+ try {
331
+ await this.client.realtime?.disconnect?.();
332
+ }
333
+ catch { /* best effort */ }
334
+ console.debug('[DEBUG] Calling createChannel() for recreation');
335
+ await this.createChannel();
336
+ }, RECREATE_TIMEOUT_MS, 'recreateChannel');
337
+ }
338
+ catch (err) {
339
+ captureRemote('remote_channel_recreate_error', { errMsg: err?.message, attempt: this.reconnectAttempt });
340
+ console.debug(`[DEBUG] Channel recreation failed: ${err?.message} — ${this.connState()}`);
341
+ }
342
+ finally {
343
+ this.isRecreatingChannel = false;
344
+ }
236
345
  }
237
346
  async markCallExecuting(callId) {
238
347
  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
@@ -1,3 +1,4 @@
1
+ import path from 'path';
1
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
3
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, InitializeRequestSchema, LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, } from "@modelcontextprotocol/sdk/types.js";
3
4
  import { zodToJsonSchema } from "zod-to-json-schema";
@@ -8,7 +9,8 @@ const OS_GUIDANCE = getOSSpecificGuidance(SYSTEM_INFO);
8
9
  const DEV_TOOL_GUIDANCE = getDevelopmentToolGuidance(SYSTEM_INFO);
9
10
  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
11
  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';
12
+ 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';
13
+ import { detectUnsupportedParams, getSupportedParams, buildUnsupportedParamsWarning, } from './utils/unsupportedParams.js';
12
14
  import { getConfig, setConfigValue } from './tools/config.js';
13
15
  import { getUsageStats } from './tools/usage.js';
14
16
  import { giveFeedbackToDesktopCommander } from './tools/feedback.js';
@@ -19,7 +21,7 @@ import { processDockerPrompt } from './utils/dockerPrompt.js';
19
21
  import { toolHistory } from './utils/toolHistory.js';
20
22
  import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
21
23
  import { VERSION } from './version.js';
22
- import { capture, capture_call_tool } from "./utils/capture.js";
24
+ import { capture, capture_call_tool, runInUiOriginCallContext } from "./utils/capture.js";
23
25
  import { logToStderr, logger } from './utils/logger.js';
24
26
  import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI, } from './ui/contracts.js';
25
27
  import { listUiResources, readUiResource } from './ui/resources.js';
@@ -86,6 +88,28 @@ let currentCallIsRemote = false;
86
88
  function setCurrentCallIsRemote(isRemote) {
87
89
  currentCallIsRemote = isRemote;
88
90
  }
91
+ // The remote caller's client for the in-flight tool call (e.g. openai-mcp,
92
+ // claude-ai). Set per CallTool when the call is remote; null for local calls.
93
+ // Mirrors currentCallIsRemote so telemetry attributes remote events to the
94
+ // actual remote client instead of the device's own currentClient (which stays
95
+ // LOCAL and must not be polluted by remote callers).
96
+ let currentRemoteClient = null;
97
+ /**
98
+ * Set the remote caller's client for the current tool call (null when local).
99
+ * Called once per tool call by the CallTool handler.
100
+ */
101
+ function setCurrentRemoteClient(clientInfo) {
102
+ currentRemoteClient = clientInfo;
103
+ }
104
+ /**
105
+ * True when this server instance is serving remote services rather than a
106
+ * local MCP client. The remote-device wrapper marks the server it spawns with
107
+ * DC_REMOTE_DEVICE=true (see remote-device/desktop-commander-integration.ts);
108
+ * the client-name check covers older wrappers that predate the env marker.
109
+ */
110
+ function isRemoteClientContext(clientName) {
111
+ return process.env.DC_REMOTE_DEVICE === 'true' || clientName === 'desktop-commander-client';
112
+ }
89
113
  /**
90
114
  * Unified way to update client information
91
115
  */
@@ -114,13 +138,33 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
114
138
  const clientInfo = request.params?.clientInfo;
115
139
  if (clientInfo) {
116
140
  await updateCurrentClient(clientInfo);
117
- // Welcome page for new claude-ai users (A/B test controlled)
118
- // Also matches 'local-agent-mode-*' which is how Claude.ai connectors report themselves
119
- if ((currentClient.name === 'claude-ai' || currentClient.name?.startsWith('local-agent-mode')) && !global.disableOnboarding) {
120
- await handleWelcomePageOnboarding();
141
+ // Welcome page for new users (A/B test controlled) — all clients except
142
+ // the Desktop Commander app and remote contexts, where the server is
143
+ // spawned by the remote-device wrapper and a locally opened browser
144
+ // would never reach the remote user.
145
+ if (currentClient.name !== 'desktop-commander-app'
146
+ && currentClient.name !== 'desktop-commander'
147
+ && !isRemoteClientContext(currentClient.name)
148
+ && !global.disableOnboarding) {
149
+ await handleWelcomePageOnboarding(currentClient.name);
121
150
  }
122
151
  }
123
- capture('run_server_mcp_initialized');
152
+ // Raw host environment signals (no PII, undefined when absent). Some
153
+ // hosts share a clientInfo name — Claude Code CLI, Claude Code inside
154
+ // the Claude Desktop app, and Cowork all report 'claude-code' — and
155
+ // these let analytics tell them apart without client-specific
156
+ // branching in code. Verified signatures: CLI → entrypoint 'cli';
157
+ // CC-in-desktop → entrypoint 'claude-desktop'; Cowork → no
158
+ // entrypoint/agent, plugin id 'desktop-commander-inline'.
159
+ // Values truncated to GA4's 100-char param limit (same convention as
160
+ // containerName/containerImage) so an oversized value can never get
161
+ // the whole event rejected.
162
+ capture('run_server_mcp_initialized', {
163
+ host_entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT?.substring(0, 100),
164
+ host_agent: process.env.AI_AGENT?.substring(0, 100),
165
+ host_plugin_id: process.env.CLAUDE_PLUGIN_DATA
166
+ ? path.basename(process.env.CLAUDE_PLUGIN_DATA).substring(0, 100) : undefined
167
+ });
124
168
  // Negotiate protocol version with client
125
169
  const requestedVersion = request.params?.protocolVersion;
126
170
  const protocolVersion = (requestedVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion))
@@ -147,7 +191,7 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
147
191
  }
148
192
  });
149
193
  // Export current client info for access by other modules
150
- export { currentClient, currentCallIsRemote };
194
+ export { currentClient, currentCallIsRemote, currentRemoteClient };
151
195
  deferLog('info', 'Setting up request handlers...');
152
196
  /**
153
197
  * Check if a tool should be included based on current client
@@ -1106,6 +1150,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1106
1150
  });
1107
1151
  import * as handlers from './handlers/index.js';
1108
1152
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1153
+ const args = request.params.arguments;
1154
+ // Calls fired programmatically by the widget UIs (file preview, config
1155
+ // editor) carry origin:'ui'. They are real tool executions but not agent
1156
+ // actions, so they must produce zero telemetry: running them inside the
1157
+ // UI-origin capture context makes capture() drop every event they raise
1158
+ // (server_call_tool, server_read_file, server_edit_block, ...). Deliberate
1159
+ // UI interactions are tracked separately via mcp_ui_event.
1160
+ const isUiOriginCall = !!(args && typeof args === 'object' && args.origin === 'ui');
1161
+ if (isUiOriginCall) {
1162
+ return runInUiOriginCallContext(() => handleCallToolRequest(request));
1163
+ }
1164
+ return handleCallToolRequest(request);
1165
+ });
1166
+ async function handleCallToolRequest(request) {
1109
1167
  const { name, arguments: args } = request.params;
1110
1168
  const startTime = Date.now();
1111
1169
  // Hoisted above the try so the finally block can read them when emitting the
@@ -1121,22 +1179,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1121
1179
  // this call carries the remote marker in _meta.
1122
1180
  const isRemoteCall = !!(metadata && typeof metadata === 'object' && metadata.remote);
1123
1181
  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
- }
1182
+ if (isRemoteCall) {
1183
+ // add remote flag (convert to string for telemetry)
1184
+ telemetryData.remote = String(metadata.remote);
1185
+ // Remote calls carry the originating MCP client (e.g. openai-mcp,
1186
+ // claude-ai) in _meta.clientInfo. Attribute this call to that remote
1187
+ // client NOT the device's own currentClient. Fall back to a sentinel
1188
+ // when it's absent so the call is visibly remote-but-unattributed
1189
+ // rather than masquerading as the local device client. We deliberately
1190
+ // do NOT call updateCurrentClient here: currentClient tracks the LOCAL
1191
+ // client and must not be polluted (nor its transport reconfigured) by
1192
+ // remote callers.
1193
+ const remoteClient = metadata.clientInfo && (metadata.clientInfo.name || metadata.clientInfo.version)
1194
+ ? metadata.clientInfo
1195
+ : { name: 'remote-unknown', version: 'unknown' };
1196
+ setCurrentRemoteClient(remoteClient);
1197
+ telemetryData.client_name = remoteClient.name;
1198
+ telemetryData.client_version = remoteClient.version;
1199
+ }
1200
+ else {
1201
+ // Local call — clear any remote attribution left by a prior call.
1202
+ setCurrentRemoteClient(null);
1136
1203
  }
1137
1204
  if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
1138
1205
  telemetryData.set_config_value_key_name = args.key;
1139
- telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
1140
1206
  }
1141
1207
  if (name === 'get_prompts' && args && typeof args === 'object') {
1142
1208
  const promptArgs = args;
@@ -1435,6 +1501,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1435
1501
  // Check if should prompt about Docker environment
1436
1502
  result = await processDockerPrompt(result, name);
1437
1503
  }
1504
+ // If the caller sent parameters this tool does not support, Zod silently
1505
+ // strips them. Prepend a corrective warning so the model knows they were
1506
+ // ignored and which parameters are actually supported.
1507
+ try {
1508
+ const argSchema = toolArgSchemas[name];
1509
+ if (argSchema && result && Array.isArray(result.content)) {
1510
+ const unsupported = detectUnsupportedParams(args, argSchema);
1511
+ if (unsupported.length > 0) {
1512
+ const warning = buildUnsupportedParamsWarning(name, unsupported, getSupportedParams(argSchema));
1513
+ result.content = [
1514
+ { type: "text", text: warning },
1515
+ ...result.content,
1516
+ ];
1517
+ }
1518
+ }
1519
+ }
1520
+ catch {
1521
+ // Never let the advisory warning break an otherwise-successful call.
1522
+ }
1438
1523
  return result;
1439
1524
  }
1440
1525
  catch (error) {
@@ -1454,12 +1539,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1454
1539
  // Single tool-call telemetry event, fired AFTER execution so it can carry
1455
1540
  // timing. In a finally so it still fires on the hard-crash path (the catch
1456
1541
  // above). Only missed if a tool never returns or throws (a true hang).
1457
- capture_call_tool('server_call_tool', {
1458
- ...telemetryData,
1459
- duration_ms: Date.now() - startTime,
1460
- is_error: String(isError),
1461
- });
1542
+ // Not emitted for track_ui_event (it is just the transport for
1543
+ // mcp_ui_event) — and UI-origin calls are dropped wholesale by the
1544
+ // capture layer, so server_call_tool reflects only genuine
1545
+ // agent-driven tool calls.
1546
+ if (name !== 'track_ui_event') {
1547
+ capture_call_tool('server_call_tool', {
1548
+ ...telemetryData,
1549
+ duration_ms: Date.now() - startTime,
1550
+ is_error: String(isError),
1551
+ });
1552
+ }
1462
1553
  }
1463
- });
1554
+ }
1464
1555
  // Add no-op handlers so Visual Studio initialization succeeds
1465
1556
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
@@ -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