@wonderwhy-er/desktop-commander 0.2.43 → 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 (41) 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 -53
  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 +1 -0
  10. package/dist/remote-device/remote-channel.js +34 -4
  11. package/dist/server.js +62 -22
  12. package/dist/tools/edit.d.ts +1 -1
  13. package/dist/tools/edit.js +30 -23
  14. package/dist/tools/filesystem.d.ts +1 -0
  15. package/dist/tools/filesystem.js +23 -13
  16. package/dist/tools/schemas.d.ts +19 -7
  17. package/dist/tools/schemas.js +20 -5
  18. package/dist/ui/config-editor/config-editor-runtime.js +49 -49
  19. package/dist/ui/config-editor/src/app.js +2 -11
  20. package/dist/ui/file-preview/preview-runtime.js +147 -146
  21. package/dist/ui/file-preview/src/app.js +172 -53
  22. package/dist/ui/file-preview/src/directory-controller.js +1 -1
  23. package/dist/ui/file-preview/src/markdown/controller.js +1 -0
  24. package/dist/ui/file-preview/src/panel-actions.js +2 -2
  25. package/dist/ui/file-preview/src/payload-utils.js +23 -7
  26. package/dist/utils/capture.d.ts +2 -0
  27. package/dist/utils/capture.js +19 -0
  28. package/dist/utils/files/base.d.ts +2 -0
  29. package/dist/utils/files/image.js +1 -1
  30. package/dist/utils/files/text.js +24 -19
  31. package/dist/utils/open-browser.d.ts +1 -1
  32. package/dist/utils/open-browser.js +5 -2
  33. package/dist/utils/usageTracker.d.ts +5 -1
  34. package/dist/utils/usageTracker.js +14 -3
  35. package/dist/utils/welcome-onboarding.d.ts +1 -1
  36. package/dist/utils/welcome-onboarding.js +2 -2
  37. package/dist/utils/withTimeout.d.ts +17 -0
  38. package/dist/utils/withTimeout.js +33 -0
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Threadpool bootstrap. MUST be the first import in index.ts.
3
+ *
4
+ * Every fs operation (read_file, write_file, edit_block, config/history/log
5
+ * persistence) runs on libuv's threadpool, which defaults to only 4 threads.
6
+ * Under heavy parallel load — e.g. several agents reading/writing files on a
7
+ * slow or cloud-synced filesystem — 4 stalled operations exhaust the pool, and
8
+ * because a stalled syscall keeps its thread until the OS returns (a JS-level
9
+ * timeout does not cancel it), every subsequent fs op queues for minutes. That
10
+ * surfaced as multi-minute tool-call hangs under parallel `claude -p` load.
11
+ *
12
+ * Raising the pool size gives enough headroom that a burst of slow reads no
13
+ * longer starves the rest. libuv reads UV_THREADPOOL_SIZE only when the pool is
14
+ * first initialized (on first submitted work), so this assignment has to happen
15
+ * before ANY threadpool work — hence "first import". A user-provided value is
16
+ * always respected.
17
+ */
18
+ const DEFAULT_THREADPOOL_SIZE = 16;
19
+ if (!process.env.UV_THREADPOOL_SIZE) {
20
+ process.env.UV_THREADPOOL_SIZE = String(DEFAULT_THREADPOOL_SIZE);
21
+ }
22
+ export {};
@@ -23,6 +23,8 @@ declare class ConfigManager {
23
23
  private config;
24
24
  private initialized;
25
25
  private _isFirstRun;
26
+ private writeChain;
27
+ private saveScheduled;
26
28
  constructor();
27
29
  /**
28
30
  * Initialize configuration - load from disk or create default
@@ -37,9 +39,26 @@ declare class ConfigManager {
37
39
  */
38
40
  private getDefaultConfig;
39
41
  /**
40
- * Save config to disk
42
+ * Write the current in-memory config to disk. All writes funnel through
43
+ * writeChain (see saveConfig / scheduleSave) so overlapping saves can never
44
+ * interleave and corrupt the file. Previously every tool call could fire its
45
+ * own independent fs.writeFile of the same path.
46
+ */
47
+ private writeConfigToDisk;
48
+ /**
49
+ * Awaitable save, serialized on writeChain. Use for explicit, user-driven
50
+ * config changes where the caller wants on-disk confirmation.
41
51
  */
42
52
  private saveConfig;
53
+ /**
54
+ * Non-blocking, coalesced save. Returns immediately; the write runs in the
55
+ * background. A burst of calls collapses to at most one queued write behind
56
+ * the in-flight one, so a storm of tool calls can't storm the disk — and,
57
+ * critically, can't pile up behind a saturated libuv threadpool and gate the
58
+ * tool-call response path. Used for high-frequency, non-critical persistence
59
+ * such as usage stats.
60
+ */
61
+ scheduleSave(): void;
43
62
  /**
44
63
  * Get the entire config
45
64
  */
@@ -52,6 +71,16 @@ declare class ConfigManager {
52
71
  * Set a specific configuration value
53
72
  */
54
73
  setValue(key: string, value: any): Promise<void>;
74
+ /**
75
+ * Update a value in memory and persist it WITHOUT blocking the caller.
76
+ * The tool-call response path must never wait on a disk write: when the libuv
77
+ * threadpool is saturated (e.g. many parallel reads stalled on a slow/cloud
78
+ * filesystem) an awaited write can't get a thread and would hang the response
79
+ * of even pure-memory tools. The in-memory value is updated synchronously so
80
+ * subsequent reads see it immediately; the write is coalesced in the
81
+ * background. Callers needing on-disk confirmation should use setValue.
82
+ */
83
+ setValueNonBlocking(key: string, value: any): Promise<void>;
55
84
  /**
56
85
  * Update multiple configuration values at once
57
86
  */
@@ -29,6 +29,10 @@ class ConfigManager {
29
29
  this.config = {};
30
30
  this.initialized = false;
31
31
  this._isFirstRun = false; // Track if this is the first run (config was just created)
32
+ // Serializes all disk writes so concurrent saves can't corrupt config.json.
33
+ this.writeChain = Promise.resolve();
34
+ // True while a coalesced background write is already queued (see scheduleSave).
35
+ this.saveScheduled = false;
32
36
  // Get user's home directory
33
37
  // Define config directory and file paths
34
38
  this.configPath = CONFIG_FILE;
@@ -140,16 +144,45 @@ class ConfigManager {
140
144
  };
141
145
  }
142
146
  /**
143
- * Save config to disk
147
+ * Write the current in-memory config to disk. All writes funnel through
148
+ * writeChain (see saveConfig / scheduleSave) so overlapping saves can never
149
+ * interleave and corrupt the file. Previously every tool call could fire its
150
+ * own independent fs.writeFile of the same path.
151
+ */
152
+ async writeConfigToDisk() {
153
+ await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
154
+ }
155
+ /**
156
+ * Awaitable save, serialized on writeChain. Use for explicit, user-driven
157
+ * config changes where the caller wants on-disk confirmation.
144
158
  */
145
159
  async saveConfig() {
146
- try {
147
- await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
148
- }
149
- catch (error) {
150
- console.error('Failed to save config:', error);
151
- throw error;
152
- }
160
+ const write = this.writeChain.then(() => this.writeConfigToDisk());
161
+ // Keep the chain alive even if this write rejects, so later writes still run.
162
+ this.writeChain = write.catch(() => { });
163
+ return write;
164
+ }
165
+ /**
166
+ * Non-blocking, coalesced save. Returns immediately; the write runs in the
167
+ * background. A burst of calls collapses to at most one queued write behind
168
+ * the in-flight one, so a storm of tool calls can't storm the disk — and,
169
+ * critically, can't pile up behind a saturated libuv threadpool and gate the
170
+ * tool-call response path. Used for high-frequency, non-critical persistence
171
+ * such as usage stats.
172
+ */
173
+ scheduleSave() {
174
+ if (this.saveScheduled)
175
+ return; // a queued write will capture the latest config
176
+ this.saveScheduled = true;
177
+ this.writeChain = this.writeChain.then(async () => {
178
+ this.saveScheduled = false; // let the next burst queue a fresh write
179
+ try {
180
+ await this.writeConfigToDisk();
181
+ }
182
+ catch (error) {
183
+ console.error('Failed to save config (background):', error);
184
+ }
185
+ });
153
186
  }
154
187
  /**
155
188
  * Get the entire config
@@ -194,6 +227,20 @@ class ConfigManager {
194
227
  this.config[key] = value;
195
228
  await this.saveConfig();
196
229
  }
230
+ /**
231
+ * Update a value in memory and persist it WITHOUT blocking the caller.
232
+ * The tool-call response path must never wait on a disk write: when the libuv
233
+ * threadpool is saturated (e.g. many parallel reads stalled on a slow/cloud
234
+ * filesystem) an awaited write can't get a thread and would hang the response
235
+ * of even pure-memory tools. The in-memory value is updated synchronously so
236
+ * subsequent reads see it immediately; the write is coalesced in the
237
+ * background. Callers needing on-disk confirmation should use setValue.
238
+ */
239
+ async setValueNonBlocking(key, value) {
240
+ await this.init();
241
+ this.config[key] = value;
242
+ this.scheduleSave();
243
+ }
197
244
  /**
198
245
  * Update multiple configuration values at once
199
246
  */
@@ -41,7 +41,12 @@ function getErrorFromPath(path) {
41
41
  * Handle read_file command
42
42
  */
43
43
  export async function handleReadFile(args) {
44
- const HANDLER_TIMEOUT = 60000; // 60 seconds total operation timeout
44
+ // Backstop for the whole handler operation. The real control is the
45
+ // 3-minute cancellable read timeout inside readFileFromDisk; this sits just
46
+ // above it (so that one fires first, with cleanup + a useful error) but
47
+ // still below the MCP client's ~4-minute hard cap, so we never leave the
48
+ // client hanging on an opaque timeout.
49
+ const HANDLER_TIMEOUT = 3.5 * 60 * 1000; // 3m30s
45
50
  // Add input validation
46
51
  if (args === null || args === undefined) {
47
52
  return createErrorResponse('No arguments provided for read_file command');
@@ -95,28 +100,47 @@ export async function handleReadFile(args) {
95
100
  },
96
101
  ...pdfContent
97
102
  ],
98
- structuredContent: {
99
- fileName: path.basename(resolvedFilePath),
100
- filePath: resolvedFilePath,
101
- fileType: 'unsupported',
102
- sourceTool: 'read_file',
103
- ...await getDefaultEditorMetadata(resolvedFilePath),
104
- content: pdfContent
105
- .filter((item) => item.type === "text")
106
- .map((item) => item.text)
107
- .join("\n"),
108
- },
103
+ ...(parsed.origin === 'ui' ? {
104
+ structuredContent: {
105
+ fileName: path.basename(resolvedFilePath),
106
+ filePath: resolvedFilePath,
107
+ fileType: 'unsupported',
108
+ ...await getDefaultEditorMetadata(resolvedFilePath),
109
+ },
110
+ } : {}),
109
111
  };
110
112
  }
111
113
  // Handle image files
112
114
  if (fileResult.metadata?.isImage) {
113
115
  // Return the image bytes in the MCP content array so the host model can
114
- // actually see the image. structuredContent additionally carries the bytes
115
- // for the preview widget to render.
116
+ // actually see the image. The preview widget gets its copy from its own
117
+ // origin:'ui' read structuredContent stays metadata-only.
116
118
  const imageData = typeof fileResult.content === 'string'
117
119
  ? fileResult.content
118
120
  : fileResult.content.toString('base64');
119
121
  const imageSummary = `Image file: ${parsed.path} (${fileResult.mimeType})\n`;
122
+ const imageStructuredContent = {
123
+ fileName: path.basename(resolvedFilePath),
124
+ filePath: resolvedFilePath,
125
+ fileType: 'image',
126
+ ...await getDefaultEditorMetadata(resolvedFilePath),
127
+ mimeType: fileResult.mimeType,
128
+ };
129
+ // Widget pull (origin: 'ui'): return the base64 in a TEXT block, never
130
+ // an image block. An image content block makes the host inline-vision-
131
+ // render the result, which preempts delivery of this RPC response to
132
+ // the widget — leaving the preview stuck on "Preparing preview…" for
133
+ // exactly the host-rendered types (png/jpeg/gif/webp). Text-only lets
134
+ // the RPC response through so the widget can draw the <img>.
135
+ if (parsed.origin === 'ui') {
136
+ return {
137
+ content: [{ type: "text", text: imageData }],
138
+ structuredContent: imageStructuredContent,
139
+ };
140
+ }
141
+ // Model-facing read: keep the image block so the host/model sees it.
142
+ // No structuredContent — the widget renders from its own origin:'ui'
143
+ // read, and nothing else consumes it.
120
144
  return {
121
145
  content: [
122
146
  {
@@ -129,37 +153,32 @@ export async function handleReadFile(args) {
129
153
  mimeType: fileResult.mimeType
130
154
  }
131
155
  ],
132
- structuredContent: {
133
- fileName: path.basename(resolvedFilePath),
134
- filePath: resolvedFilePath,
135
- fileType: 'image',
136
- sourceTool: 'read_file',
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.
140
- content: imageData,
141
- mimeType: fileResult.mimeType
142
- }
143
156
  };
144
157
  }
145
158
  else {
146
159
  // For all other files, return as text.
147
160
  // structuredContent carries only file metadata (no content duplication);
148
- // the widget reads text from the MCP content array.
149
- const textContent = typeof fileResult.content === 'string'
161
+ // the widget reads text from the MCP content array (over the RPC read).
162
+ let textContent = typeof fileResult.content === 'string'
150
163
  ? fileResult.content
151
164
  : fileResult.content.toString('utf8');
152
165
  const fileType = fileResult.metadata?.isDirectory ? 'directory' : resolvePreviewFileType(resolvedFilePath);
166
+ // The directory fallback prefixes a "use list_directory instead" hint
167
+ // for the LLM. The widget's own read (a list_directory preview pulls
168
+ // read_file on the dir path) would render that hint as a notice — strip it.
169
+ if (parsed.origin === 'ui' && fileType === 'directory') {
170
+ textContent = textContent.replace(/^This is a directory, not a file\.[^\n]*\n+/, '');
171
+ }
153
172
  return {
154
173
  content: [{ type: "text", text: textContent }],
155
- structuredContent: {
156
- fileName: path.basename(resolvedFilePath),
157
- filePath: resolvedFilePath,
158
- fileType,
159
- sourceTool: 'read_file',
160
- ...await getDefaultEditorMetadata(resolvedFilePath),
161
- content: textContent,
162
- },
174
+ ...(parsed.origin === 'ui' ? {
175
+ structuredContent: {
176
+ fileName: path.basename(resolvedFilePath),
177
+ filePath: resolvedFilePath,
178
+ fileType,
179
+ ...await getDefaultEditorMetadata(resolvedFilePath),
180
+ },
181
+ } : {}),
163
182
  };
164
183
  }
165
184
  };
@@ -238,7 +257,30 @@ export async function handleReadMultipleFiles(args) {
238
257
  */
239
258
  export async function handleWriteFile(args) {
240
259
  try {
260
+ const modeProvided = !!(args && typeof args === 'object' && 'mode' in args);
241
261
  const parsed = WriteFileArgsSchema.parse(args);
262
+ // An omitted `mode` falls back to 'rewrite', which silently destroys
263
+ // the target's existing content. The common failure is an LLM caller
264
+ // intending to append but dropping the optional param (issue #541), so
265
+ // require explicit intent when the target already has content;
266
+ // new/empty files keep the zero-friction default.
267
+ if (!modeProvided) {
268
+ let existing = null;
269
+ try {
270
+ existing = await getFileInfo(parsed.path);
271
+ }
272
+ catch {
273
+ // Missing file or invalid path — nothing to protect; the write
274
+ // itself will surface any real path error.
275
+ }
276
+ if (existing?.isFile && existing.size > 0) {
277
+ return createErrorResponse(`Write rejected to prevent accidental data loss: ${parsed.path} already exists ` +
278
+ `with content (${existing.size} bytes), and no 'mode' was specified — the default ` +
279
+ `mode 'rewrite' would REPLACE the entire file. Retry with an explicit mode: ` +
280
+ `'append' to add your content to the end of the existing file, or 'rewrite' ` +
281
+ `to replace all existing content.`);
282
+ }
283
+ }
242
284
  // Get the line limit from configuration
243
285
  const config = await configManager.getConfig();
244
286
  const MAX_LINES = config.fileWriteLineLimit ?? 50; // Default to 50 if not set
@@ -255,19 +297,13 @@ export async function handleWriteFile(args) {
255
297
  await writeFile(parsed.path, parsed.content, parsed.mode);
256
298
  // Provide more informative message based on mode
257
299
  const modeMessage = parsed.mode === 'append' ? 'appended to' : 'wrote to';
258
- const resolvedWritePath = resolveAbsolutePath(parsed.path);
300
+ // No structuredContent: nothing in the UI calls write_file (the widget
301
+ // previews the write via its own read_file pull at tool-result time).
259
302
  return {
260
303
  content: [{
261
304
  type: "text",
262
305
  text: `Successfully ${modeMessage} ${parsed.path} (${lineCount} lines) ${errorMessage}`
263
306
  }],
264
- structuredContent: {
265
- fileName: path.basename(resolvedWritePath),
266
- filePath: resolvedWritePath,
267
- fileType: resolvePreviewFileType(resolvedWritePath),
268
- sourceTool: 'write_file',
269
- ...await getDefaultEditorMetadata(resolvedWritePath),
270
- },
271
307
  };
272
308
  }
273
309
  catch (error) {
@@ -304,16 +340,13 @@ export async function handleListDirectory(args) {
304
340
  const resolvedPath = resolveAbsolutePath(parsed.path);
305
341
  return {
306
342
  content: [{ type: "text", text: resultText }],
307
- structuredContent: {
308
- fileName: path.basename(resolvedPath),
309
- filePath: resolvedPath,
310
- fileType: 'directory',
311
- sourceTool: 'list_directory',
312
- // Carry the listing in structuredContent too. Chat reads the text
313
- // content array, but structuredContent-only consumers (e.g. Cowork)
314
- // render from here and would otherwise show an empty directory.
315
- content: resultText,
316
- },
343
+ ...(parsed.origin === 'ui' ? {
344
+ structuredContent: {
345
+ fileName: path.basename(resolvedPath),
346
+ filePath: resolvedPath,
347
+ fileType: 'directory',
348
+ },
349
+ } : {}),
317
350
  };
318
351
  }
319
352
  catch (error) {
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import './bootstrap.js';
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // MUST be first: raises the libuv threadpool size before any fs work is
3
+ // submitted. See src/bootstrap.ts for why import order matters.
4
+ import './bootstrap.js';
2
5
  import { FilteredStdioServerTransport } from './custom-stdio.js';
3
6
  import { server, flushDeferredMessages } from './server.js';
4
7
  import { configManager } from './config-manager.js';
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
4
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5
+ import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { captureRemote } from '../utils/capture.js';
8
8
  const __filename = fileURLToPath(import.meta.url);
@@ -24,7 +24,13 @@ export class DesktopCommanderIntegration {
24
24
  console.debug('[DEBUG] MCP config:', JSON.stringify(config, null, 2));
25
25
  try {
26
26
  console.debug('[DEBUG] Creating StdioClientTransport');
27
- this.mcpTransport = new StdioClientTransport(config);
27
+ // DC_REMOTE_DEVICE tells the spawned server it is serving remote
28
+ // services, so it suppresses local-only behavior like opening the
29
+ // welcome page in a browser the remote user would never see.
30
+ this.mcpTransport = new StdioClientTransport({
31
+ ...config,
32
+ env: { ...getDefaultEnvironment(), ...config.env, DC_REMOTE_DEVICE: 'true' }
33
+ });
28
34
  // Create MCP client
29
35
  console.debug('[DEBUG] Creating MCP Client');
30
36
  this.mcpClient = new Client({
@@ -22,6 +22,7 @@ export declare class RemoteChannel {
22
22
  private lastChannelState;
23
23
  private reconnectAttempt;
24
24
  private isRecreatingChannel;
25
+ private joiningSince;
25
26
  private _user;
26
27
  get user(): User | null;
27
28
  initialize(url: string, key: string): void;
@@ -4,6 +4,14 @@ const HEARTBEAT_INTERVAL = 15000;
4
4
  // Cap a single channel recreate so a hung await can't pin the re-entrancy guard
5
5
  // true (which would silently disable the connection watchdog).
6
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;
7
15
  export class RemoteChannel {
8
16
  constructor() {
9
17
  this.client = null;
@@ -20,6 +28,7 @@ export class RemoteChannel {
20
28
  // Reconnect diagnostics + guard (see connState() / recreateChannel())
21
29
  this.reconnectAttempt = 0; // recreateChannel() attempts since last success
22
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
23
32
  this._user = null;
24
33
  }
25
34
  get user() { return this._user; }
@@ -232,12 +241,33 @@ export class RemoteChannel {
232
241
  console.debug(`[DEBUG] channel state: ${state} — ${this.connState()}`);
233
242
  this.lastChannelState = state;
234
243
  }
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')
244
+ // 'joined' = healthy. Clear the joining-overstay timer.
245
+ if (state === 'joined') {
246
+ this.joiningSince = null;
239
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;
266
+ this.recreateChannel();
267
+ return;
268
+ }
240
269
  // Unhealthy: closed, errored, leaving — recreate
270
+ this.joiningSince = null;
241
271
  captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt });
242
272
  console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... — ${this.connState()}`);
243
273
  this.recreateChannel();
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";
@@ -20,7 +21,7 @@ import { processDockerPrompt } from './utils/dockerPrompt.js';
20
21
  import { toolHistory } from './utils/toolHistory.js';
21
22
  import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
22
23
  import { VERSION } from './version.js';
23
- import { capture, capture_call_tool } from "./utils/capture.js";
24
+ import { capture, capture_call_tool, runInUiOriginCallContext } from "./utils/capture.js";
24
25
  import { logToStderr, logger } from './utils/logger.js';
25
26
  import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI, } from './ui/contracts.js';
26
27
  import { listUiResources, readUiResource } from './ui/resources.js';
@@ -100,6 +101,15 @@ let currentRemoteClient = null;
100
101
  function setCurrentRemoteClient(clientInfo) {
101
102
  currentRemoteClient = clientInfo;
102
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
+ }
103
113
  /**
104
114
  * Unified way to update client information
105
115
  */
@@ -128,13 +138,33 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
128
138
  const clientInfo = request.params?.clientInfo;
129
139
  if (clientInfo) {
130
140
  await updateCurrentClient(clientInfo);
131
- // Welcome page for new claude-ai users (A/B test controlled)
132
- // Also matches 'local-agent-mode-*' which is how Claude.ai connectors report themselves
133
- if ((currentClient.name === 'claude-ai' || currentClient.name?.startsWith('local-agent-mode')) && !global.disableOnboarding) {
134
- 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);
135
150
  }
136
151
  }
137
- 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
+ });
138
168
  // Negotiate protocol version with client
139
169
  const requestedVersion = request.params?.protocolVersion;
140
170
  const protocolVersion = (requestedVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion))
@@ -1120,6 +1150,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1120
1150
  });
1121
1151
  import * as handlers from './handlers/index.js';
1122
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) {
1123
1167
  const { name, arguments: args } = request.params;
1124
1168
  const startTime = Date.now();
1125
1169
  // Hoisted above the try so the finally block can read them when emitting the
@@ -1159,16 +1203,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1159
1203
  }
1160
1204
  if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
1161
1205
  telemetryData.set_config_value_key_name = args.key;
1162
- telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
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
1206
  }
1173
1207
  if (name === 'get_prompts' && args && typeof args === 'object') {
1174
1208
  const promptArgs = args;
@@ -1505,12 +1539,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1505
1539
  // Single tool-call telemetry event, fired AFTER execution so it can carry
1506
1540
  // timing. In a finally so it still fires on the hard-crash path (the catch
1507
1541
  // above). Only missed if a tool never returns or throws (a true hang).
1508
- capture_call_tool('server_call_tool', {
1509
- ...telemetryData,
1510
- duration_ms: Date.now() - startTime,
1511
- is_error: String(isError),
1512
- });
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
+ }
1513
1553
  }
1514
- });
1554
+ }
1515
1555
  // Add no-op handlers so Visual Studio initialization succeeds
1516
1556
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
@@ -19,7 +19,7 @@ interface SearchReplace {
19
19
  search: string;
20
20
  replace: string;
21
21
  }
22
- export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number): Promise<ServerResult>;
22
+ export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number, origin?: 'ui' | 'llm'): Promise<ServerResult>;
23
23
  /**
24
24
  * Handle edit_block command
25
25
  *