@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
@@ -43,7 +43,7 @@ export class TextFileHandler {
43
43
  const length = options?.length ?? 1000; // Default from config
44
44
  const includeStatusMessage = options?.includeStatusMessage ?? true;
45
45
  // Binary detection is done at factory level - just read as text
46
- return this.readFileWithSmartPositioning(filePath, offset, length, 'text/plain', includeStatusMessage);
46
+ return this.readFileWithSmartPositioning(filePath, offset, length, 'text/plain', includeStatusMessage, options?.signal);
47
47
  }
48
48
  async write(path, content, mode = 'rewrite') {
49
49
  if (mode === 'append') {
@@ -100,11 +100,11 @@ export class TextFileHandler {
100
100
  /**
101
101
  * Get file line count (for files under size limit)
102
102
  */
103
- async getFileLineCount(filePath) {
103
+ async getFileLineCount(filePath, signal) {
104
104
  try {
105
105
  const stats = await fs.stat(filePath);
106
106
  if (stats.size < FILE_SIZE_LIMITS.LINE_COUNT_LIMIT) {
107
- const content = await fs.readFile(filePath, 'utf8');
107
+ const content = await fs.readFile(filePath, { encoding: 'utf8', signal });
108
108
  return TextFileHandler.countLines(content);
109
109
  }
110
110
  }
@@ -179,32 +179,32 @@ export class TextFileHandler {
179
179
  /**
180
180
  * Read file with smart positioning for optimal performance
181
181
  */
182
- async readFileWithSmartPositioning(filePath, offset, length, mimeType, includeStatusMessage = true) {
182
+ async readFileWithSmartPositioning(filePath, offset, length, mimeType, includeStatusMessage = true, signal) {
183
183
  const stats = await fs.stat(filePath);
184
184
  const fileSize = stats.size;
185
- const totalLines = await this.getFileLineCount(filePath);
185
+ const totalLines = await this.getFileLineCount(filePath, signal);
186
186
  // For negative offsets (tail behavior), use reverse reading
187
187
  if (offset < 0) {
188
188
  const requestedLines = Math.abs(offset);
189
189
  if (fileSize > FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD &&
190
190
  requestedLines <= READ_PERFORMANCE_THRESHOLDS.SMALL_READ_THRESHOLD) {
191
- return await this.readLastNLinesReverse(filePath, requestedLines, mimeType, includeStatusMessage, totalLines);
191
+ return await this.readLastNLinesReverse(filePath, requestedLines, mimeType, includeStatusMessage, totalLines, signal);
192
192
  }
193
193
  else {
194
- return await this.readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage, totalLines);
194
+ return await this.readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage, totalLines, signal);
195
195
  }
196
196
  }
197
197
  // For positive offsets
198
198
  else {
199
199
  if (fileSize < FILE_SIZE_LIMITS.LARGE_FILE_THRESHOLD || offset === 0) {
200
- return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines);
200
+ return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
201
201
  }
202
202
  else {
203
203
  if (offset > READ_PERFORMANCE_THRESHOLDS.DEEP_OFFSET_THRESHOLD) {
204
- return await this.readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage, totalLines);
204
+ return await this.readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
205
205
  }
206
206
  else {
207
- return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines);
207
+ return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, totalLines, signal);
208
208
  }
209
209
  }
210
210
  }
@@ -212,7 +212,7 @@ export class TextFileHandler {
212
212
  /**
213
213
  * Read last N lines efficiently by reading file backwards
214
214
  */
215
- async readLastNLinesReverse(filePath, n, mimeType, includeStatusMessage = true, fileTotalLines) {
215
+ async readLastNLinesReverse(filePath, n, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
216
216
  const fd = await fs.open(filePath, 'r');
217
217
  try {
218
218
  const stats = await fd.stat();
@@ -221,6 +221,11 @@ export class TextFileHandler {
221
221
  let lines = [];
222
222
  let partialLine = '';
223
223
  while (position > 0 && lines.length < n) {
224
+ if (signal?.aborted) {
225
+ const err = new Error('Read aborted');
226
+ err.code = 'ABORT_ERR';
227
+ throw err;
228
+ }
224
229
  const readSize = Math.min(READ_PERFORMANCE_THRESHOLDS.CHUNK_SIZE, position);
225
230
  position -= readSize;
226
231
  const buffer = Buffer.alloc(readSize);
@@ -247,9 +252,9 @@ export class TextFileHandler {
247
252
  /**
248
253
  * Read from end using readline with circular buffer
249
254
  */
250
- async readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage = true, fileTotalLines) {
255
+ async readFromEndWithReadline(filePath, requestedLines, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
251
256
  const rl = createInterface({
252
- input: createReadStream(filePath),
257
+ input: createReadStream(filePath, { signal }),
253
258
  crlfDelay: Infinity
254
259
  });
255
260
  const buffer = new Array(requestedLines);
@@ -279,9 +284,9 @@ export class TextFileHandler {
279
284
  /**
280
285
  * Read from start/middle using readline
281
286
  */
282
- async readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines) {
287
+ async readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
283
288
  const rl = createInterface({
284
- input: createReadStream(filePath),
289
+ input: createReadStream(filePath, { signal }),
285
290
  crlfDelay: Infinity
286
291
  });
287
292
  const result = [];
@@ -308,10 +313,10 @@ export class TextFileHandler {
308
313
  /**
309
314
  * Read from estimated byte position for very large files
310
315
  */
311
- async readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines) {
316
+ async readFromEstimatedPosition(filePath, offset, length, mimeType, includeStatusMessage = true, fileTotalLines, signal) {
312
317
  // First, do a quick scan to estimate lines per byte
313
318
  const rl = createInterface({
314
- input: createReadStream(filePath),
319
+ input: createReadStream(filePath, { signal }),
315
320
  crlfDelay: Infinity
316
321
  });
317
322
  let sampleLines = 0;
@@ -324,7 +329,7 @@ export class TextFileHandler {
324
329
  }
325
330
  rl.close();
326
331
  if (sampleLines === 0) {
327
- return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines);
332
+ return await this.readFromStartWithReadline(filePath, offset, length, mimeType, includeStatusMessage, fileTotalLines, signal);
328
333
  }
329
334
  // Estimate position
330
335
  const avgLineLength = bytesRead / sampleLines;
@@ -333,7 +338,7 @@ export class TextFileHandler {
333
338
  try {
334
339
  const stats = await fd.stat();
335
340
  const startPosition = Math.min(estimatedBytePosition, stats.size);
336
- const stream = createReadStream(filePath, { start: startPosition });
341
+ const stream = createReadStream(filePath, { start: startPosition, signal });
337
342
  const rl2 = createInterface({
338
343
  input: stream,
339
344
  crlfDelay: Infinity
@@ -6,4 +6,4 @@ export declare function openBrowser(url: string): Promise<void>;
6
6
  /**
7
7
  * Open the Desktop Commander welcome page
8
8
  */
9
- export declare function openWelcomePage(): Promise<void>;
9
+ export declare function openWelcomePage(clientName?: string): Promise<void>;
@@ -37,7 +37,10 @@ export async function openBrowser(url) {
37
37
  /**
38
38
  * Open the Desktop Commander welcome page
39
39
  */
40
- export async function openWelcomePage() {
41
- const url = 'https://desktopcommander.app/welcome/';
40
+ export async function openWelcomePage(clientName) {
41
+ // utm_source is auto-captured by the welcome page's PostHog (and GA4), so
42
+ // web analytics can segment by MCP client without any web-side changes.
43
+ const url = 'https://desktopcommander.app/welcome/'
44
+ + (clientName ? `?utm_source=${encodeURIComponent(clientName)}` : '');
42
45
  await openBrowser(url);
43
46
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Utility for detecting parameters a caller sent that a tool's Zod schema does
3
+ * not accept.
4
+ *
5
+ * Zod's default `z.object()` silently strips unknown keys, so a model that
6
+ * invents a parameter (e.g. `view_range`) gets a normal-looking response with no
7
+ * signal that its input was ignored. These helpers let the dispatcher surface a
8
+ * corrective warning instead.
9
+ *
10
+ * Detection is top-level only. That is sufficient here because none of the tool
11
+ * arg schemas use a top-level `.passthrough()`/catchall — free-form fields
12
+ * (`options`, `params`, `pdfOptions`) are named keys whose contents are
13
+ * intentionally unvalidated.
14
+ */
15
+ /** List the parameter names a tool schema accepts (empty array if it takes none). */
16
+ export declare function getSupportedParams(schema: unknown): string[];
17
+ /**
18
+ * Return the names of top-level keys in `args` that the schema does not accept.
19
+ * Returns [] when args is not a plain object or the schema's params can't be
20
+ * determined (so we never warn on something we can't reason about).
21
+ */
22
+ export declare function detectUnsupportedParams(args: unknown, schema: unknown): string[];
23
+ /** Build the corrective warning string shown to the model. */
24
+ export declare function buildUnsupportedParamsWarning(toolName: string, unsupported: string[], supported: string[]): string;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Utility for detecting parameters a caller sent that a tool's Zod schema does
3
+ * not accept.
4
+ *
5
+ * Zod's default `z.object()` silently strips unknown keys, so a model that
6
+ * invents a parameter (e.g. `view_range`) gets a normal-looking response with no
7
+ * signal that its input was ignored. These helpers let the dispatcher surface a
8
+ * corrective warning instead.
9
+ *
10
+ * Detection is top-level only. That is sufficient here because none of the tool
11
+ * arg schemas use a top-level `.passthrough()`/catchall — free-form fields
12
+ * (`options`, `params`, `pdfOptions`) are named keys whose contents are
13
+ * intentionally unvalidated.
14
+ */
15
+ /**
16
+ * Resolve a Zod schema down to the field map of its underlying object schema,
17
+ * unwrapping wrappers (effects/optional/default/nullable/branded) defensively.
18
+ * Returns null when the schema is not (and does not wrap) a plain object schema,
19
+ * in which case we cannot determine the supported parameters and must not warn.
20
+ */
21
+ function getObjectShape(schema) {
22
+ let current = schema;
23
+ for (let i = 0; current && current._def && i < 10; i++) {
24
+ const typeName = current._def.typeName;
25
+ if (typeName === 'ZodObject') {
26
+ const shape = typeof current.shape === 'function' ? current.shape() : current.shape;
27
+ return shape ?? null;
28
+ }
29
+ if (typeName === 'ZodEffects')
30
+ current = current._def.schema;
31
+ else if (typeName === 'ZodOptional' || typeName === 'ZodNullable' || typeName === 'ZodDefault') {
32
+ current = typeof current._def.innerType === 'function' ? current._def.innerType() : current._def.innerType;
33
+ }
34
+ else if (typeName === 'ZodBranded')
35
+ current = current._def.type;
36
+ else
37
+ return null;
38
+ }
39
+ return null;
40
+ }
41
+ /** List the parameter names a tool schema accepts (empty array if it takes none). */
42
+ export function getSupportedParams(schema) {
43
+ const shape = getObjectShape(schema);
44
+ return shape ? Object.keys(shape) : [];
45
+ }
46
+ /**
47
+ * Return the names of top-level keys in `args` that the schema does not accept.
48
+ * Returns [] when args is not a plain object or the schema's params can't be
49
+ * determined (so we never warn on something we can't reason about).
50
+ */
51
+ export function detectUnsupportedParams(args, schema) {
52
+ if (!args || typeof args !== 'object' || Array.isArray(args))
53
+ return [];
54
+ const shape = getObjectShape(schema);
55
+ if (shape === null)
56
+ return [];
57
+ const supported = new Set(Object.keys(shape));
58
+ return Object.keys(args).filter((k) => !supported.has(k));
59
+ }
60
+ /** Build the corrective warning string shown to the model. */
61
+ export function buildUnsupportedParamsWarning(toolName, unsupported, supported) {
62
+ const ignored = unsupported.join(', ');
63
+ const supportedList = supported.length > 0 ? supported.join(', ') : '(none)';
64
+ return `You sent parameters not supported by this tool, which were ignored: ${ignored}. `
65
+ + `Supported parameters for ${toolName}: ${supportedList}.`;
66
+ }
@@ -38,7 +38,11 @@ declare class UsageTracker {
38
38
  */
39
39
  getStats(): Promise<ToolUsageStats>;
40
40
  /**
41
- * Save usage stats to config
41
+ * Save usage stats to config.
42
+ * Non-blocking: the tool-call return path must not wait on a disk write. The
43
+ * in-memory stats are already updated by the caller (getStats returns the live
44
+ * config object), so persistence is coalesced in the background. This is what
45
+ * keeps a saturated libuv threadpool from gating tool responses on every call.
42
46
  */
43
47
  private saveStats;
44
48
  /**
@@ -45,10 +45,14 @@ class UsageTracker {
45
45
  return stats || this.getDefaultStats();
46
46
  }
47
47
  /**
48
- * Save usage stats to config
48
+ * Save usage stats to config.
49
+ * Non-blocking: the tool-call return path must not wait on a disk write. The
50
+ * in-memory stats are already updated by the caller (getStats returns the live
51
+ * config object), so persistence is coalesced in the background. This is what
52
+ * keeps a saturated libuv threadpool from gating tool responses on every call.
49
53
  */
50
54
  async saveStats(stats) {
51
- await configManager.setValue('usageStats', stats);
55
+ await configManager.setValueNonBlocking('usageStats', stats);
52
56
  }
53
57
  /**
54
58
  * Determine which category a tool belongs to
@@ -327,7 +331,14 @@ class UsageTracker {
327
331
  async shouldShowOnboarding() {
328
332
  // Check feature flag first (remote kill switch)
329
333
  const { featureFlagManager } = await import('./feature-flags.js');
330
- const onboardingEnabled = featureFlagManager.get('onboarding_injection', true);
334
+ // Default false: on cold starts (no flag cache yet, e.g. ephemeral Docker
335
+ // containers) this can run before the background flag fetch completes.
336
+ // Treat an unknown flag as "off" so nothing is injected — flags load
337
+ // within seconds, so when the flag is ON onboarding still fires on a
338
+ // later call while the user is new. Deliberately no waiting here to keep
339
+ // tool calls latency-free.
340
+ // See: https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/538
341
+ const onboardingEnabled = featureFlagManager.get('onboarding_injection', false);
331
342
  if (!onboardingEnabled) {
332
343
  return false;
333
344
  }
@@ -6,4 +6,4 @@
6
6
  * 2. Users in the 'showOnboardingPage' A/B variant
7
7
  * 3. Haven't seen it yet
8
8
  */
9
- export declare function handleWelcomePageOnboarding(): Promise<void>;
9
+ export declare function handleWelcomePageOnboarding(clientName?: string): Promise<void>;
@@ -12,7 +12,7 @@ import { capture } from './capture.js';
12
12
  * 2. Users in the 'showOnboardingPage' A/B variant
13
13
  * 3. Haven't seen it yet
14
14
  */
15
- export async function handleWelcomePageOnboarding() {
15
+ export async function handleWelcomePageOnboarding(clientName) {
16
16
  // Check if this is a new install pending A/B decision
17
17
  // This flag is set when config is first created and survives process restarts
18
18
  const pending = await configManager.getValue('pendingWelcomeOnboarding');
@@ -48,7 +48,7 @@ export async function handleWelcomePageOnboarding() {
48
48
  return;
49
49
  }
50
50
  try {
51
- await openWelcomePage();
51
+ await openWelcomePage(clientName);
52
52
  await configManager.setValue('sawOnboardingPage', true);
53
53
  await configManager.setValue('pendingWelcomeOnboarding', false);
54
54
  capture('server_welcome_page_opened', { success: true });
@@ -9,3 +9,20 @@
9
9
  * @returns Promise that resolves with the operation result or the default value on timeout
10
10
  */
11
11
  export declare function withTimeout<T>(operation: Promise<T>, timeoutMs: number, operationName: string, defaultValue: T): Promise<T>;
12
+ /**
13
+ * Run an operation under a timeout WITH real cancellation.
14
+ *
15
+ * Unlike withTimeout (which only races a timer and leaves the underlying work
16
+ * running — holding its libuv thread/fd until the OS call returns), this passes
17
+ * an AbortSignal into the operation and aborts it when the timeout fires, so a
18
+ * read/stream that honors the signal is cancelled and its resources released.
19
+ *
20
+ * Rejects with an Error whose `.code` is 'ETIMEDOUT' on timeout (so existing
21
+ * ETIMEDOUT handling / permission-error mapping keeps working).
22
+ *
23
+ * Caveat: an operation wedged inside a single un-interruptible syscall only
24
+ * observes the abort once that syscall returns; library reads that ignore the
25
+ * signal (e.g. Excel/PDF parsers) still get the timeout rejection but keep
26
+ * running in the background until they finish on their own.
27
+ */
28
+ export declare function runWithAbortableTimeout<T>(operation: (signal: AbortSignal) => Promise<T>, timeoutMs: number, operationName: string): Promise<T>;
@@ -50,3 +50,36 @@ export function withTimeout(operation, timeoutMs, operationName, defaultValue) {
50
50
  });
51
51
  });
52
52
  }
53
+ /**
54
+ * Run an operation under a timeout WITH real cancellation.
55
+ *
56
+ * Unlike withTimeout (which only races a timer and leaves the underlying work
57
+ * running — holding its libuv thread/fd until the OS call returns), this passes
58
+ * an AbortSignal into the operation and aborts it when the timeout fires, so a
59
+ * read/stream that honors the signal is cancelled and its resources released.
60
+ *
61
+ * Rejects with an Error whose `.code` is 'ETIMEDOUT' on timeout (so existing
62
+ * ETIMEDOUT handling / permission-error mapping keeps working).
63
+ *
64
+ * Caveat: an operation wedged inside a single un-interruptible syscall only
65
+ * observes the abort once that syscall returns; library reads that ignore the
66
+ * signal (e.g. Excel/PDF parsers) still get the timeout rejection but keep
67
+ * running in the background until they finish on their own.
68
+ */
69
+ export function runWithAbortableTimeout(operation, timeoutMs, operationName) {
70
+ const controller = new AbortController();
71
+ let timeoutId;
72
+ const timeout = new Promise((_, reject) => {
73
+ timeoutId = setTimeout(() => {
74
+ controller.abort();
75
+ const error = new Error(`${operationName} timed out after ${timeoutMs / 1000} seconds`);
76
+ error.code = 'ETIMEDOUT';
77
+ reject(error);
78
+ }, timeoutMs);
79
+ });
80
+ const op = operation(controller.signal);
81
+ // Swallow the late abort rejection so it can't surface as an unhandled
82
+ // rejection after the timeout has already settled the race.
83
+ op.catch(() => { });
84
+ return Promise.race([op, timeout]).finally(() => clearTimeout(timeoutId));
85
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.2.42";
1
+ export declare const VERSION = "0.2.44";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.2.42';
1
+ export const VERSION = '0.2.44';
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@wonderwhy-er/desktop-commander",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
4
4
  "description": "MCP server for terminal operations and file editing",
5
5
  "mcpName": "io.github.wonderwhy-er/desktop-commander",
6
6
  "license": "MIT",
7
7
  "author": "Eduards Ruzga",
8
8
  "homepage": "https://github.com/wonderwhy-er/DesktopCommanderMCP",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/wonderwhy-er/DesktopCommanderMCP.git"
12
+ },
9
13
  "bugs": "https://github.com/wonderwhy-er/DesktopCommanderMCP/issues",
10
14
  "type": "module",
11
15
  "engines": {