@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.
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +22 -0
- package/dist/config-manager.d.ts +30 -1
- package/dist/config-manager.js +55 -8
- package/dist/handlers/filesystem-handlers.js +86 -53
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -0
- package/dist/remote-device/desktop-commander-integration.js +8 -2
- package/dist/remote-device/remote-channel.d.ts +1 -0
- package/dist/remote-device/remote-channel.js +34 -4
- package/dist/server.js +62 -22
- package/dist/tools/edit.d.ts +1 -1
- package/dist/tools/edit.js +30 -23
- package/dist/tools/filesystem.d.ts +1 -0
- package/dist/tools/filesystem.js +23 -13
- package/dist/tools/schemas.d.ts +19 -7
- package/dist/tools/schemas.js +20 -5
- package/dist/ui/config-editor/config-editor-runtime.js +49 -49
- package/dist/ui/config-editor/src/app.js +2 -11
- package/dist/ui/file-preview/preview-runtime.js +147 -146
- package/dist/ui/file-preview/src/app.js +172 -53
- package/dist/ui/file-preview/src/directory-controller.js +1 -1
- package/dist/ui/file-preview/src/markdown/controller.js +1 -0
- package/dist/ui/file-preview/src/panel-actions.js +2 -2
- package/dist/ui/file-preview/src/payload-utils.js +23 -7
- package/dist/utils/capture.d.ts +2 -0
- package/dist/utils/capture.js +19 -0
- package/dist/utils/files/base.d.ts +2 -0
- package/dist/utils/files/image.js +1 -1
- package/dist/utils/files/text.js +24 -19
- package/dist/utils/open-browser.d.ts +1 -1
- package/dist/utils/open-browser.js +5 -2
- package/dist/utils/usageTracker.d.ts +5 -1
- package/dist/utils/usageTracker.js +14 -3
- package/dist/utils/welcome-onboarding.d.ts +1 -1
- package/dist/utils/welcome-onboarding.js +2 -2
- package/dist/utils/withTimeout.d.ts +17 -0
- package/dist/utils/withTimeout.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
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 (
|
|
144
|
-
return
|
|
195
|
+
if (pullInFlightPath === filePath) {
|
|
196
|
+
return;
|
|
145
197
|
}
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
async function readAndResolvePayload(payload, onReady) {
|
|
198
|
+
pullInFlightPath = filePath;
|
|
149
199
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 (
|
|
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
|
-
|
|
575
|
+
renderStatusState(container, message);
|
|
576
|
+
onRender?.();
|
|
463
577
|
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return buildRenderPayload(meta,
|
|
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)) {
|
package/dist/utils/capture.d.ts
CHANGED
package/dist/utils/capture.js
CHANGED
|
@@ -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 {
|
package/dist/utils/files/text.js
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|