@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
@@ -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,36 +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
- content: imageData,
139
- imageData,
140
- mimeType: fileResult.mimeType
141
- }
142
156
  };
143
157
  }
144
158
  else {
145
159
  // For all other files, return as text.
146
160
  // structuredContent carries only file metadata (no content duplication);
147
- // the widget reads text from the MCP content array.
148
- 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'
149
163
  ? fileResult.content
150
164
  : fileResult.content.toString('utf8');
151
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
+ }
152
172
  return {
153
173
  content: [{ type: "text", text: textContent }],
154
- structuredContent: {
155
- fileName: path.basename(resolvedFilePath),
156
- filePath: resolvedFilePath,
157
- fileType,
158
- sourceTool: 'read_file',
159
- ...await getDefaultEditorMetadata(resolvedFilePath),
160
- content: textContent,
161
- },
174
+ ...(parsed.origin === 'ui' ? {
175
+ structuredContent: {
176
+ fileName: path.basename(resolvedFilePath),
177
+ filePath: resolvedFilePath,
178
+ fileType,
179
+ ...await getDefaultEditorMetadata(resolvedFilePath),
180
+ },
181
+ } : {}),
162
182
  };
163
183
  }
164
184
  };
@@ -237,7 +257,30 @@ export async function handleReadMultipleFiles(args) {
237
257
  */
238
258
  export async function handleWriteFile(args) {
239
259
  try {
260
+ const modeProvided = !!(args && typeof args === 'object' && 'mode' in args);
240
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
+ }
241
284
  // Get the line limit from configuration
242
285
  const config = await configManager.getConfig();
243
286
  const MAX_LINES = config.fileWriteLineLimit ?? 50; // Default to 50 if not set
@@ -254,19 +297,13 @@ export async function handleWriteFile(args) {
254
297
  await writeFile(parsed.path, parsed.content, parsed.mode);
255
298
  // Provide more informative message based on mode
256
299
  const modeMessage = parsed.mode === 'append' ? 'appended to' : 'wrote to';
257
- 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).
258
302
  return {
259
303
  content: [{
260
304
  type: "text",
261
305
  text: `Successfully ${modeMessage} ${parsed.path} (${lineCount} lines) ${errorMessage}`
262
306
  }],
263
- structuredContent: {
264
- fileName: path.basename(resolvedWritePath),
265
- filePath: resolvedWritePath,
266
- fileType: resolvePreviewFileType(resolvedWritePath),
267
- sourceTool: 'write_file',
268
- ...await getDefaultEditorMetadata(resolvedWritePath),
269
- },
270
307
  };
271
308
  }
272
309
  catch (error) {
@@ -303,16 +340,13 @@ export async function handleListDirectory(args) {
303
340
  const resolvedPath = resolveAbsolutePath(parsed.path);
304
341
  return {
305
342
  content: [{ type: "text", text: resultText }],
306
- structuredContent: {
307
- fileName: path.basename(resolvedPath),
308
- filePath: resolvedPath,
309
- fileType: 'directory',
310
- sourceTool: 'list_directory',
311
- // Carry the listing in structuredContent too. Chat reads the text
312
- // content array, but structuredContent-only consumers (e.g. Cowork)
313
- // render from here and would otherwise show an empty directory.
314
- content: resultText,
315
- },
343
+ ...(parsed.origin === 'ui' ? {
344
+ structuredContent: {
345
+ fileName: path.basename(resolvedPath),
346
+ filePath: resolvedPath,
347
+ fileType: 'directory',
348
+ },
349
+ } : {}),
316
350
  };
317
351
  }
318
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({
@@ -20,6 +20,9 @@ export declare class RemoteChannel {
20
20
  private onToolCall;
21
21
  private lastDeviceStatus;
22
22
  private lastChannelState;
23
+ private reconnectAttempt;
24
+ private isRecreatingChannel;
25
+ private joiningSince;
23
26
  private _user;
24
27
  get user(): User | null;
25
28
  initialize(url: string, key: string): void;
@@ -50,10 +53,22 @@ export declare class RemoteChannel {
50
53
  * This is used for both initial subscription and recreation after socket reconnects.
51
54
  */
52
55
  private createChannel;
56
+ /**
57
+ * Compact connection state for logs — e.g. "socket=open(1) ch=errored attempt=3".
58
+ * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
59
+ * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
60
+ */
61
+ private connState;
53
62
  /**
54
63
  * Check if channel is connected, recreate if not.
55
64
  */
56
65
  private checkConnectionHealth;
66
+ /**
67
+ * Run an async op but reject if it doesn't settle within `ms`, so a hung await
68
+ * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
69
+ * closeWithTimeout() in desktop-commander-integration.ts.
70
+ */
71
+ private withTimeout;
57
72
  /**
58
73
  * Recreate the channel by destroying old one and creating fresh instance.
59
74
  */