@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
@@ -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.
@@ -146,7 +146,7 @@ export function attachDirectoryHandlers(options) {
146
146
  loadMoreBtn.querySelector('.dir-warning-text').textContent = 'Loading…';
147
147
  loadMoreBtn.disabled = true;
148
148
  try {
149
- const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1 });
149
+ const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1, origin: 'ui' });
150
150
  const text = extractToolText(result) ?? '';
151
151
  if (text) {
152
152
  const parsed = parseDirectoryEntries(text);
@@ -194,7 +194,7 @@ export function attachDirectoryHandlers(options) {
194
194
  if (chevron)
195
195
  chevron.textContent = '⏳';
196
196
  try {
197
- const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2 });
197
+ const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2, origin: 'ui' });
198
198
  const text = extractToolText(result) ?? '';
199
199
  if (text) {
200
200
  target.dataset.loaded = 'true';
@@ -222,7 +222,7 @@ export function attachDirectoryHandlers(options) {
222
222
  if (target.classList.contains('dir-row-file')) {
223
223
  target.classList.add('dir-loading');
224
224
  try {
225
- const result = await options.callTool?.('read_file', { path: fullPath });
225
+ const result = await options.callTool?.('read_file', { path: fullPath, origin: 'ui' });
226
226
  if (!result || typeof result !== 'object' || result === null) {
227
227
  return;
228
228
  }
@@ -16,13 +16,13 @@ function renderImageBody(payload) {
16
16
  html: '<div class="panel-content source-content"></div>',
17
17
  };
18
18
  }
19
- if (!payload.imageData || payload.imageData.trim().length === 0) {
19
+ if (!payload.content || payload.content.trim().length === 0) {
20
20
  return {
21
21
  notice: 'Preview is unavailable because image data is missing.',
22
22
  html: '<div class="panel-content source-content"></div>',
23
23
  };
24
24
  }
25
- const src = `data:${mimeType};base64,${payload.imageData}`;
25
+ const src = `data:${mimeType};base64,${payload.content}`;
26
26
  return {
27
27
  html: `<div class="panel-content image-content"><div class="image-preview"><img src="${escapeHtml(src)}" alt="${escapeHtml(payload.fileName)}" loading="eager" decoding="async"></div></div>`,
28
28
  };
@@ -281,6 +281,7 @@ export function createMarkdownController(dependencies) {
281
281
  const rawResult = await dependencies.callTool?.('read_file', {
282
282
  path: filePath,
283
283
  ...(typeof length === 'number' ? { offset: offset ?? 0, length } : {}),
284
+ origin: 'ui',
284
285
  });
285
286
  return { rawResult, payload: extractRenderPayload(rawResult) ?? null };
286
287
  }
@@ -360,7 +361,7 @@ export function createMarkdownController(dependencies) {
360
361
  const markers = new Set(['[DIR] .git', '[DIR] .obsidian', '[FILE] package.json', '[FILE] pnpm-workspace.yaml', '[FILE] turbo.json']);
361
362
  for (const ancestor of ancestors) {
362
363
  try {
363
- const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1 });
364
+ const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1, origin: 'ui' });
364
365
  const text = extractToolText(result) ?? '';
365
366
  const entries = splitListingLines(text);
366
367
  if (entries.some((entry) => markers.has(entry))) {
@@ -387,6 +388,7 @@ export function createMarkdownController(dependencies) {
387
388
  maxResults: 20,
388
389
  earlyTermination: false,
389
390
  literalSearch: true,
391
+ origin: 'ui',
390
392
  });
391
393
  const text = extractToolText(result) ?? '';
392
394
  const filePaths = parseFileSearchResults(text);
@@ -681,6 +683,7 @@ export function createMarkdownController(dependencies) {
681
683
  old_string: block.old_string,
682
684
  new_string: block.new_string,
683
685
  expected_replacements: 1,
686
+ origin: 'ui',
684
687
  });
685
688
  assertSuccessfulEditBlockResult(editResult);
686
689
  appliedCount++;
@@ -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.
@@ -144,8 +144,8 @@ export function attachPanelActions(options) {
144
144
  });
145
145
  try {
146
146
  const readArgs = direction === 'before'
147
- ? { path: options.payload.filePath, offset: 0, length: range.fromLine - 1 }
148
- : { path: options.payload.filePath, offset: range.toLine };
147
+ ? { path: options.payload.filePath, offset: 0, length: range.fromLine - 1, origin: 'ui' }
148
+ : { path: options.payload.filePath, offset: range.toLine, origin: 'ui' };
149
149
  const result = await options.callTool?.('read_file', readArgs);
150
150
  const newText = extractToolText(result);
151
151
  if (newText && typeof newText === 'string') {
@@ -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
- import { currentClient, currentCallIsRemote } from '../server.js';
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');
@@ -89,11 +103,14 @@ export const captureBase = async (captureURL, event, properties) => {
89
103
  uniqueUserId = await configManager.getOrCreateClientId();
90
104
  }
91
105
  // Get current client information for all events
106
+ // For remote calls, attribute to the originating remote client (carried on
107
+ // the tool call) instead of the device's local currentClient.
108
+ const effectiveClient = currentCallIsRemote && currentRemoteClient ? currentRemoteClient : currentClient;
92
109
  let clientContext = {};
93
- if (currentClient) {
110
+ if (effectiveClient) {
94
111
  clientContext = {
95
- client_name: currentClient.name,
96
- client_version: currentClient.version,
112
+ client_name: effectiveClient.name,
113
+ client_version: effectiveClient.version,
97
114
  };
98
115
  }
99
116
  // Track if user saw onboarding page
@@ -265,11 +282,14 @@ const buildEventProperties = async (properties) => {
265
282
  if (uniqueUserId === 'unknown') {
266
283
  uniqueUserId = await configManager.getOrCreateClientId();
267
284
  }
285
+ // For remote calls, attribute to the originating remote client (carried on
286
+ // the tool call) instead of the device's local currentClient.
287
+ const effectiveClient = currentCallIsRemote && currentRemoteClient ? currentRemoteClient : currentClient;
268
288
  let clientContext = {};
269
- if (currentClient) {
289
+ if (effectiveClient) {
270
290
  clientContext = {
271
- client_name: currentClient.name,
272
- client_version: currentClient.version,
291
+ client_name: effectiveClient.name,
292
+ client_version: effectiveClient.version,
273
293
  };
274
294
  }
275
295
  const sawOnboardingPage = await configManager.getValue('sawOnboardingPage');
@@ -421,6 +441,11 @@ const postTelemetryPayload = async (endpoint, payload) => {
421
441
  // can be silently dropped. If we need delivery guarantees on short-lived paths,
422
442
  // expose an awaitable variant or flush-before-exit hook.
423
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
+ }
424
449
  void (async () => {
425
450
  try {
426
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 {