@hcu-lab.me/mcp-device 1.0.5 → 1.0.7

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 (57) hide show
  1. package/README.md +7 -71
  2. package/dist/bootstrap.js +1 -1
  3. package/dist/config-manager.js +56 -19
  4. package/dist/config.js +1 -1
  5. package/dist/custom-stdio.js +1 -2
  6. package/dist/data/onboarding-prompts.json +22 -11
  7. package/dist/device/device-state.d.ts +2 -5
  8. package/dist/device/device-state.js +3 -85
  9. package/dist/device/device.d.ts +1 -1
  10. package/dist/device/device.js +69 -9
  11. package/dist/device/execution-engine.d.ts +11 -1
  12. package/dist/device/execution-engine.js +85 -126
  13. package/dist/device/gateway-channel.d.ts +19 -1
  14. package/dist/device/gateway-channel.js +107 -21
  15. package/dist/device/gateway-identity.d.ts +1 -0
  16. package/dist/device/gateway-identity.js +4 -0
  17. package/dist/device/gateway-tool-adapter.d.ts +3 -3
  18. package/dist/device/gateway-tool-adapter.js +12 -11
  19. package/dist/device/linux-service.d.ts +6 -0
  20. package/dist/device/linux-service.js +12 -5
  21. package/dist/device/runtime-owner.d.ts +1 -0
  22. package/dist/device/runtime-owner.js +6 -6
  23. package/dist/device/self-update.d.ts +107 -12
  24. package/dist/device/self-update.js +660 -62
  25. package/dist/device/update-helper.cjs +343 -0
  26. package/dist/device/windows-service.d.ts +3 -4
  27. package/dist/device/windows-service.js +5 -44
  28. package/dist/handlers/filesystem-handlers.js +3 -0
  29. package/dist/index.js +7 -3
  30. package/dist/npm-scripts/remote.d.ts +5 -0
  31. package/dist/npm-scripts/remote.js +50 -3
  32. package/dist/server.js +83 -167
  33. package/dist/terminal-manager.js +41 -1
  34. package/dist/tools/config.d.ts +1 -1
  35. package/dist/tools/filesystem.js +3 -3
  36. package/dist/tools/pdf/markdown.js +1 -1
  37. package/dist/tools/schemas.d.ts +2 -3
  38. package/dist/tools/schemas.js +0 -10
  39. package/dist/ui/config-editor/index.html +1 -1
  40. package/dist/ui/file-preview/index.html +1 -1
  41. package/dist/ui/file-preview/styles.css +2 -2
  42. package/dist/utils/capture.d.ts +5 -28
  43. package/dist/utils/capture.js +11 -455
  44. package/dist/utils/feature-flags.d.ts +0 -47
  45. package/dist/utils/feature-flags.js +5 -199
  46. package/dist/utils/files/docx.d.ts +2 -2
  47. package/dist/utils/files/docx.js +2 -2
  48. package/dist/utils/files/factory.js +20 -0
  49. package/dist/utils/files/text.d.ts +1 -0
  50. package/dist/utils/files/text.js +78 -34
  51. package/dist/utils/trackTools.js +23 -2
  52. package/dist/utils/usageTracker.js +2 -24
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +6 -7
  56. package/dist/tools/feedback.d.ts +0 -8
  57. package/dist/tools/feedback.js +0 -132
@@ -1,135 +1,116 @@
1
- import { spawn } from 'child_process';
1
+ import { randomUUID } from 'crypto';
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
4
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
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
+ import { deviceStatePaths } from './device-state.js';
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
10
- export class DesktopCommanderIntegration {
11
+ export class LocalExecutionEngine {
11
12
  constructor() {
12
13
  this.mcpClient = null;
13
14
  this.mcpTransport = null;
14
15
  this.isReady = false;
16
+ this.runtimeReason = 'LOCAL_EXECUTION_ENGINE_UNAVAILABLE';
17
+ this.runtimeGeneration = null;
18
+ }
19
+ markUnavailable(client) {
20
+ if (client && this.mcpClient !== client)
21
+ return;
22
+ this.isReady = false;
23
+ this.runtimeReason = 'LOCAL_EXECUTION_ENGINE_UNAVAILABLE';
24
+ this.runtimeGeneration = null;
15
25
  }
16
26
  async initialize() {
17
- console.debug('[DEBUG] DesktopCommanderIntegration.initialize() called');
18
- const config = await this.resolveMcpConfig();
19
- if (!config) {
20
- console.debug('[DEBUG] No MCP config found');
21
- throw new Error('Desktop Commander MCP not found. Please install it globally via `npm install -g @wonderwhy-er/desktop-commander` or build the local project.');
22
- }
23
- console.log(` - ⏳ Connecting to Local Desktop Commander MCP using: ${config.command} ${config.args.join(' ')}`);
24
- console.debug('[DEBUG] MCP config:', JSON.stringify(config, null, 2));
27
+ console.debug('[DEBUG] LocalExecutionEngine.initialize() called');
28
+ this.markUnavailable();
25
29
  try {
26
- console.debug('[DEBUG] Creating StdioClientTransport');
27
- // MCP_DEVICE_REMOTE 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({
30
+ const config = await this.resolveMcpConfig();
31
+ if (!config)
32
+ throw new Error('Bundled local execution engine is unavailable. Reinstall MCP Device.');
33
+ const transport = new StdioClientTransport({
31
34
  ...config,
32
35
  env: {
33
36
  ...getDefaultEnvironment(),
34
37
  ...config.env,
35
- MCP_DEVICE_REMOTE: 'true',
36
- DESKTOP_COMMANDER_DISABLE_TELEMETRY: 'true'
38
+ MCP_DEVICE_REMOTE: 'true'
37
39
  }
38
40
  });
39
- // Create MCP client
40
- console.debug('[DEBUG] Creating MCP Client');
41
- this.mcpClient = new Client({
42
- name: "desktop-commander-client",
43
- version: "1.0.0"
44
- }, {
45
- capabilities: {}
46
- });
47
- // Connect to Desktop Commander
48
- console.debug('[DEBUG] Connecting MCP client to transport');
49
- await this.mcpClient.connect(this.mcpTransport);
41
+ const client = new Client({ name: 'mcp-device-local-engine', version: '1.0.0' }, { capabilities: {} });
42
+ this.mcpTransport = transport;
43
+ this.mcpClient = client;
44
+ client.onclose = () => this.markUnavailable(client);
45
+ await client.connect(transport);
46
+ if (this.mcpClient !== client || this.mcpTransport !== transport || transport.pid === null) {
47
+ throw new Error('Local execution engine closed during startup.');
48
+ }
49
+ this.runtimeGeneration = randomUUID();
50
50
  this.isReady = true;
51
- console.log(' - 🔌 Connected to Desktop Commander MCP');
52
- console.debug('[DEBUG] Desktop Commander MCP connection successful');
51
+ this.runtimeReason = null;
52
+ console.log(' - Local execution engine ready');
53
53
  }
54
54
  catch (error) {
55
- console.error(' - ❌ Failed to connect to Desktop Commander MCP:', error);
56
- console.debug('[DEBUG] MCP connection error:', error);
57
- await captureRemote('desktop_integration_init_failed', { error });
58
- throw error;
55
+ console.error(' - Failed to start local execution engine:', error);
56
+ await captureRemote('local_engine_init_failed', { error });
57
+ try {
58
+ await this.mcpClient?.close();
59
+ }
60
+ catch { }
61
+ try {
62
+ await this.mcpTransport?.close();
63
+ }
64
+ catch { }
65
+ this.mcpClient = null;
66
+ this.mcpTransport = null;
67
+ this.markUnavailable();
59
68
  }
60
69
  }
61
70
  async resolveMcpConfig() {
62
- console.debug('[DEBUG] Resolving MCP config...');
63
- // Option 1: Development/Local Build
64
- // Adjusting path resolution since we are now in src/device and dist is in root/dist
65
- // Original: path.resolve(__dirname, '../../dist/index.js')
66
- const devPath = path.resolve(__dirname, '../../dist/index.js');
67
- console.debug('[DEBUG] Checking local dev path:', devPath);
71
+ const localEngine = path.resolve(__dirname, '../../dist/index.js');
68
72
  try {
69
- await fs.access(devPath);
70
- console.debug(' - 🔍 Found local MCP server at:', devPath);
71
- return {
72
- command: process.execPath, // Use the current node executable
73
- args: [devPath],
74
- cwd: path.dirname(devPath)
75
- };
73
+ await fs.access(localEngine);
76
74
  }
77
75
  catch {
78
- console.debug('[DEBUG] Local dev path not found, trying global installation');
79
- // Local file not found, continue...
76
+ return null;
80
77
  }
81
- // Option 2: Global Installation
82
- const commandName = 'desktop-commander';
83
- console.debug('[DEBUG] Checking for global command:', commandName);
84
- try {
85
- await new Promise((resolve, reject) => {
86
- // Use platform-appropriate command to check if the command exists in PATH
87
- // We can't run it directly as it's an stdio MCP server that waits for input
88
- const whichCommand = process.platform === 'win32' ? 'where' : 'which';
89
- console.debug('[DEBUG] Using platform command:', whichCommand, 'on platform:', process.platform);
90
- const check = spawn(whichCommand, [commandName], { windowsHide: true }); // Prevent visible console windows on Windows
91
- check.on('error', (err) => {
92
- console.debug('[DEBUG] Spawn error for', whichCommand, ':', err.message);
93
- reject(err);
94
- });
95
- check.on('close', (code) => {
96
- console.debug('[DEBUG]', whichCommand, 'exited with code:', code);
97
- return code === 0 ? resolve() : reject(new Error('Command not found'));
98
- });
99
- });
100
- console.debug(' - Found global desktop-commander CLI');
101
- return {
102
- command: commandName,
103
- args: []
104
- };
105
- }
106
- catch (err) {
107
- console.debug('[DEBUG] Global command not found:', err);
108
- // Global command not found
109
- }
110
- console.debug('[DEBUG] No MCP config resolved');
111
- return null;
78
+ const runtimeDir = deviceStatePaths().runtime;
79
+ await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
80
+ return {
81
+ command: process.execPath,
82
+ args: [localEngine],
83
+ cwd: runtimeDir
84
+ };
85
+ }
86
+ getChildPid() {
87
+ return this.mcpTransport?.pid ?? null;
88
+ }
89
+ getRuntimeState() {
90
+ return {
91
+ runtime_ready: this.isReady,
92
+ runtime_reason: this.isReady ? null : this.runtimeReason,
93
+ execution_runtime_generation: this.isReady ? this.runtimeGeneration : null
94
+ };
95
+ }
96
+ assertReady() {
97
+ if (this.isReady && this.mcpClient)
98
+ return;
99
+ const error = new Error('Local execution engine is unavailable.');
100
+ error.code = 'DEVICE_NOT_READY';
101
+ throw error;
112
102
  }
113
103
  async callClientTool(toolName, args, metadata) {
114
- if (!this.isReady || !this.mcpClient) {
115
- console.debug('[DEBUG] callClientTool() failed - not ready or no client');
116
- throw new Error('DesktopIntegration not initialized');
117
- }
118
- // Proxy other tools to MCP server
104
+ this.assertReady();
119
105
  try {
120
- console.debug('[DEBUG] Calling MCP tool:', toolName, 'args:', JSON.stringify(args).substring(0, 100));
121
- const result = await this.mcpClient.callTool({
106
+ return await this.mcpClient.callTool({
122
107
  name: toolName,
123
108
  arguments: args,
124
109
  _meta: { remote: true, ...metadata || {} }
125
110
  });
126
- console.debug('[DEBUG] Tool call successful:', toolName);
127
- return result;
128
111
  }
129
112
  catch (error) {
130
- console.error(`Error executing tool ${toolName}:`, error);
131
- console.debug('[DEBUG] Tool call error details:', error);
132
- await captureRemote('desktop_integration_tool_call_failed', { error, toolName });
113
+ await captureRemote('local_engine_tool_call_failed', { error, toolName });
133
114
  throw error;
134
115
  }
135
116
  }
@@ -137,59 +118,37 @@ export class DesktopCommanderIntegration {
137
118
  if (!this.mcpClient)
138
119
  return { tools: [] };
139
120
  try {
140
- // List tools from MCP server
141
121
  const mcpTools = await this.mcpClient.listTools();
142
- // Merge tools
143
- return {
144
- tools: mcpTools.tools || []
145
- };
122
+ return { tools: mcpTools.tools || [] };
146
123
  }
147
124
  catch (error) {
148
- console.error('Error fetching capabilities:', error);
149
- await captureRemote('desktop_integration_list_tools_failed', { error });
150
- // Fallback to local tools
151
- return {
152
- tools: []
153
- };
125
+ await captureRemote('local_engine_list_tools_failed', { error });
126
+ return { tools: [] };
154
127
  }
155
128
  }
156
129
  async shutdown() {
157
- console.debug('[DEBUG] DesktopCommanderIntegration.shutdown() called');
158
- const closeWithTimeout = async (operation, name, timeoutMs = 3000) => {
159
- return Promise.race([
160
- operation(),
161
- new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timeout after ${timeoutMs}ms`)), timeoutMs))
162
- ]);
163
- };
130
+ const closeWithTimeout = async (operation, name, timeoutMs = 3000) => Promise.race([
131
+ operation(),
132
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timeout after ${timeoutMs}ms`)), timeoutMs))
133
+ ]);
164
134
  if (this.mcpClient) {
165
135
  try {
166
- console.log(' → Closing MCP client...');
167
- console.debug('[DEBUG] Calling mcpClient.close() with timeout');
168
136
  await closeWithTimeout(() => this.mcpClient.close(), 'MCP client close');
169
- console.log(' ✓ MCP client closed');
170
137
  }
171
- catch (e) {
172
- console.warn(' ⚠️ MCP client close timeout or error:', e.message);
173
- console.debug('[DEBUG] MCP client close error:', e);
174
- await captureRemote('desktop_integration_shutdown_error', { error: e, component: 'client' });
138
+ catch (error) {
139
+ await captureRemote('local_engine_shutdown_error', { error, component: 'client' });
175
140
  }
176
141
  this.mcpClient = null;
177
142
  }
178
143
  if (this.mcpTransport) {
179
144
  try {
180
- console.log(' → Closing MCP transport...');
181
- console.debug('[DEBUG] Calling mcpTransport.close() with timeout');
182
145
  await closeWithTimeout(() => this.mcpTransport.close(), 'MCP transport close');
183
- console.log(' ✓ MCP transport closed');
184
146
  }
185
- catch (e) {
186
- console.warn(' ⚠️ MCP transport close timeout or error:', e.message);
187
- console.debug('[DEBUG] MCP transport close error:', e);
188
- await captureRemote('desktop_integration_shutdown_error', { error: e, component: 'transport' });
147
+ catch (error) {
148
+ await captureRemote('local_engine_shutdown_error', { error, component: 'transport' });
189
149
  }
190
150
  this.mcpTransport = null;
191
151
  }
192
- this.isReady = false;
193
- console.debug('[DEBUG] Desktop Commander integration shutdown complete');
152
+ this.markUnavailable();
194
153
  }
195
154
  }
@@ -1,6 +1,10 @@
1
1
  import type { Agent } from 'http';
2
2
  import { GatewayDeviceIdentity } from './gateway-identity.js';
3
3
  import { GatewayToolAdapter } from './gateway-tool-adapter.js';
4
+ export type GatewayUpdateRequestOutcome = {
5
+ mode: 'detached';
6
+ handoff: () => Promise<void>;
7
+ };
4
8
  export interface GatewayChannelOptions {
5
9
  gatewayUrl: string;
6
10
  enrollmentToken?: string;
@@ -10,9 +14,16 @@ export interface GatewayChannelOptions {
10
14
  securityProtocolFloor?: number;
11
15
  appCaPem?: string;
12
16
  adapter: GatewayToolAdapter;
17
+ runtimeState?: () => {
18
+ runtime_ready: boolean;
19
+ runtime_reason: string | null;
20
+ execution_runtime_generation: string | null;
21
+ };
13
22
  agentVersion?: string;
14
23
  onStatus?: (payload: any) => void | Promise<void>;
15
- onUpdateRequest?: (targetVersion: string) => void | Promise<void>;
24
+ onUpdateRequest?: (targetVersion: string, context: {
25
+ requestId: string;
26
+ }) => GatewayUpdateRequestOutcome | Promise<GatewayUpdateRequestOutcome>;
16
27
  }
17
28
  export declare class GatewayDeviceChannel {
18
29
  private options;
@@ -32,7 +43,9 @@ export declare class GatewayDeviceChannel {
32
43
  private protocolVersion;
33
44
  private secureSocket?;
34
45
  private authMode;
46
+ private updateInFlight?;
35
47
  constructor(options: GatewayChannelOptions);
48
+ private runtimePayload;
36
49
  start(): Promise<void>;
37
50
  private startV2Transport;
38
51
  stop(): Promise<void>;
@@ -40,11 +53,16 @@ export declare class GatewayDeviceChannel {
40
53
  private sendHelloV2;
41
54
  private handleMessage;
42
55
  logoutAccount(timeoutMs?: number): Promise<any>;
56
+ private updateStatusMessage;
57
+ private sendUpdateStatus;
58
+ private sendUpdateStatusFlushed;
59
+ reportUpdateFailure(requestId: string, targetVersion: string, code: string, message: string): Promise<void>;
43
60
  private handleUpdateRequest;
44
61
  private handleToolCall;
45
62
  private scheduleReconnect;
46
63
  private startHeartbeat;
47
64
  private stopHeartbeat;
48
65
  private send;
66
+ private sendFlushed;
49
67
  private fail;
50
68
  }
@@ -19,6 +19,19 @@ export class GatewayDeviceChannel {
19
19
  this.authMode = 'reconnect';
20
20
  this.identity = options.identity || new GatewayDeviceIdentity();
21
21
  }
22
+ runtimePayload() {
23
+ if (!this.options.runtimeState)
24
+ return {};
25
+ const state = this.options.runtimeState();
26
+ const ready = state?.runtime_ready === true;
27
+ const reason = ready ? null : String(state?.runtime_reason || 'LOCAL_EXECUTION_ENGINE_UNAVAILABLE').slice(0, 96);
28
+ const generation = String(state?.execution_runtime_generation || '').trim().slice(0, 128) || null;
29
+ return {
30
+ runtime_ready: ready,
31
+ runtime_reason: reason,
32
+ execution_runtime_generation: generation
33
+ };
34
+ }
22
35
  async start() {
23
36
  this.authenticatedDeviceId = undefined;
24
37
  this.connectionEpoch = undefined;
@@ -55,10 +68,15 @@ export class GatewayDeviceChannel {
55
68
  socket.once('error', error => this.fail(error));
56
69
  socket.once('close', (code, reason) => {
57
70
  this.stopHeartbeat();
71
+ const reasonText = reason.toString();
72
+ const forgotten = code === 4003 && /(?:unknown or revoked device|revoked device|device revoked)/i.test(reasonText);
73
+ const error = new Error(`Gateway connection closed (${code}): ${reasonText}`);
74
+ if (forgotten)
75
+ error.code = 'DEVICE_FORGOTTEN';
58
76
  if (!this.shuttingDown && this.readyReject) {
59
- this.fail(new Error(`Gateway connection closed (${code}): ${reason.toString()}`));
77
+ this.fail(error);
60
78
  }
61
- else if (!this.shuttingDown) {
79
+ else if (!this.shuttingDown && !forgotten) {
62
80
  this.scheduleReconnect();
63
81
  }
64
82
  });
@@ -145,6 +163,7 @@ export class GatewayDeviceChannel {
145
163
  arch: process.arch,
146
164
  path_style: process.platform === 'win32' ? 'windows' : 'posix',
147
165
  capabilities: [...GATEWAY_CAPABILITIES],
166
+ ...this.runtimePayload(),
148
167
  ...(enrolling || pairing ? { public_key_pem: record.publicKeyPem } : {})
149
168
  }
150
169
  });
@@ -168,6 +187,7 @@ export class GatewayDeviceChannel {
168
187
  arch: process.arch,
169
188
  path_style: process.platform === 'win32' ? 'windows' : 'posix',
170
189
  capabilities: [...GATEWAY_CAPABILITIES],
190
+ ...this.runtimePayload(),
171
191
  ...(pairing || enrolling ? { public_key_pem: record.publicKeyPem, enrollment_grant: grant } : {})
172
192
  }
173
193
  });
@@ -297,40 +317,83 @@ export class GatewayDeviceChannel {
297
317
  clearTimeout(timer);
298
318
  }
299
319
  }
320
+ updateStatusMessage(requestId, targetVersion, state, extra = {}) {
321
+ if (this.connectionEpoch === undefined || !this.authenticatedDeviceId) {
322
+ throw new Error('Gateway update status cannot be sent before authentication.');
323
+ }
324
+ return {
325
+ protocol_version: this.protocolVersion,
326
+ type: 'device_update_status',
327
+ request_id: requestId,
328
+ device_id: this.authenticatedDeviceId,
329
+ connection_epoch: this.connectionEpoch,
330
+ timestamp: Date.now(),
331
+ payload: { state, target_version: targetVersion, package_version: VERSION, ...extra }
332
+ };
333
+ }
334
+ sendUpdateStatus(requestId, targetVersion, state, extra = {}) {
335
+ try {
336
+ this.send(this.updateStatusMessage(requestId, targetVersion, state, extra));
337
+ }
338
+ catch {
339
+ // Best-effort path used only when the socket may already be closing.
340
+ }
341
+ }
342
+ async sendUpdateStatusFlushed(requestId, targetVersion, state, extra = {}) {
343
+ await this.sendFlushed(this.updateStatusMessage(requestId, targetVersion, state, extra));
344
+ }
345
+ async reportUpdateFailure(requestId, targetVersion, code, message) {
346
+ // Failed update state is retired only after this awaited send succeeds.
347
+ await this.sendUpdateStatusFlushed(requestId, targetVersion, 'failed', {
348
+ code: String(code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
349
+ message: String(message || 'Device update failed.').slice(0, 240)
350
+ });
351
+ }
300
352
  async handleUpdateRequest(message) {
301
353
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
302
354
  return;
303
355
  const requestId = String(message.request_id || '').trim();
304
356
  const targetVersion = String(message.payload?.target_version || '').trim();
305
- const sendStatus = (state, extra = {}) => {
306
- try {
307
- this.send({
308
- protocol_version: this.protocolVersion,
309
- type: 'device_update_status',
310
- request_id: requestId,
311
- device_id: this.authenticatedDeviceId,
312
- connection_epoch: this.connectionEpoch,
313
- timestamp: Date.now(),
314
- payload: { state, target_version: targetVersion, package_version: VERSION, ...extra }
357
+ if (!requestId || !/^\d+\.\d+\.\d+$/.test(targetVersion) || typeof this.options.onUpdateRequest !== 'function') {
358
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
359
+ code: 'DEVICE_UPDATE_UNSUPPORTED',
360
+ message: 'Device update request is unsupported or invalid.'
361
+ });
362
+ return;
363
+ }
364
+ if (this.updateInFlight) {
365
+ if (this.updateInFlight !== requestId) {
366
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
367
+ code: 'DEVICE_UPDATE_IN_PROGRESS',
368
+ message: 'Another MCP Device update is already in progress.'
315
369
  });
316
370
  }
317
- catch { /* reconnect state will make the final version authoritative */ }
318
- };
319
- if (!requestId || !/^\d+\.\d+\.\d+$/.test(targetVersion) || typeof this.options.onUpdateRequest !== 'function') {
320
- sendStatus('failed', { code: 'DEVICE_UPDATE_UNSUPPORTED', message: 'Device update request is unsupported or invalid.' });
321
371
  return;
322
372
  }
323
- sendStatus('accepted');
373
+ this.updateInFlight = requestId;
324
374
  try {
325
- await this.options.onUpdateRequest(targetVersion);
326
- sendStatus('installed');
375
+ // Pre-stage and verify the target before acknowledging the update.
376
+ // If this throws, the currently running device stays healthy.
377
+ const outcome = await this.options.onUpdateRequest(targetVersion, { requestId });
378
+ if (!outcome || outcome.mode !== 'detached') {
379
+ throw Object.assign(new Error('Device update handler did not provide a detached handoff.'), { code: 'DEVICE_UPDATE_INVALID_HANDOFF' });
380
+ }
381
+ // Do not tear down the socket until the gateway has accepted
382
+ // the frame into the transport write path.
383
+ await this.sendUpdateStatusFlushed(requestId, targetVersion, 'accepted');
384
+ await outcome.handoff();
385
+ return;
327
386
  }
328
387
  catch (error) {
329
- sendStatus('failed', {
388
+ this.sendUpdateStatus(requestId, targetVersion, 'failed', {
330
389
  code: String(error?.code || 'DEVICE_UPDATE_FAILED').slice(0, 64),
331
390
  message: String(error?.message || error).slice(0, 240)
332
391
  });
333
392
  }
393
+ finally {
394
+ if (this.updateInFlight === requestId)
395
+ this.updateInFlight = undefined;
396
+ }
334
397
  }
335
398
  async handleToolCall(message) {
336
399
  if (this.connectionEpoch === undefined || !this.authenticatedDeviceId)
@@ -384,7 +447,7 @@ export class GatewayDeviceChannel {
384
447
  type: 'heartbeat',
385
448
  device_id: record.deviceId,
386
449
  timestamp: Date.now(),
387
- payload: {}
450
+ payload: this.runtimePayload()
388
451
  });
389
452
  }, HEARTBEAT_MS);
390
453
  this.heartbeat.unref?.();
@@ -411,6 +474,29 @@ export class GatewayDeviceChannel {
411
474
  }
412
475
  this.socket.send(encoded);
413
476
  }
477
+ async sendFlushed(message) {
478
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
479
+ throw new Error('Gateway socket is not open');
480
+ const encoded = JSON.stringify(message);
481
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_OUTBOUND_MESSAGE_BYTES) {
482
+ const error = new Error('Gateway device response exceeds the outbound message limit');
483
+ error.code = 'DEVICE_OUTPUT_TOO_LARGE';
484
+ throw error;
485
+ }
486
+ if (this.protocolVersion === PROTOCOL_VERSION_V2) {
487
+ if (!this.secureSocket || this.secureSocket.destroyed)
488
+ throw new Error('Gateway v2 inner TLS socket is not open');
489
+ const secureSocket = this.secureSocket;
490
+ await new Promise((resolve, reject) => {
491
+ secureSocket.write(encodeJsonFrame(message), error => error ? reject(error) : resolve());
492
+ });
493
+ return;
494
+ }
495
+ const socket = this.socket;
496
+ await new Promise((resolve, reject) => {
497
+ socket.send(encoded, error => error ? reject(error) : resolve());
498
+ });
499
+ }
414
500
  fail(error) {
415
501
  const reject = this.readyReject;
416
502
  this.readyResolve = undefined;
@@ -13,6 +13,7 @@ export declare class GatewayDeviceIdentity {
13
13
  });
14
14
  loadOrCreate(): Promise<GatewayIdentityRecord>;
15
15
  markEnrolled(): Promise<void>;
16
+ forget(): Promise<void>;
16
17
  signChallenge(nonce: string): Promise<string>;
17
18
  signChallengeV2(options: {
18
19
  mode: 'reconnect' | 'enroll' | 'pair';
@@ -82,6 +82,10 @@ export class GatewayDeviceIdentity {
82
82
  this.record = { ...current, enrolled: true };
83
83
  await this.persist();
84
84
  }
85
+ async forget() {
86
+ this.record = undefined;
87
+ await fs.rm(this.identityPath, { force: true });
88
+ }
85
89
  async signChallenge(nonce) {
86
90
  const current = await this.loadOrCreate();
87
91
  const challenge = Buffer.from(`mcp-device-auth-v1\n${current.deviceId}\n${nonce}`, 'utf8');
@@ -1,15 +1,15 @@
1
- import { DesktopCommanderIntegration } from './execution-engine.js';
1
+ import { LocalExecutionEngine } from './execution-engine.js';
2
2
  export declare const GATEWAY_CAPABILITIES: readonly ["read_text_file", "write_file", "edit_file", "shell_execute", "start_process", "read_process_output", "interact_with_process", "terminate_process", "image_preview", "project_inspect"];
3
3
  export interface GatewayToolAdapterOptions {
4
4
  allowedRoots?: string[];
5
5
  pathValidator?: (requestedPath: string) => Promise<string>;
6
6
  }
7
7
  export declare class GatewayToolAdapter {
8
- private desktop;
8
+ private engine;
9
9
  private allowedRoots;
10
10
  private pathValidator;
11
11
  private canonicalRoots?;
12
- constructor(desktop: DesktopCommanderIntegration, options?: GatewayToolAdapterOptions);
12
+ constructor(engine: LocalExecutionEngine, options?: GatewayToolAdapterOptions);
13
13
  private guardPath;
14
14
  private imagePreview;
15
15
  call(tool: string, args?: any): Promise<any>;