@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threadpool bootstrap. MUST be the first import in index.ts.
|
|
3
|
+
*
|
|
4
|
+
* Every fs operation (read_file, write_file, edit_block, config/history/log
|
|
5
|
+
* persistence) runs on libuv's threadpool, which defaults to only 4 threads.
|
|
6
|
+
* Under heavy parallel load — e.g. several agents reading/writing files on a
|
|
7
|
+
* slow or cloud-synced filesystem — 4 stalled operations exhaust the pool, and
|
|
8
|
+
* because a stalled syscall keeps its thread until the OS returns (a JS-level
|
|
9
|
+
* timeout does not cancel it), every subsequent fs op queues for minutes. That
|
|
10
|
+
* surfaced as multi-minute tool-call hangs under parallel `claude -p` load.
|
|
11
|
+
*
|
|
12
|
+
* Raising the pool size gives enough headroom that a burst of slow reads no
|
|
13
|
+
* longer starves the rest. libuv reads UV_THREADPOOL_SIZE only when the pool is
|
|
14
|
+
* first initialized (on first submitted work), so this assignment has to happen
|
|
15
|
+
* before ANY threadpool work — hence "first import". A user-provided value is
|
|
16
|
+
* always respected.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_THREADPOOL_SIZE = 16;
|
|
19
|
+
if (!process.env.UV_THREADPOOL_SIZE) {
|
|
20
|
+
process.env.UV_THREADPOOL_SIZE = String(DEFAULT_THREADPOOL_SIZE);
|
|
21
|
+
}
|
|
22
|
+
export {};
|
package/dist/config-manager.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ declare class ConfigManager {
|
|
|
23
23
|
private config;
|
|
24
24
|
private initialized;
|
|
25
25
|
private _isFirstRun;
|
|
26
|
+
private writeChain;
|
|
27
|
+
private saveScheduled;
|
|
26
28
|
constructor();
|
|
27
29
|
/**
|
|
28
30
|
* Initialize configuration - load from disk or create default
|
|
@@ -37,9 +39,26 @@ declare class ConfigManager {
|
|
|
37
39
|
*/
|
|
38
40
|
private getDefaultConfig;
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
42
|
+
* Write the current in-memory config to disk. All writes funnel through
|
|
43
|
+
* writeChain (see saveConfig / scheduleSave) so overlapping saves can never
|
|
44
|
+
* interleave and corrupt the file. Previously every tool call could fire its
|
|
45
|
+
* own independent fs.writeFile of the same path.
|
|
46
|
+
*/
|
|
47
|
+
private writeConfigToDisk;
|
|
48
|
+
/**
|
|
49
|
+
* Awaitable save, serialized on writeChain. Use for explicit, user-driven
|
|
50
|
+
* config changes where the caller wants on-disk confirmation.
|
|
41
51
|
*/
|
|
42
52
|
private saveConfig;
|
|
53
|
+
/**
|
|
54
|
+
* Non-blocking, coalesced save. Returns immediately; the write runs in the
|
|
55
|
+
* background. A burst of calls collapses to at most one queued write behind
|
|
56
|
+
* the in-flight one, so a storm of tool calls can't storm the disk — and,
|
|
57
|
+
* critically, can't pile up behind a saturated libuv threadpool and gate the
|
|
58
|
+
* tool-call response path. Used for high-frequency, non-critical persistence
|
|
59
|
+
* such as usage stats.
|
|
60
|
+
*/
|
|
61
|
+
scheduleSave(): void;
|
|
43
62
|
/**
|
|
44
63
|
* Get the entire config
|
|
45
64
|
*/
|
|
@@ -52,6 +71,16 @@ declare class ConfigManager {
|
|
|
52
71
|
* Set a specific configuration value
|
|
53
72
|
*/
|
|
54
73
|
setValue(key: string, value: any): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Update a value in memory and persist it WITHOUT blocking the caller.
|
|
76
|
+
* The tool-call response path must never wait on a disk write: when the libuv
|
|
77
|
+
* threadpool is saturated (e.g. many parallel reads stalled on a slow/cloud
|
|
78
|
+
* filesystem) an awaited write can't get a thread and would hang the response
|
|
79
|
+
* of even pure-memory tools. The in-memory value is updated synchronously so
|
|
80
|
+
* subsequent reads see it immediately; the write is coalesced in the
|
|
81
|
+
* background. Callers needing on-disk confirmation should use setValue.
|
|
82
|
+
*/
|
|
83
|
+
setValueNonBlocking(key: string, value: any): Promise<void>;
|
|
55
84
|
/**
|
|
56
85
|
* Update multiple configuration values at once
|
|
57
86
|
*/
|
package/dist/config-manager.js
CHANGED
|
@@ -29,6 +29,10 @@ class ConfigManager {
|
|
|
29
29
|
this.config = {};
|
|
30
30
|
this.initialized = false;
|
|
31
31
|
this._isFirstRun = false; // Track if this is the first run (config was just created)
|
|
32
|
+
// Serializes all disk writes so concurrent saves can't corrupt config.json.
|
|
33
|
+
this.writeChain = Promise.resolve();
|
|
34
|
+
// True while a coalesced background write is already queued (see scheduleSave).
|
|
35
|
+
this.saveScheduled = false;
|
|
32
36
|
// Get user's home directory
|
|
33
37
|
// Define config directory and file paths
|
|
34
38
|
this.configPath = CONFIG_FILE;
|
|
@@ -140,16 +144,45 @@ class ConfigManager {
|
|
|
140
144
|
};
|
|
141
145
|
}
|
|
142
146
|
/**
|
|
143
|
-
*
|
|
147
|
+
* Write the current in-memory config to disk. All writes funnel through
|
|
148
|
+
* writeChain (see saveConfig / scheduleSave) so overlapping saves can never
|
|
149
|
+
* interleave and corrupt the file. Previously every tool call could fire its
|
|
150
|
+
* own independent fs.writeFile of the same path.
|
|
151
|
+
*/
|
|
152
|
+
async writeConfigToDisk() {
|
|
153
|
+
await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Awaitable save, serialized on writeChain. Use for explicit, user-driven
|
|
157
|
+
* config changes where the caller wants on-disk confirmation.
|
|
144
158
|
*/
|
|
145
159
|
async saveConfig() {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
160
|
+
const write = this.writeChain.then(() => this.writeConfigToDisk());
|
|
161
|
+
// Keep the chain alive even if this write rejects, so later writes still run.
|
|
162
|
+
this.writeChain = write.catch(() => { });
|
|
163
|
+
return write;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Non-blocking, coalesced save. Returns immediately; the write runs in the
|
|
167
|
+
* background. A burst of calls collapses to at most one queued write behind
|
|
168
|
+
* the in-flight one, so a storm of tool calls can't storm the disk — and,
|
|
169
|
+
* critically, can't pile up behind a saturated libuv threadpool and gate the
|
|
170
|
+
* tool-call response path. Used for high-frequency, non-critical persistence
|
|
171
|
+
* such as usage stats.
|
|
172
|
+
*/
|
|
173
|
+
scheduleSave() {
|
|
174
|
+
if (this.saveScheduled)
|
|
175
|
+
return; // a queued write will capture the latest config
|
|
176
|
+
this.saveScheduled = true;
|
|
177
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
178
|
+
this.saveScheduled = false; // let the next burst queue a fresh write
|
|
179
|
+
try {
|
|
180
|
+
await this.writeConfigToDisk();
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
console.error('Failed to save config (background):', error);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
153
186
|
}
|
|
154
187
|
/**
|
|
155
188
|
* Get the entire config
|
|
@@ -194,6 +227,20 @@ class ConfigManager {
|
|
|
194
227
|
this.config[key] = value;
|
|
195
228
|
await this.saveConfig();
|
|
196
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Update a value in memory and persist it WITHOUT blocking the caller.
|
|
232
|
+
* The tool-call response path must never wait on a disk write: when the libuv
|
|
233
|
+
* threadpool is saturated (e.g. many parallel reads stalled on a slow/cloud
|
|
234
|
+
* filesystem) an awaited write can't get a thread and would hang the response
|
|
235
|
+
* of even pure-memory tools. The in-memory value is updated synchronously so
|
|
236
|
+
* subsequent reads see it immediately; the write is coalesced in the
|
|
237
|
+
* background. Callers needing on-disk confirmation should use setValue.
|
|
238
|
+
*/
|
|
239
|
+
async setValueNonBlocking(key, value) {
|
|
240
|
+
await this.init();
|
|
241
|
+
this.config[key] = value;
|
|
242
|
+
this.scheduleSave();
|
|
243
|
+
}
|
|
197
244
|
/**
|
|
198
245
|
* Update multiple configuration values at once
|
|
199
246
|
*/
|
|
@@ -41,7 +41,12 @@ function getErrorFromPath(path) {
|
|
|
41
41
|
* Handle read_file command
|
|
42
42
|
*/
|
|
43
43
|
export async function handleReadFile(args) {
|
|
44
|
-
|
|
44
|
+
// Backstop for the whole handler operation. The real control is the
|
|
45
|
+
// 3-minute cancellable read timeout inside readFileFromDisk; this sits just
|
|
46
|
+
// above it (so that one fires first, with cleanup + a useful error) but
|
|
47
|
+
// still below the MCP client's ~4-minute hard cap, so we never leave the
|
|
48
|
+
// client hanging on an opaque timeout.
|
|
49
|
+
const HANDLER_TIMEOUT = 3.5 * 60 * 1000; // 3m30s
|
|
45
50
|
// Add input validation
|
|
46
51
|
if (args === null || args === undefined) {
|
|
47
52
|
return createErrorResponse('No arguments provided for read_file command');
|
|
@@ -95,28 +100,47 @@ export async function handleReadFile(args) {
|
|
|
95
100
|
},
|
|
96
101
|
...pdfContent
|
|
97
102
|
],
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.map((item) => item.text)
|
|
107
|
-
.join("\n"),
|
|
108
|
-
},
|
|
103
|
+
...(parsed.origin === 'ui' ? {
|
|
104
|
+
structuredContent: {
|
|
105
|
+
fileName: path.basename(resolvedFilePath),
|
|
106
|
+
filePath: resolvedFilePath,
|
|
107
|
+
fileType: 'unsupported',
|
|
108
|
+
...await getDefaultEditorMetadata(resolvedFilePath),
|
|
109
|
+
},
|
|
110
|
+
} : {}),
|
|
109
111
|
};
|
|
110
112
|
}
|
|
111
113
|
// Handle image files
|
|
112
114
|
if (fileResult.metadata?.isImage) {
|
|
113
115
|
// Return the image bytes in the MCP content array so the host model can
|
|
114
|
-
// actually see the image.
|
|
115
|
-
//
|
|
116
|
+
// actually see the image. The preview widget gets its copy from its own
|
|
117
|
+
// origin:'ui' read — structuredContent stays metadata-only.
|
|
116
118
|
const imageData = typeof fileResult.content === 'string'
|
|
117
119
|
? fileResult.content
|
|
118
120
|
: fileResult.content.toString('base64');
|
|
119
121
|
const imageSummary = `Image file: ${parsed.path} (${fileResult.mimeType})\n`;
|
|
122
|
+
const imageStructuredContent = {
|
|
123
|
+
fileName: path.basename(resolvedFilePath),
|
|
124
|
+
filePath: resolvedFilePath,
|
|
125
|
+
fileType: 'image',
|
|
126
|
+
...await getDefaultEditorMetadata(resolvedFilePath),
|
|
127
|
+
mimeType: fileResult.mimeType,
|
|
128
|
+
};
|
|
129
|
+
// Widget pull (origin: 'ui'): return the base64 in a TEXT block, never
|
|
130
|
+
// an image block. An image content block makes the host inline-vision-
|
|
131
|
+
// render the result, which preempts delivery of this RPC response to
|
|
132
|
+
// the widget — leaving the preview stuck on "Preparing preview…" for
|
|
133
|
+
// exactly the host-rendered types (png/jpeg/gif/webp). Text-only lets
|
|
134
|
+
// the RPC response through so the widget can draw the <img>.
|
|
135
|
+
if (parsed.origin === 'ui') {
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: imageData }],
|
|
138
|
+
structuredContent: imageStructuredContent,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Model-facing read: keep the image block so the host/model sees it.
|
|
142
|
+
// No structuredContent — the widget renders from its own origin:'ui'
|
|
143
|
+
// read, and nothing else consumes it.
|
|
120
144
|
return {
|
|
121
145
|
content: [
|
|
122
146
|
{
|
|
@@ -129,37 +153,32 @@ export async function handleReadFile(args) {
|
|
|
129
153
|
mimeType: fileResult.mimeType
|
|
130
154
|
}
|
|
131
155
|
],
|
|
132
|
-
structuredContent: {
|
|
133
|
-
fileName: path.basename(resolvedFilePath),
|
|
134
|
-
filePath: resolvedFilePath,
|
|
135
|
-
fileType: 'image',
|
|
136
|
-
sourceTool: 'read_file',
|
|
137
|
-
...await getDefaultEditorMetadata(resolvedFilePath),
|
|
138
|
-
// Image bytes for the preview widget, carried once. Deduped from the
|
|
139
|
-
// old `content` + `imageData` pair down to this single `content` field.
|
|
140
|
-
content: imageData,
|
|
141
|
-
mimeType: fileResult.mimeType
|
|
142
|
-
}
|
|
143
156
|
};
|
|
144
157
|
}
|
|
145
158
|
else {
|
|
146
159
|
// For all other files, return as text.
|
|
147
160
|
// structuredContent carries only file metadata (no content duplication);
|
|
148
|
-
// the widget reads text from the MCP content array.
|
|
149
|
-
|
|
161
|
+
// the widget reads text from the MCP content array (over the RPC read).
|
|
162
|
+
let textContent = typeof fileResult.content === 'string'
|
|
150
163
|
? fileResult.content
|
|
151
164
|
: fileResult.content.toString('utf8');
|
|
152
165
|
const fileType = fileResult.metadata?.isDirectory ? 'directory' : resolvePreviewFileType(resolvedFilePath);
|
|
166
|
+
// The directory fallback prefixes a "use list_directory instead" hint
|
|
167
|
+
// for the LLM. The widget's own read (a list_directory preview pulls
|
|
168
|
+
// read_file on the dir path) would render that hint as a notice — strip it.
|
|
169
|
+
if (parsed.origin === 'ui' && fileType === 'directory') {
|
|
170
|
+
textContent = textContent.replace(/^This is a directory, not a file\.[^\n]*\n+/, '');
|
|
171
|
+
}
|
|
153
172
|
return {
|
|
154
173
|
content: [{ type: "text", text: textContent }],
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
},
|
|
174
|
+
...(parsed.origin === 'ui' ? {
|
|
175
|
+
structuredContent: {
|
|
176
|
+
fileName: path.basename(resolvedFilePath),
|
|
177
|
+
filePath: resolvedFilePath,
|
|
178
|
+
fileType,
|
|
179
|
+
...await getDefaultEditorMetadata(resolvedFilePath),
|
|
180
|
+
},
|
|
181
|
+
} : {}),
|
|
163
182
|
};
|
|
164
183
|
}
|
|
165
184
|
};
|
|
@@ -238,7 +257,30 @@ export async function handleReadMultipleFiles(args) {
|
|
|
238
257
|
*/
|
|
239
258
|
export async function handleWriteFile(args) {
|
|
240
259
|
try {
|
|
260
|
+
const modeProvided = !!(args && typeof args === 'object' && 'mode' in args);
|
|
241
261
|
const parsed = WriteFileArgsSchema.parse(args);
|
|
262
|
+
// An omitted `mode` falls back to 'rewrite', which silently destroys
|
|
263
|
+
// the target's existing content. The common failure is an LLM caller
|
|
264
|
+
// intending to append but dropping the optional param (issue #541), so
|
|
265
|
+
// require explicit intent when the target already has content;
|
|
266
|
+
// new/empty files keep the zero-friction default.
|
|
267
|
+
if (!modeProvided) {
|
|
268
|
+
let existing = null;
|
|
269
|
+
try {
|
|
270
|
+
existing = await getFileInfo(parsed.path);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Missing file or invalid path — nothing to protect; the write
|
|
274
|
+
// itself will surface any real path error.
|
|
275
|
+
}
|
|
276
|
+
if (existing?.isFile && existing.size > 0) {
|
|
277
|
+
return createErrorResponse(`Write rejected to prevent accidental data loss: ${parsed.path} already exists ` +
|
|
278
|
+
`with content (${existing.size} bytes), and no 'mode' was specified — the default ` +
|
|
279
|
+
`mode 'rewrite' would REPLACE the entire file. Retry with an explicit mode: ` +
|
|
280
|
+
`'append' to add your content to the end of the existing file, or 'rewrite' ` +
|
|
281
|
+
`to replace all existing content.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
242
284
|
// Get the line limit from configuration
|
|
243
285
|
const config = await configManager.getConfig();
|
|
244
286
|
const MAX_LINES = config.fileWriteLineLimit ?? 50; // Default to 50 if not set
|
|
@@ -255,19 +297,13 @@ export async function handleWriteFile(args) {
|
|
|
255
297
|
await writeFile(parsed.path, parsed.content, parsed.mode);
|
|
256
298
|
// Provide more informative message based on mode
|
|
257
299
|
const modeMessage = parsed.mode === 'append' ? 'appended to' : 'wrote to';
|
|
258
|
-
|
|
300
|
+
// No structuredContent: nothing in the UI calls write_file (the widget
|
|
301
|
+
// previews the write via its own read_file pull at tool-result time).
|
|
259
302
|
return {
|
|
260
303
|
content: [{
|
|
261
304
|
type: "text",
|
|
262
305
|
text: `Successfully ${modeMessage} ${parsed.path} (${lineCount} lines) ${errorMessage}`
|
|
263
306
|
}],
|
|
264
|
-
structuredContent: {
|
|
265
|
-
fileName: path.basename(resolvedWritePath),
|
|
266
|
-
filePath: resolvedWritePath,
|
|
267
|
-
fileType: resolvePreviewFileType(resolvedWritePath),
|
|
268
|
-
sourceTool: 'write_file',
|
|
269
|
-
...await getDefaultEditorMetadata(resolvedWritePath),
|
|
270
|
-
},
|
|
271
307
|
};
|
|
272
308
|
}
|
|
273
309
|
catch (error) {
|
|
@@ -304,16 +340,13 @@ export async function handleListDirectory(args) {
|
|
|
304
340
|
const resolvedPath = resolveAbsolutePath(parsed.path);
|
|
305
341
|
return {
|
|
306
342
|
content: [{ type: "text", text: resultText }],
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// render from here and would otherwise show an empty directory.
|
|
315
|
-
content: resultText,
|
|
316
|
-
},
|
|
343
|
+
...(parsed.origin === 'ui' ? {
|
|
344
|
+
structuredContent: {
|
|
345
|
+
fileName: path.basename(resolvedPath),
|
|
346
|
+
filePath: resolvedPath,
|
|
347
|
+
fileType: 'directory',
|
|
348
|
+
},
|
|
349
|
+
} : {}),
|
|
317
350
|
};
|
|
318
351
|
}
|
|
319
352
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import './bootstrap.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// MUST be first: raises the libuv threadpool size before any fs work is
|
|
3
|
+
// submitted. See src/bootstrap.ts for why import order matters.
|
|
4
|
+
import './bootstrap.js';
|
|
2
5
|
import { FilteredStdioServerTransport } from './custom-stdio.js';
|
|
3
6
|
import { server, flushDeferredMessages } from './server.js';
|
|
4
7
|
import { configManager } from './config-manager.js';
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
5
|
+
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { captureRemote } from '../utils/capture.js';
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -24,7 +24,13 @@ export class DesktopCommanderIntegration {
|
|
|
24
24
|
console.debug('[DEBUG] MCP config:', JSON.stringify(config, null, 2));
|
|
25
25
|
try {
|
|
26
26
|
console.debug('[DEBUG] Creating StdioClientTransport');
|
|
27
|
-
|
|
27
|
+
// DC_REMOTE_DEVICE tells the spawned server it is serving remote
|
|
28
|
+
// services, so it suppresses local-only behavior like opening the
|
|
29
|
+
// welcome page in a browser the remote user would never see.
|
|
30
|
+
this.mcpTransport = new StdioClientTransport({
|
|
31
|
+
...config,
|
|
32
|
+
env: { ...getDefaultEnvironment(), ...config.env, DC_REMOTE_DEVICE: 'true' }
|
|
33
|
+
});
|
|
28
34
|
// Create MCP client
|
|
29
35
|
console.debug('[DEBUG] Creating MCP Client');
|
|
30
36
|
this.mcpClient = new Client({
|
|
@@ -4,6 +4,14 @@ const HEARTBEAT_INTERVAL = 15000;
|
|
|
4
4
|
// Cap a single channel recreate so a hung await can't pin the re-entrancy guard
|
|
5
5
|
// true (which would silently disable the connection watchdog).
|
|
6
6
|
const RECREATE_TIMEOUT_MS = 30000;
|
|
7
|
+
// Max time the channel may sit CONTINUOUSLY in 'joining' before we force a recreate.
|
|
8
|
+
// 'joining' is normally healthy (we let realtime-js's rejoin backoff converge), but on a
|
|
9
|
+
// HALF-OPEN socket (readyState OPEN yet dead) realtime-js parks the channel in 'joining'
|
|
10
|
+
// forever and never reconnects the socket — the device then wedges offline silently with
|
|
11
|
+
// no recreate firing. realtime-js's join push times out in ~10s, so a genuine join
|
|
12
|
+
// resolves/errors well within this window; 3 health ticks of unbroken 'joining' means the
|
|
13
|
+
// state machine has stalled and only a fresh socket (via recreate) recovers it.
|
|
14
|
+
const JOINING_WEDGE_TIMEOUT_MS = 30000;
|
|
7
15
|
export class RemoteChannel {
|
|
8
16
|
constructor() {
|
|
9
17
|
this.client = null;
|
|
@@ -20,6 +28,7 @@ export class RemoteChannel {
|
|
|
20
28
|
// Reconnect diagnostics + guard (see connState() / recreateChannel())
|
|
21
29
|
this.reconnectAttempt = 0; // recreateChannel() attempts since last success
|
|
22
30
|
this.isRecreatingChannel = false; // a recreate is in flight (re-entrancy guard)
|
|
31
|
+
this.joiningSince = null; // ts the channel entered an unbroken 'joining' run; null when not joining
|
|
23
32
|
this._user = null;
|
|
24
33
|
}
|
|
25
34
|
get user() { return this._user; }
|
|
@@ -232,12 +241,33 @@ export class RemoteChannel {
|
|
|
232
241
|
console.debug(`[DEBUG] channel state: ${state} — ${this.connState()}`);
|
|
233
242
|
this.lastChannelState = state;
|
|
234
243
|
}
|
|
235
|
-
// 'joined' = healthy
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (state === 'joined' || state === 'joining')
|
|
244
|
+
// 'joined' = healthy. Clear the joining-overstay timer.
|
|
245
|
+
if (state === 'joined') {
|
|
246
|
+
this.joiningSince = null;
|
|
239
247
|
return;
|
|
248
|
+
}
|
|
249
|
+
// 'joining' = transitional — normally let realtime-js's own rejoin backoff converge
|
|
250
|
+
// instead of tearing the channel down mid-join (recreating on every non-joined state
|
|
251
|
+
// amputates that backoff). BUT bound it: on a half-open socket realtime-js can park
|
|
252
|
+
// the channel in 'joining' indefinitely without ever reconnecting the socket, so the
|
|
253
|
+
// recreate below would never fire and the device wedges offline silently. If 'joining'
|
|
254
|
+
// overstays JOINING_WEDGE_TIMEOUT_MS unbroken, force a recreate — the only path that
|
|
255
|
+
// disconnect()s the dead socket. (connState() in the log shows the half-open socket.)
|
|
256
|
+
if (state === 'joining') {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
if (this.joiningSince === null)
|
|
259
|
+
this.joiningSince = now;
|
|
260
|
+
const stuckMs = now - this.joiningSince;
|
|
261
|
+
if (stuckMs < JOINING_WEDGE_TIMEOUT_MS)
|
|
262
|
+
return;
|
|
263
|
+
console.debug(`[DEBUG] ⚠️ Channel stuck 'joining' ${Math.round(stuckMs / 1000)}s - forcing recreate — ${this.connState()}`);
|
|
264
|
+
captureRemote('remote_channel_joining_wedge', { stuckMs, attempt: this.reconnectAttempt });
|
|
265
|
+
this.joiningSince = null;
|
|
266
|
+
this.recreateChannel();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
240
269
|
// Unhealthy: closed, errored, leaving — recreate
|
|
270
|
+
this.joiningSince = null;
|
|
241
271
|
captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt });
|
|
242
272
|
console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... — ${this.connState()}`);
|
|
243
273
|
this.recreateChannel();
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'path';
|
|
1
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, InitializeRequestSchema, LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
4
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
@@ -20,7 +21,7 @@ import { processDockerPrompt } from './utils/dockerPrompt.js';
|
|
|
20
21
|
import { toolHistory } from './utils/toolHistory.js';
|
|
21
22
|
import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
|
|
22
23
|
import { VERSION } from './version.js';
|
|
23
|
-
import { capture, capture_call_tool } from "./utils/capture.js";
|
|
24
|
+
import { capture, capture_call_tool, runInUiOriginCallContext } from "./utils/capture.js";
|
|
24
25
|
import { logToStderr, logger } from './utils/logger.js';
|
|
25
26
|
import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI, } from './ui/contracts.js';
|
|
26
27
|
import { listUiResources, readUiResource } from './ui/resources.js';
|
|
@@ -100,6 +101,15 @@ let currentRemoteClient = null;
|
|
|
100
101
|
function setCurrentRemoteClient(clientInfo) {
|
|
101
102
|
currentRemoteClient = clientInfo;
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* True when this server instance is serving remote services rather than a
|
|
106
|
+
* local MCP client. The remote-device wrapper marks the server it spawns with
|
|
107
|
+
* DC_REMOTE_DEVICE=true (see remote-device/desktop-commander-integration.ts);
|
|
108
|
+
* the client-name check covers older wrappers that predate the env marker.
|
|
109
|
+
*/
|
|
110
|
+
function isRemoteClientContext(clientName) {
|
|
111
|
+
return process.env.DC_REMOTE_DEVICE === 'true' || clientName === 'desktop-commander-client';
|
|
112
|
+
}
|
|
103
113
|
/**
|
|
104
114
|
* Unified way to update client information
|
|
105
115
|
*/
|
|
@@ -128,13 +138,33 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
|
|
128
138
|
const clientInfo = request.params?.clientInfo;
|
|
129
139
|
if (clientInfo) {
|
|
130
140
|
await updateCurrentClient(clientInfo);
|
|
131
|
-
// Welcome page for new
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
// Welcome page for new users (A/B test controlled) — all clients except
|
|
142
|
+
// the Desktop Commander app and remote contexts, where the server is
|
|
143
|
+
// spawned by the remote-device wrapper and a locally opened browser
|
|
144
|
+
// would never reach the remote user.
|
|
145
|
+
if (currentClient.name !== 'desktop-commander-app'
|
|
146
|
+
&& currentClient.name !== 'desktop-commander'
|
|
147
|
+
&& !isRemoteClientContext(currentClient.name)
|
|
148
|
+
&& !global.disableOnboarding) {
|
|
149
|
+
await handleWelcomePageOnboarding(currentClient.name);
|
|
135
150
|
}
|
|
136
151
|
}
|
|
137
|
-
|
|
152
|
+
// Raw host environment signals (no PII, undefined when absent). Some
|
|
153
|
+
// hosts share a clientInfo name — Claude Code CLI, Claude Code inside
|
|
154
|
+
// the Claude Desktop app, and Cowork all report 'claude-code' — and
|
|
155
|
+
// these let analytics tell them apart without client-specific
|
|
156
|
+
// branching in code. Verified signatures: CLI → entrypoint 'cli';
|
|
157
|
+
// CC-in-desktop → entrypoint 'claude-desktop'; Cowork → no
|
|
158
|
+
// entrypoint/agent, plugin id 'desktop-commander-inline'.
|
|
159
|
+
// Values truncated to GA4's 100-char param limit (same convention as
|
|
160
|
+
// containerName/containerImage) so an oversized value can never get
|
|
161
|
+
// the whole event rejected.
|
|
162
|
+
capture('run_server_mcp_initialized', {
|
|
163
|
+
host_entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT?.substring(0, 100),
|
|
164
|
+
host_agent: process.env.AI_AGENT?.substring(0, 100),
|
|
165
|
+
host_plugin_id: process.env.CLAUDE_PLUGIN_DATA
|
|
166
|
+
? path.basename(process.env.CLAUDE_PLUGIN_DATA).substring(0, 100) : undefined
|
|
167
|
+
});
|
|
138
168
|
// Negotiate protocol version with client
|
|
139
169
|
const requestedVersion = request.params?.protocolVersion;
|
|
140
170
|
const protocolVersion = (requestedVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion))
|
|
@@ -1120,6 +1150,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1120
1150
|
});
|
|
1121
1151
|
import * as handlers from './handlers/index.js';
|
|
1122
1152
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1153
|
+
const args = request.params.arguments;
|
|
1154
|
+
// Calls fired programmatically by the widget UIs (file preview, config
|
|
1155
|
+
// editor) carry origin:'ui'. They are real tool executions but not agent
|
|
1156
|
+
// actions, so they must produce zero telemetry: running them inside the
|
|
1157
|
+
// UI-origin capture context makes capture() drop every event they raise
|
|
1158
|
+
// (server_call_tool, server_read_file, server_edit_block, ...). Deliberate
|
|
1159
|
+
// UI interactions are tracked separately via mcp_ui_event.
|
|
1160
|
+
const isUiOriginCall = !!(args && typeof args === 'object' && args.origin === 'ui');
|
|
1161
|
+
if (isUiOriginCall) {
|
|
1162
|
+
return runInUiOriginCallContext(() => handleCallToolRequest(request));
|
|
1163
|
+
}
|
|
1164
|
+
return handleCallToolRequest(request);
|
|
1165
|
+
});
|
|
1166
|
+
async function handleCallToolRequest(request) {
|
|
1123
1167
|
const { name, arguments: args } = request.params;
|
|
1124
1168
|
const startTime = Date.now();
|
|
1125
1169
|
// Hoisted above the try so the finally block can read them when emitting the
|
|
@@ -1159,16 +1203,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1159
1203
|
}
|
|
1160
1204
|
if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
|
|
1161
1205
|
telemetryData.set_config_value_key_name = args.key;
|
|
1162
|
-
telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
|
|
1163
|
-
}
|
|
1164
|
-
// Attribute file-surface tool calls to the file-preview UI vs the LLM.
|
|
1165
|
-
// The UI passes origin:'ui' when it fires these tools to refresh/navigate
|
|
1166
|
-
// (pagination, folder expansion, link resolution, in-preview edits); the
|
|
1167
|
-
// first read_file that opens a file comes from the LLM and is recorded as
|
|
1168
|
-
// 'llm'. Lets us separate UI-refresh churn from genuine model-driven reads.
|
|
1169
|
-
if ((name === 'read_file' || name === 'write_file' || name === 'edit_block' || name === 'list_directory') &&
|
|
1170
|
-
args && typeof args === 'object') {
|
|
1171
|
-
telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
|
|
1172
1206
|
}
|
|
1173
1207
|
if (name === 'get_prompts' && args && typeof args === 'object') {
|
|
1174
1208
|
const promptArgs = args;
|
|
@@ -1505,12 +1539,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1505
1539
|
// Single tool-call telemetry event, fired AFTER execution so it can carry
|
|
1506
1540
|
// timing. In a finally so it still fires on the hard-crash path (the catch
|
|
1507
1541
|
// above). Only missed if a tool never returns or throws (a true hang).
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1542
|
+
// Not emitted for track_ui_event (it is just the transport for
|
|
1543
|
+
// mcp_ui_event) — and UI-origin calls are dropped wholesale by the
|
|
1544
|
+
// capture layer, so server_call_tool reflects only genuine
|
|
1545
|
+
// agent-driven tool calls.
|
|
1546
|
+
if (name !== 'track_ui_event') {
|
|
1547
|
+
capture_call_tool('server_call_tool', {
|
|
1548
|
+
...telemetryData,
|
|
1549
|
+
duration_ms: Date.now() - startTime,
|
|
1550
|
+
is_error: String(isError),
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1513
1553
|
}
|
|
1514
|
-
}
|
|
1554
|
+
}
|
|
1515
1555
|
// Add no-op handlers so Visual Studio initialization succeeds
|
|
1516
1556
|
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface SearchReplace {
|
|
|
19
19
|
search: string;
|
|
20
20
|
replace: string;
|
|
21
21
|
}
|
|
22
|
-
export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number): Promise<ServerResult>;
|
|
22
|
+
export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number, origin?: 'ui' | 'llm'): Promise<ServerResult>;
|
|
23
23
|
/**
|
|
24
24
|
* Handle edit_block command
|
|
25
25
|
*
|