@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
@@ -19,7 +19,6 @@ import { attachPanelActions } from './panel-actions.js';
19
19
  import { extractRenderPayload, extractToolText, getFileExtensionForAnalytics, isLikelyUrl, isPreviewStructuredContent } from './payload-utils.js';
20
20
  let isExpanded = false;
21
21
  let hideSummaryRow = false;
22
- let previewShownFired = false;
23
22
  let onRender;
24
23
  let trackUiEvent;
25
24
  let conflictDialogController;
@@ -128,43 +127,113 @@ const markdownController = createMarkdownController({
128
127
  options.onCancel?.();
129
128
  },
130
129
  });
130
+ // Only read_file's own parameters may ride along on a re-read. Forwarding a
131
+ // different tool's args (e.g. edit_block's old_string) makes the server prepend
132
+ // an "unsupported params" warning that would pollute the pulled content.
133
+ const READ_ARG_KEYS = ['path', 'offset', 'length', 'sheet', 'range', 'isUrl'];
134
+ function pickReadArgs(args) {
135
+ if (!args || typeof args !== 'object') {
136
+ return undefined;
137
+ }
138
+ const src = args;
139
+ // edit_block calls the file "file_path"; read_file/write_file call it "path".
140
+ const path = typeof src.path === 'string'
141
+ ? src.path
142
+ : typeof src.file_path === 'string'
143
+ ? src.file_path
144
+ : undefined;
145
+ if (!path) {
146
+ return undefined;
147
+ }
148
+ const out = { path };
149
+ for (const key of READ_ARG_KEYS) {
150
+ if (key !== 'path' && src[key] !== undefined) {
151
+ out[key] = src[key];
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ // The host doesn't send the tool name with tool-input, so infer mutations from
157
+ // their arg shape: write_file carries `content`; edit_block `old_string`/
158
+ // `new_string`. Drives two things: timing (a mutation's tool-input arrives
159
+ // BEFORE the file changes, so those pull at tool-result time instead of input
160
+ // time) and telemetry (the pulled payload's sourceTool is re-stamped with the
161
+ // originating tool, since the pull itself is always a read_file).
162
+ function inferMutationTool(args) {
163
+ if (!args || typeof args !== 'object') {
164
+ return undefined;
165
+ }
166
+ const src = args;
167
+ if (src.old_string !== undefined || src.new_string !== undefined) {
168
+ return 'edit_block';
169
+ }
170
+ if (src.content !== undefined) {
171
+ return 'write_file';
172
+ }
173
+ return undefined;
174
+ }
175
+ // Bound the RPC read so a stalled host response resolves to onFail instead of
176
+ // hanging. Kept under the loading watchdog (PREVIEW_WATCHDOG_MS) so the pull
177
+ // gets its chance to fail before the failure row appears.
178
+ const PULL_TIMEOUT_MS = 8000;
179
+ // One in-flight pull per path — the input-time pull and a tool-result-triggered
180
+ // pull for the same call would otherwise read the file twice.
181
+ let pullInFlightPath;
131
182
  /**
132
- * Check if a payload needs its file content to be read.
133
- * Tool results from edit_block/write_file include structuredContent but
134
- * their text is a success message, not file content. Detect this by
135
- * checking for the absence of the read status line that read_file always includes.
136
- * URL payloads are fetched remotely by read_file(isUrl:true); we can't
137
- * re-fetch them from here (no isUrl flag on the refresh path), so skip.
183
+ * The widget's render path: pull content + metadata over the RPC channel
184
+ * (`app.callServerTool` with origin:'ui'). The notification channel is not used
185
+ * for rendering the host may strip structuredContent from it or swallow it
186
+ * entirely (images). `args` reproduces the original read exactly
187
+ * (offset/length/sheet/range/isUrl) so partial reads stay faithful.
138
188
  */
139
- function needsContentRead(payload) {
140
- if (payload.fileType === 'directory' || payload.fileType === 'image' || payload.fileType === 'unsupported') {
141
- return false;
189
+ async function pullPayloadByArgs(args, onReady, onFail) {
190
+ const filePath = typeof args.path === 'string' ? args.path : undefined;
191
+ if (!filePath) {
192
+ onFail();
193
+ return;
142
194
  }
143
- if (/^https?:\/\//i.test(payload.filePath)) {
144
- return false;
195
+ if (pullInFlightPath === filePath) {
196
+ return;
145
197
  }
146
- return !parseReadRange(payload.content);
147
- }
148
- async function readAndResolvePayload(payload, onReady) {
198
+ pullInFlightPath = filePath;
149
199
  try {
150
- const freshPayload = await markdownController.readPayload(payload.filePath);
151
- if (freshPayload) {
152
- onReady({
153
- ...freshPayload,
154
- sourceTool: payload.sourceTool ?? freshPayload.sourceTool,
155
- });
156
- if (freshPayload.fileType === 'markdown') {
157
- void markdownController.refreshFromDisk(freshPayload);
158
- }
200
+ // The host can hold the RPC response indefinitely (e.g. it preempts
201
+ // delivery to inline-render an image), so callServerTool would await
202
+ // forever with no throw. Race a timeout so the pull settles either way.
203
+ const raw = await Promise.race([
204
+ callToolIfReady('read_file', { ...args, origin: 'ui' }),
205
+ new Promise((_resolve, reject) => {
206
+ setTimeout(() => reject(new Error(`RPC read_file did not respond within ${PULL_TIMEOUT_MS}ms`)), PULL_TIMEOUT_MS);
207
+ }),
208
+ ]);
209
+ const payload = extractRenderPayload(raw);
210
+ if (payload) {
211
+ onReady(payload);
159
212
  return;
160
213
  }
161
214
  }
162
215
  catch {
163
- // Fall through to original payload.
216
+ // Timed out or rejected — fall through to the caller's fallback.
217
+ }
218
+ finally {
219
+ pullInFlightPath = undefined;
220
+ }
221
+ onFail();
222
+ }
223
+ // If a loading state hasn't resolved into a real render within this window, the
224
+ // host most likely never delivered the tool result / RPC response (e.g. it
225
+ // preempted delivery to inline-render an image). Rather than sit on "Preparing
226
+ // preview…" forever, we surface a failure row.
227
+ const PREVIEW_WATCHDOG_MS = 10000;
228
+ let previewWatchdogId;
229
+ function clearPreviewWatchdog() {
230
+ if (previewWatchdogId !== undefined) {
231
+ clearTimeout(previewWatchdogId);
232
+ previewWatchdogId = undefined;
164
233
  }
165
- onReady(payload);
166
234
  }
167
235
  function renderStatusState(container, message) {
236
+ clearPreviewWatchdog();
168
237
  container.innerHTML = `
169
238
  <main class="shell">
170
239
  ${renderCompactRow({ label: message, variant: 'status', interactive: false })}
@@ -179,8 +248,16 @@ function renderLoadingState(container) {
179
248
  </main>
180
249
  `;
181
250
  document.body.classList.add('dc-ready');
251
+ // Fall back to a failure row if nothing renders in time. Any successful
252
+ // render (renderApp) or explicit status clears this first.
253
+ clearPreviewWatchdog();
254
+ previewWatchdogId = setTimeout(() => {
255
+ previewWatchdogId = undefined;
256
+ renderStatusState(container, 'Unable to generate preview in this environment.');
257
+ }, PREVIEW_WATCHDOG_MS);
182
258
  }
183
259
  export function renderApp(container, payload, htmlMode = 'rendered', expandedState = false) {
260
+ clearPreviewWatchdog();
184
261
  isExpanded = expandedState;
185
262
  currentHtmlMode = htmlMode;
186
263
  shellController?.dispose();
@@ -314,13 +391,6 @@ export function renderApp(container, payload, htmlMode = 'rendered', expandedSta
314
391
  onRender,
315
392
  });
316
393
  onRender?.();
317
- if (!previewShownFired) {
318
- previewShownFired = true;
319
- trackUiEvent?.('preview_shown', {
320
- file_type: payload.fileType,
321
- file_extension: fileExtension,
322
- });
323
- }
324
394
  }
325
395
  export function bootstrapApp() {
326
396
  const container = document.getElementById('app');
@@ -376,6 +446,14 @@ export function bootstrapApp() {
376
446
  renderApp(container, currentPayload, currentHtmlMode, isExpanded);
377
447
  };
378
448
  let pendingCachedPayload;
449
+ let lastToolInputArgs;
450
+ // The mutation tool that produced the current tool call, if any — stamped
451
+ // onto the pulled payload so telemetry attributes to write_file/edit_block
452
+ // rather than the read_file pull that rendered it.
453
+ let lastMutationTool;
454
+ // True once the current tool call has been rendered (via the input-time pull),
455
+ // so a late tool-result notification doesn't trigger a redundant second pull.
456
+ let renderedForCurrentInput = false;
379
457
  let initialStateResolved = false;
380
458
  const resolveInitialState = (payload, message) => {
381
459
  if (initialStateResolved) {
@@ -423,6 +501,13 @@ export function bootstrapApp() {
423
501
  });
424
502
  app.ontoolinput = (params) => {
425
503
  const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined;
504
+ const readArgs = pickReadArgs(params.arguments);
505
+ lastMutationTool = inferMutationTool(params.arguments);
506
+ if (readArgs) {
507
+ // Retained so mutations (which pull at tool-result time) know what
508
+ // to re-read — faithfully reproducing offset/length/sheet/range.
509
+ lastToolInputArgs = readArgs;
510
+ }
426
511
  if (!initialStateResolved
427
512
  && pendingCachedPayload
428
513
  && requestedPath
@@ -434,38 +519,71 @@ export function bootstrapApp() {
434
519
  }
435
520
  renderLoadingState(container);
436
521
  onRender?.();
522
+ renderedForCurrentInput = false;
523
+ // All previews render from the widget's own origin:'ui' RPC read — the
524
+ // notification channel is unreliable (the host strips structuredContent,
525
+ // and for images swallows the tool-result entirely to inline-render).
526
+ // tool-input always fires and carries the path + read args, so pull here.
527
+ // Exception: mutations (write_file/edit_block) — their tool-input arrives
528
+ // before the file changes, so they pull at tool-result time instead.
529
+ if (readArgs && !lastMutationTool) {
530
+ void pullPayloadByArgs(readArgs, (p) => {
531
+ renderedForCurrentInput = true;
532
+ if (initialStateResolved) {
533
+ renderAndSync(getEffectiveIncomingPayload(p));
534
+ }
535
+ else {
536
+ resolveInitialState(getEffectiveIncomingPayload(p));
537
+ }
538
+ },
539
+ // Pull failed/timed out — the loading watchdog shows the failure row.
540
+ () => { });
541
+ }
437
542
  };
438
543
  app.ontoolresult = (result) => {
439
544
  pendingCachedPayload = undefined;
440
- const payload = extractRenderPayload(result);
545
+ // Host-facing responses carry no structuredContent, so this notification
546
+ // only signals completion. Reads already rendered from their input-time
547
+ // pull; mutations (write_file/edit_block) pull now that the file changed.
548
+ if (renderedForCurrentInput) {
549
+ return;
550
+ }
441
551
  const message = extractToolText(result);
442
- if (!initialStateResolved) {
443
- if (payload) {
444
- if (needsContentRead(payload)) {
445
- void readAndResolvePayload(payload, (p) => resolveInitialState(getEffectiveIncomingPayload(p)));
446
- return;
447
- }
448
- resolveInitialState(getEffectiveIncomingPayload(payload));
552
+ const isError = result?.isError === true;
553
+ const pullArgs = lastToolInputArgs
554
+ ?? (currentPayload?.filePath ? { path: currentPayload.filePath } : undefined);
555
+ const deliver = (pulled) => {
556
+ renderedForCurrentInput = true;
557
+ // The pull is always a read_file; re-stamp the originating mutation
558
+ // tool so telemetry attributes to write_file/edit_block.
559
+ const p = lastMutationTool ? { ...pulled, sourceTool: lastMutationTool } : pulled;
560
+ if (initialStateResolved) {
561
+ renderAndSync(getEffectiveIncomingPayload(p));
562
+ }
563
+ else {
564
+ resolveInitialState(getEffectiveIncomingPayload(p));
565
+ }
566
+ };
567
+ const renderMessageFallback = () => {
568
+ if (!message) {
449
569
  return;
450
570
  }
451
- if (message) {
571
+ if (!initialStateResolved) {
452
572
  resolveInitialState(undefined, message);
453
573
  }
454
- return;
455
- }
456
- if (payload) {
457
- if (needsContentRead(payload)) {
458
- renderLoadingState(container);
459
- void readAndResolvePayload(payload, (p) => renderAndSync(getEffectiveIncomingPayload(p)));
460
- }
461
574
  else {
462
- renderAndSync(getEffectiveIncomingPayload(payload));
575
+ renderStatusState(container, message);
576
+ onRender?.();
463
577
  }
464
- }
465
- else if (message) {
466
- renderStatusState(container, message);
578
+ };
579
+ // A failed tool call has nothing worth re-reading — show its message.
580
+ if (!isError && pullArgs) {
581
+ renderLoadingState(container);
467
582
  onRender?.();
583
+ void pullPayloadByArgs(pullArgs, deliver, renderMessageFallback);
584
+ return;
468
585
  }
586
+ renderMessageFallback();
469
587
  };
470
588
  app.ontoolcancelled = (params) => {
471
589
  resolveInitialState(undefined, params.reason ?? 'Tool was cancelled.');
@@ -484,6 +602,7 @@ export function bootstrapApp() {
484
602
  }
485
603
  };
486
604
  const teardown = () => {
605
+ clearPreviewWatchdog();
487
606
  shellController?.dispose();
488
607
  shellController = undefined;
489
608
  markdownController.disposeHandles();
@@ -128,7 +128,7 @@ export function attachDirectoryHandlers(options) {
128
128
  const command = options.buildOpenInFolderCommand(openPath);
129
129
  if (command) {
130
130
  try {
131
- await options.callTool?.('start_process', { command, timeout_ms: 12000 });
131
+ await options.callTool?.('start_process', { command, timeout_ms: 12000, origin: 'ui' });
132
132
  }
133
133
  catch {
134
134
  // Keep UI stable if opening folder fails.
@@ -388,6 +388,7 @@ export function createMarkdownController(dependencies) {
388
388
  maxResults: 20,
389
389
  earlyTermination: false,
390
390
  literalSearch: true,
391
+ origin: 'ui',
391
392
  });
392
393
  const text = extractToolText(result) ?? '';
393
394
  const filePaths = parseFileSearchResults(text);
@@ -95,7 +95,7 @@ export function attachPanelActions(options) {
95
95
  file_extension: fileExtension,
96
96
  });
97
97
  try {
98
- await options.callTool?.('start_process', { command, timeout_ms: 12000 });
98
+ await options.callTool?.('start_process', { command, timeout_ms: 12000, origin: 'ui' });
99
99
  }
100
100
  catch {
101
101
  // Keep UI stable if opening folder fails.
@@ -116,7 +116,7 @@ export function attachPanelActions(options) {
116
116
  file_extension: fileExtension,
117
117
  });
118
118
  try {
119
- await options.callTool?.('start_process', { command, timeout_ms: 12000 });
119
+ await options.callTool?.('start_process', { command, timeout_ms: 12000, origin: 'ui' });
120
120
  }
121
121
  catch {
122
122
  // Keep UI stable if opening editor fails.
@@ -41,11 +41,27 @@ export function extractToolText(value) {
41
41
  }
42
42
  return undefined;
43
43
  }
44
- function extractStructuredContentText(value) {
44
+ // Join ALL non-empty text blocks, not just the first. Most reads return a
45
+ // single text block, but PDF reads return a summary block followed by
46
+ // per-page text blocks — taking only the first would drop the page text.
47
+ function extractJoinedToolText(value) {
45
48
  if (!isObjectRecord(value)) {
46
49
  return undefined;
47
50
  }
48
- return typeof value.content === 'string' ? value.content : undefined;
51
+ const content = value.content;
52
+ if (!Array.isArray(content)) {
53
+ return undefined;
54
+ }
55
+ const texts = [];
56
+ for (const item of content) {
57
+ if (isObjectRecord(item)
58
+ && item.type === 'text'
59
+ && typeof item.text === 'string'
60
+ && item.text.trim().length > 0) {
61
+ texts.push(item.text);
62
+ }
63
+ }
64
+ return texts.length > 0 ? texts.join('\n') : undefined;
49
65
  }
50
66
  export function extractRenderPayload(value) {
51
67
  if (!isObjectRecord(value)) {
@@ -58,11 +74,11 @@ export function extractRenderPayload(value) {
58
74
  : null;
59
75
  if (!meta)
60
76
  return undefined;
61
- const text = extractStructuredContentText(value.structuredContent)
62
- ?? extractToolText(value)
63
- ?? extractToolText(value.structuredContent)
64
- ?? '';
65
- return buildRenderPayload(meta, text);
77
+ // Content always comes from the read output's content[] text blocks; the
78
+ // structuredContent alongside it is metadata-only. Images arrive as a base64
79
+ // text block too (origin:'ui' reads carry no image block, so the host won't
80
+ // inline-render and stall the RPC).
81
+ return buildRenderPayload(meta, extractJoinedToolText(value) ?? '');
66
82
  }
67
83
  export function assertSuccessfulEditBlockResult(result) {
68
84
  if (!isObjectRecord(result)) {
@@ -1,3 +1,5 @@
1
+ export declare function runInUiOriginCallContext<T>(fn: () => T): T;
2
+ export declare function isInsideUiOriginCall(): boolean;
1
3
  /**
2
4
  * Hard kill-switch for telemetry via environment variable.
3
5
  *
@@ -1,7 +1,21 @@
1
1
  import { platform } from 'os';
2
2
  import * as https from 'https';
3
+ import { AsyncLocalStorage } from 'async_hooks';
3
4
  import { configManager, isTelemetryDisabledValue } from '../config-manager.js';
4
5
  import { currentClient, currentCallIsRemote, currentRemoteClient } from '../server.js';
6
+ // Execution context for tool calls fired programmatically by the widget UIs
7
+ // (file preview, config editor), marked by args.origin === 'ui'. While code
8
+ // runs inside this context, capture() drops every event, so UI refresh churn
9
+ // (pull-by-path reads, in-preview saves, folder expansion, link search, ...)
10
+ // produces zero telemetry. AsyncLocalStorage (rather than a module-level flag)
11
+ // keeps attribution correct when a widget call interleaves with an agent call.
12
+ const uiOriginCallContext = new AsyncLocalStorage();
13
+ export function runInUiOriginCallContext(fn) {
14
+ return uiOriginCallContext.run(true, fn);
15
+ }
16
+ export function isInsideUiOriginCall() {
17
+ return uiOriginCallContext.getStore() === true;
18
+ }
5
19
  let VERSION = 'unknown';
6
20
  try {
7
21
  const versionModule = await import('../version.js');
@@ -427,6 +441,11 @@ const postTelemetryPayload = async (endpoint, payload) => {
427
441
  // can be silently dropped. If we need delivery guarantees on short-lived paths,
428
442
  // expose an awaitable variant or flush-before-exit hook.
429
443
  export const capture = async (event, properties) => {
444
+ // Tool calls fired programmatically by the widget UIs must produce zero
445
+ // telemetry — drop every event raised while serving one.
446
+ if (isInsideUiOriginCall()) {
447
+ return;
448
+ }
430
449
  void (async () => {
431
450
  try {
432
451
  const eventProperties = await buildEventProperties(properties);
@@ -69,6 +69,8 @@ export interface ReadOptions {
69
69
  range?: string;
70
70
  /** Whether to include status messages (default: true) */
71
71
  includeStatusMessage?: boolean;
72
+ /** Optional AbortSignal to cancel an in-flight read (frees fd/thread on timeout). */
73
+ signal?: AbortSignal;
72
74
  }
73
75
  /**
74
76
  * Result from reading a file
@@ -14,7 +14,7 @@ export class ImageFileHandler {
14
14
  }
15
15
  async read(path, options) {
16
16
  // Images are always read in full, ignoring offset and length
17
- const buffer = await fs.readFile(path);
17
+ const buffer = await fs.readFile(path, { signal: options?.signal });
18
18
  const content = buffer.toString('base64');
19
19
  const mimeType = this.getMimeType(path);
20
20
  return {
@@ -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
  }
@@ -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
  }