@kln-mcp/ctrl-mobile-cn 0.0.6

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.
@@ -0,0 +1,2553 @@
1
+ 'use strict';
2
+
3
+ const { AsyncLocalStorage } = require('node:async_hooks');
4
+ const crypto = require('node:crypto');
5
+ const fs = require('node:fs');
6
+ const http = require('node:http');
7
+ const https = require('node:https');
8
+ const os = require('node:os');
9
+ const path = require('node:path');
10
+ const { URL } = require('node:url');
11
+ const { KlnCoreClient } = require('../core');
12
+ const { loadConfig } = require('../config');
13
+
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+ const DEFAULT_RECONNECT_WINDOW_MS = 10_000;
16
+ const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'outputs');
17
+ const DEFAULT_VIEW_HOST = '127.0.0.1';
18
+ const DEFAULT_VIEW_PORT = 8090;
19
+ const LIVE_VIEW_HTML_PATH = path.join(__dirname, 'live-view.html');
20
+ const DEFAULT_DEVICE_CACHE_PATH = path.join(os.homedir(), '.kln-mobile-ctrl', 'selected-device.json');
21
+ const DEFAULT_TASK_MEMORY_PATH = path.join(os.homedir(), '.kln', 'task_memory.json');
22
+ const DEFAULT_CONTEXT_ELEMENT_LIMIT = 10;
23
+ const MAX_CONTEXT_TEXT_LENGTH = 60;
24
+ const DEFAULT_SUBSCRIPTION_CHECK_MS = 10 * 60 * 1000;
25
+ const SUBSCRIPTION_CHECK_JITTER_MS = 60 * 1000;
26
+ const traceStorage = new AsyncLocalStorage();
27
+
28
+ const TOOL_DEFINITIONS = [
29
+ {
30
+ name: 'kln_set_api_key',
31
+ description: 'Set the server API key for this MCP session.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ apiKey: { type: 'string', description: 'User API key from KLN server.' }
36
+ },
37
+ required: ['apiKey']
38
+ }
39
+ },
40
+ {
41
+ name: 'kln_list_devices',
42
+ description: 'List online Android devices from KLN server. Each device includes its current P2P endpoint ticket.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ apiKey: { type: 'string', description: 'Optional API key. Falls back to --api-key or KLN_API_KEY.' }
47
+ }
48
+ }
49
+ },
50
+ {
51
+ name: 'kln_select_device',
52
+ description: 'Select an online Android device by device ID, cache the selected device ID, and connect to it.',
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ deviceId: { type: 'string', description: 'Device ID returned by kln_list_devices.' },
57
+ apiKey: { type: 'string', description: 'Optional API key. Falls back to --api-key or KLN_API_KEY.' }
58
+ },
59
+ required: ['deviceId']
60
+ }
61
+ },
62
+ {
63
+ name: 'kln_send_command',
64
+ description: 'Send a command to the connected Android device and wait for a response.',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ name: { type: 'string', description: 'Command name, for example ping or get-view-tree.' },
69
+ text: { type: 'string', description: 'Optional command payload text.' },
70
+ ticket: { type: 'string', description: 'Optional endpoint ticket. Falls back to server config.' },
71
+ apiKey: { type: 'string', description: 'Optional server API key.' },
72
+ deviceId: { type: 'string', description: 'Optional server device ID.' },
73
+ timeoutMs: { type: 'number', description: 'Response timeout in milliseconds.' }
74
+ },
75
+ required: ['name']
76
+ }
77
+ },
78
+ {
79
+ name: 'kln_send_text',
80
+ description: 'Send a text message to the connected Android device and wait for a response.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ text: { type: 'string', description: 'Text message body.' },
85
+ ticket: { type: 'string', description: 'Optional endpoint ticket. Falls back to server config.' },
86
+ apiKey: { type: 'string', description: 'Optional server API key.' },
87
+ deviceId: { type: 'string', description: 'Optional server device ID.' },
88
+ timeoutMs: { type: 'number', description: 'Response timeout in milliseconds.' }
89
+ },
90
+ required: ['text']
91
+ }
92
+ },
93
+ {
94
+ name: 'kln_start_screen_stream',
95
+ description: 'Request an H264 screen stream and save it to a local file.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ text: { type: 'string', description: 'Optional stream request payload text.' },
100
+ ticket: { type: 'string', description: 'Optional endpoint ticket. Falls back to server config.' },
101
+ apiKey: { type: 'string', description: 'Optional server API key.' },
102
+ deviceId: { type: 'string', description: 'Optional server device ID.' },
103
+ outputDir: { type: 'string', description: 'Directory for the raw H264 output file.' }
104
+ }
105
+ }
106
+ },
107
+ {
108
+ name: 'kln_get_device_context',
109
+ description: 'Get compact Android screen context for agent decisions. Returns screen bounds plus a trimmed clickable element list.',
110
+ inputSchema: {
111
+ type: 'object',
112
+ properties: {
113
+ ticket: { type: 'string', description: 'Optional endpoint ticket. Falls back to server config.' },
114
+ apiKey: { type: 'string', description: 'Optional server API key.' },
115
+ deviceId: { type: 'string', description: 'Optional server device ID.' },
116
+ timeoutMs: { type: 'number', description: 'Response timeout in milliseconds.' },
117
+ maxElements: { type: 'number', description: 'Maximum clickable text elements to return. Default: 10.' }
118
+ }
119
+ }
120
+ },
121
+ {
122
+ name: 'kln_task_memory',
123
+ description: 'Persist or read task-level progress in ~/.kln/task_memory.json for resuming long mobile tasks.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ op: { type: 'string', description: 'Operation: list, get, put, or delete.' },
128
+ key: { type: 'string', description: 'Task key. Required for get, put, and delete.' },
129
+ value: { type: 'string', description: 'Opaque JSON string. Required for put.' },
130
+ summary: { type: 'string', description: 'Human-readable task title. Required for put.' },
131
+ progress: { type: 'string', description: 'Human-readable task progress. Required for put.' }
132
+ },
133
+ required: ['op']
134
+ }
135
+ },
136
+ {
137
+ name: 'kln_input_tap',
138
+ description: 'Tap a device coordinate.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ x: { type: 'number' },
143
+ y: { type: 'number' },
144
+ profile: { type: 'string', description: 'Gesture simulation profile. Default: human.' },
145
+ ticket: { type: 'string' },
146
+ timeoutMs: { type: 'number' }
147
+ },
148
+ required: ['x', 'y']
149
+ }
150
+ },
151
+ {
152
+ name: 'kln_input_swipe',
153
+ description: 'Swipe or drag between two device coordinates.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ startX: { type: 'number' },
158
+ startY: { type: 'number' },
159
+ endX: { type: 'number' },
160
+ endY: { type: 'number' },
161
+ durationMs: { type: 'number' },
162
+ profile: { type: 'string', description: 'Gesture simulation profile. Default: human.' },
163
+ ticket: { type: 'string' },
164
+ timeoutMs: { type: 'number' }
165
+ },
166
+ required: ['startX', 'startY', 'endX', 'endY']
167
+ }
168
+ },
169
+ {
170
+ name: 'kln_input_text',
171
+ description: 'Set or append text in the focused editable field.',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ text: { type: 'string' },
176
+ replace: { type: 'boolean', description: 'Replace focused field text. Default: false.' },
177
+ ticket: { type: 'string' },
178
+ timeoutMs: { type: 'number' }
179
+ },
180
+ required: ['text']
181
+ }
182
+ },
183
+ {
184
+ name: 'kln_input_clear',
185
+ description: 'Clear the focused editable field.',
186
+ inputSchema: {
187
+ type: 'object',
188
+ properties: {
189
+ ticket: { type: 'string' },
190
+ timeoutMs: { type: 'number' }
191
+ }
192
+ }
193
+ },
194
+ {
195
+ name: 'kln_input_key',
196
+ description: 'Send a supported key action.',
197
+ inputSchema: {
198
+ type: 'object',
199
+ properties: {
200
+ key: { type: 'string', description: 'Supported values include back, home, recents, enter, delete.' },
201
+ ticket: { type: 'string' },
202
+ timeoutMs: { type: 'number' }
203
+ },
204
+ required: ['key']
205
+ }
206
+ },
207
+ {
208
+ name: 'kln_device_global_action',
209
+ description: 'Perform an Android accessibility global action.',
210
+ inputSchema: {
211
+ type: 'object',
212
+ properties: {
213
+ action: { type: 'string', description: 'back, home, recents, notifications, quickSettings, powerDialog, lockScreen.' },
214
+ ticket: { type: 'string' },
215
+ timeoutMs: { type: 'number' }
216
+ },
217
+ required: ['action']
218
+ }
219
+ },
220
+ {
221
+ name: 'kln_app_start',
222
+ description: 'Start a launchable app by package name.',
223
+ inputSchema: {
224
+ type: 'object',
225
+ properties: {
226
+ packageName: { type: 'string' },
227
+ activity: { type: 'string' },
228
+ ticket: { type: 'string' },
229
+ timeoutMs: { type: 'number' }
230
+ },
231
+ required: ['packageName']
232
+ }
233
+ },
234
+ {
235
+ name: 'kln_app_stop',
236
+ description: 'Best-effort stop for an app by package name.',
237
+ inputSchema: {
238
+ type: 'object',
239
+ properties: {
240
+ packageName: { type: 'string' },
241
+ ticket: { type: 'string' },
242
+ timeoutMs: { type: 'number' }
243
+ },
244
+ required: ['packageName']
245
+ }
246
+ },
247
+ {
248
+ name: 'kln_apps_list_launchable',
249
+ description: 'Return launchable apps from the Android device via postcard.',
250
+ inputSchema: {
251
+ type: 'object',
252
+ properties: {
253
+ includeSystem: { type: 'boolean', description: 'Include system apps. Default: true.' },
254
+ ticket: { type: 'string' },
255
+ timeoutMs: { type: 'number' }
256
+ }
257
+ }
258
+ }
259
+ ];
260
+
261
+ for (const tool of TOOL_DEFINITIONS) {
262
+ const properties = tool.inputSchema && tool.inputSchema.properties;
263
+ if (properties) {
264
+ properties.trace_id = properties.trace_id || { type: 'string', description: 'OpenObserve/OTel trace_id. Generated automatically when omitted.' };
265
+ }
266
+ if (properties && properties.ticket) {
267
+ properties.apiKey = properties.apiKey || { type: 'string', description: 'Optional server API key.' };
268
+ properties.deviceId = properties.deviceId || { type: 'string', description: 'Optional server device ID.' };
269
+ }
270
+ }
271
+
272
+ class KlnMobileController {
273
+ constructor(options = {}) {
274
+ const config = loadConfig(options.env);
275
+ this.env = config.env;
276
+ this.debug = config.env === 'dev';
277
+ if (this.debug) {
278
+ process.env.KLN_MCP_DEBUG = '1';
279
+ }
280
+ this.apiUrl = cleanString(options.apiUrl || options.api_url || config.api_url || process.env.KLN_API_URL);
281
+ this.apiKey = cleanString(options.apiKey || options.api_key || process.env.KLN_API_KEY);
282
+ this.deviceId = cleanString(options.deviceId || options.device_id || process.env.KLN_DEVICE_ID);
283
+ this.deviceCachePath = cleanString(options.deviceCachePath || options.device_cache_path || process.env.KLN_DEVICE_CACHE_PATH) || DEFAULT_DEVICE_CACHE_PATH;
284
+ this.reconnectWindowMs = normalizeTimeout(options.reconnectWindowMs || options.reconnect_window_ms, DEFAULT_RECONNECT_WINDOW_MS);
285
+ this.staticTicket = cleanString(options.ticket || process.env.KLN_TICKET);
286
+ this.ticket = this.staticTicket;
287
+ this.relayUrls = normalizeRelayUrls(options.relayUrls || options.relay_urls || config.relay_urls);
288
+ this.alpn = cleanString(options.alpn || process.env.KLN_ALPN);
289
+ this.traceId = cleanString(options.traceId || options.trace_id || process.env.KLN_TRACE_ID);
290
+ this.timeoutMs = normalizeTimeout(options.timeoutMs || options.timeout_ms, DEFAULT_TIMEOUT_MS);
291
+ this.outputDir = options.outputDir || options.output_dir || process.env.KLN_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
292
+ this.taskMemoryPath = cleanString(options.taskMemoryPath || options.task_memory_path || process.env.KLN_TASK_MEMORY_PATH) || DEFAULT_TASK_MEMORY_PATH;
293
+ this.client = null;
294
+ this.clientTicket = null;
295
+ this.clientKey = null;
296
+ this.connectPromise = null;
297
+ this.connectKey = null;
298
+ this.subscriptionStatus = null;
299
+ this.subscriptionTimer = null;
300
+ this.subscriptionCheckPromise = null;
301
+ this.log('controller initialized', {
302
+ env: this.env,
303
+ ticket: summarizeTicket(this.staticTicket),
304
+ apiUrl: this.apiUrl,
305
+ hasApiKey: Boolean(this.apiKey),
306
+ deviceId: this.deviceId || '<cache-or-auto>',
307
+ relayUrls: this.relayUrls,
308
+ alpn: this.alpn || '<default>',
309
+ trace_id: this.traceId || '<auto>',
310
+ timeoutMs: this.timeoutMs,
311
+ reconnectWindowMs: this.reconnectWindowMs,
312
+ outputDir: this.outputDir,
313
+ taskMemoryPath: this.taskMemoryPath
314
+ });
315
+ }
316
+
317
+ async connect(options = {}) {
318
+ const target = await this.resolveConnectionTarget(options);
319
+ const ticket = requireTicket(target.ticket);
320
+ const relayUrls = normalizeRelayUrls(options.relayUrls || options.relay_urls || this.relayUrls);
321
+ const alpn = cleanString(options.alpn || this.alpn);
322
+ const apiKey = cleanString(options.apiKey || this.apiKey);
323
+ const connectKey = JSON.stringify({ ticket, relayUrls, alpn: alpn || '', apiKey: apiKey || '' });
324
+
325
+ if (this.client && this.clientKey === connectKey) {
326
+ this.log('reuse existing P2P connection', {
327
+ ticket: summarizeTicket(ticket)
328
+ });
329
+ return this.client;
330
+ }
331
+
332
+ if (this.connectPromise && this.connectKey === connectKey) {
333
+ this.log('wait for in-flight P2P connection', {
334
+ ticket: summarizeTicket(ticket)
335
+ });
336
+ return this.connectPromise;
337
+ }
338
+
339
+ this.log('start P2P connection', {
340
+ ticket: summarizeTicket(ticket),
341
+ relayUrls,
342
+ alpn: alpn || '<default>'
343
+ });
344
+ await this.close();
345
+ this.connectKey = connectKey;
346
+ this.connectPromise = KlnCoreClient.connect({
347
+ ticket,
348
+ relayUrls,
349
+ ...(alpn ? { alpn } : {}),
350
+ ...(apiKey ? { apiKey } : {})
351
+ })
352
+ .then((client) => {
353
+ this.client = client;
354
+ this.clientTicket = ticket;
355
+ this.clientKey = connectKey;
356
+ this.log('P2P connection established', {
357
+ nodeId: client.nodeId,
358
+ ticket: summarizeTicket(ticket)
359
+ });
360
+ this.scheduleSubscriptionCheck();
361
+ return client;
362
+ })
363
+ .catch((error) => {
364
+ this.log('P2P connection failed', {
365
+ message: error.message || String(error)
366
+ });
367
+ throw error;
368
+ })
369
+ .finally(() => {
370
+ if (this.connectKey === connectKey) {
371
+ this.connectPromise = null;
372
+ this.connectKey = null;
373
+ }
374
+ });
375
+
376
+ return this.connectPromise;
377
+ }
378
+
379
+ async setApiKey(apiKey, options = {}) {
380
+ const normalized = cleanString(apiKey);
381
+ if (!normalized) {
382
+ throw new Error('apiKey is required');
383
+ }
384
+ this.apiKey = normalized;
385
+ if (options.validate !== false) {
386
+ await this.listServerDevices({ apiKey: normalized });
387
+ }
388
+ return {
389
+ ok: true,
390
+ summary: 'API key configured for this MCP session.'
391
+ };
392
+ }
393
+
394
+ async listServerDevices(options = {}) {
395
+ const apiKey = this.resolveApiKey(options);
396
+ const data = await this.serverRequest('/api/v1/user-device/mcp/devices', { apiKey });
397
+ const devices = normalizeDeviceList(data);
398
+ const deviceWithSubscription = devices.find((device) => device.subscription);
399
+ if (deviceWithSubscription) {
400
+ this.recordSubscriptionStatus(deviceWithSubscription.subscription);
401
+ }
402
+ return devices;
403
+ }
404
+
405
+ async selectDevice(deviceId, options = {}) {
406
+ const normalizedDeviceId = cleanString(deviceId);
407
+ if (!normalizedDeviceId) {
408
+ throw new Error('deviceId is required');
409
+ }
410
+ if (cleanString(options.apiKey || options.api_key)) {
411
+ this.apiKey = cleanString(options.apiKey || options.api_key);
412
+ }
413
+ const device = await this.fetchServerDeviceTicket(normalizedDeviceId, options);
414
+ this.deviceId = device.deviceId;
415
+ this.ticket = device.ticket;
416
+ this.writeCachedDeviceId(device.deviceId);
417
+ await this.close();
418
+ await this.connect({ ...options, deviceId: device.deviceId });
419
+ return {
420
+ ok: true,
421
+ device,
422
+ summary: `Selected device ${device.deviceId}.`
423
+ };
424
+ }
425
+
426
+ async resolveConnectionTarget(options = {}) {
427
+ const ticket = cleanString(options.ticket || this.staticTicket);
428
+ if (ticket) {
429
+ return { ticket, managed: false };
430
+ }
431
+
432
+ const device = await this.ensureServerDevice(options);
433
+ return {
434
+ ticket: device.ticket,
435
+ deviceId: device.deviceId,
436
+ managed: true
437
+ };
438
+ }
439
+
440
+ async ensureServerDevice(options = {}) {
441
+ const requestedDeviceId = cleanString(options.deviceId || options.device_id || this.deviceId || this.readCachedDeviceId());
442
+ if (requestedDeviceId) {
443
+ const device = await this.fetchServerDeviceTicket(requestedDeviceId, options);
444
+ this.deviceId = device.deviceId;
445
+ this.ticket = device.ticket;
446
+ return device;
447
+ }
448
+
449
+ const devices = await this.listServerDevices(options);
450
+ if (devices.length === 0) {
451
+ throw new Error('没有在线安卓设备,请确认安卓端已登录并上报 heartbeat。');
452
+ }
453
+ if (devices.length > 1) {
454
+ const ids = devices.map((device) => device.deviceId).join(', ');
455
+ throw new Error(`发现多个在线设备,请先调用 kln_select_device 选择一个设备: ${ids}`);
456
+ }
457
+
458
+ const [device] = devices;
459
+ this.deviceId = device.deviceId;
460
+ this.ticket = device.ticket;
461
+ this.writeCachedDeviceId(device.deviceId);
462
+ return device;
463
+ }
464
+
465
+ async fetchServerDeviceTicket(deviceId, options = {}) {
466
+ const apiKey = this.resolveApiKey(options);
467
+ const encodedDeviceId = encodeURIComponent(deviceId);
468
+ const data = await this.serverRequest(`/api/v1/user-device/mcp/devices/${encodedDeviceId}/ticket`, { apiKey });
469
+ const device = normalizeDevice(data);
470
+ if (!device || !device.ticket) {
471
+ throw new Error('server did not return a device ticket');
472
+ }
473
+ this.recordSubscriptionStatus(device.subscription);
474
+ return device;
475
+ }
476
+
477
+ async checkSubscriptionStatus(options = {}) {
478
+ if (!this.apiUrl || !cleanString(options.apiKey || options.api_key || this.apiKey)) {
479
+ return this.subscriptionStatus;
480
+ }
481
+ if (this.subscriptionCheckPromise) {
482
+ return this.subscriptionCheckPromise;
483
+ }
484
+ this.subscriptionCheckPromise = this.serverRequest('/api/v1/user-device/mcp/subscription/status', options)
485
+ .then((status) => {
486
+ this.recordSubscriptionStatus(status);
487
+ return this.subscriptionStatus;
488
+ })
489
+ .finally(() => {
490
+ this.subscriptionCheckPromise = null;
491
+ });
492
+ return this.subscriptionCheckPromise;
493
+ }
494
+
495
+ async ensureSubscriptionActive(options = {}) {
496
+ const status = this.subscriptionStatus;
497
+ if (status && status.active === false) {
498
+ await this.close();
499
+ throw new Error(status.message || '订阅已到期');
500
+ }
501
+ if (status && status.graceUntilMs && Date.now() >= status.graceUntilMs) {
502
+ this.recordSubscriptionStatus({ ...status, active: false, message: '订阅已到期' });
503
+ await this.close();
504
+ throw new Error('订阅已到期');
505
+ }
506
+ if (status && status.nextCheckAtMs && Date.now() < status.nextCheckAtMs) {
507
+ return status;
508
+ }
509
+ return this.checkSubscriptionStatus(options);
510
+ }
511
+
512
+ recordSubscriptionStatus(status) {
513
+ const normalized = normalizeSubscriptionStatus(status);
514
+ if (!normalized) {
515
+ return;
516
+ }
517
+ this.subscriptionStatus = normalized;
518
+ this.log('subscription status updated', {
519
+ active: normalized.active,
520
+ vipLevel: normalized.vipLevel,
521
+ expiresAt: normalized.expiresAt || '<unknown>',
522
+ graceUntil: normalized.graceUntil || '<unknown>',
523
+ nextCheckAt: normalized.nextCheckAt || '<default>'
524
+ });
525
+ this.scheduleSubscriptionCheck(normalized);
526
+ }
527
+
528
+ scheduleSubscriptionCheck(status = this.subscriptionStatus) {
529
+ if (this.subscriptionTimer) {
530
+ clearTimeout(this.subscriptionTimer);
531
+ this.subscriptionTimer = null;
532
+ }
533
+ if (!status || status.active === false || !this.apiUrl || !this.apiKey) {
534
+ return;
535
+ }
536
+ const nextCheckAtMs = status.nextCheckAtMs || Date.now() + DEFAULT_SUBSCRIPTION_CHECK_MS;
537
+ const jitterMs = Math.floor(Math.random() * SUBSCRIPTION_CHECK_JITTER_MS);
538
+ const delayMs = Math.max(60_000, nextCheckAtMs - Date.now() + jitterMs);
539
+ this.subscriptionTimer = setTimeout(() => {
540
+ this.checkSubscriptionStatus().catch(async (error) => {
541
+ if (isSubscriptionExpiredError(error)) {
542
+ this.recordSubscriptionStatus({ active: false, message: '订阅已到期' });
543
+ await this.close();
544
+ return;
545
+ }
546
+ this.log('subscription status check failed', {
547
+ message: error.message || String(error)
548
+ });
549
+ this.scheduleSubscriptionCheck({
550
+ ...this.subscriptionStatus,
551
+ active: true,
552
+ nextCheckAtMs: Date.now() + DEFAULT_SUBSCRIPTION_CHECK_MS
553
+ });
554
+ });
555
+ }, delayMs);
556
+ if (typeof this.subscriptionTimer.unref === 'function') {
557
+ this.subscriptionTimer.unref();
558
+ }
559
+ }
560
+
561
+ async refreshManagedTicket(options = {}) {
562
+ const deviceId = cleanString(options.deviceId || options.device_id || this.deviceId || this.readCachedDeviceId());
563
+ if (!deviceId) {
564
+ throw new Error('deviceId is required to refresh ticket from server');
565
+ }
566
+ const device = await this.fetchServerDeviceTicket(deviceId, options);
567
+ this.deviceId = device.deviceId;
568
+ this.ticket = device.ticket;
569
+ this.writeCachedDeviceId(device.deviceId);
570
+ await this.close();
571
+ return device;
572
+ }
573
+
574
+ async withConnectionRetry(operation, options = {}) {
575
+ try {
576
+ await this.ensureSubscriptionActive(options);
577
+ const client = await this.connect(options);
578
+ return await operation(client);
579
+ } catch (error) {
580
+ if (isSubscriptionExpiredError(error)) {
581
+ await this.close();
582
+ throw new Error('订阅已到期');
583
+ }
584
+ if (!this.shouldRetryWithServerTicket(options, error)) {
585
+ throw error;
586
+ }
587
+ return this.retryManagedConnection(operation, options, error);
588
+ }
589
+ }
590
+
591
+ async retryManagedConnection(operation, options, firstError) {
592
+ const deadline = Date.now() + this.reconnectWindowMs;
593
+ let lastError = firstError;
594
+ this.log('P2P operation failed; retry current ticket before server refresh', {
595
+ message: firstError.message || String(firstError),
596
+ retryWindowMs: this.reconnectWindowMs
597
+ });
598
+
599
+ while (Date.now() < deadline) {
600
+ await this.close();
601
+ try {
602
+ const client = await this.connect(options);
603
+ return await operation(client);
604
+ } catch (error) {
605
+ lastError = error;
606
+ await delay(Math.min(1000, Math.max(100, deadline - Date.now())));
607
+ }
608
+ }
609
+
610
+ this.log('current ticket retry exhausted; refresh ticket from server', {
611
+ message: lastError.message || String(lastError),
612
+ deviceId: this.deviceId || '<cache>'
613
+ });
614
+ await this.refreshManagedTicket(options);
615
+ const client = await this.connect(options);
616
+ return operation(client);
617
+ }
618
+
619
+ shouldRetryWithServerTicket(options, error) {
620
+ if (cleanString(options.ticket)) {
621
+ return false;
622
+ }
623
+ if (this.staticTicket && !this.deviceId && !this.readCachedDeviceId()) {
624
+ return false;
625
+ }
626
+ return isLikelyConnectionError(error);
627
+ }
628
+
629
+ async sendCommand(name, text, options = {}) {
630
+ if (!cleanString(name)) {
631
+ throw new Error('command name is required');
632
+ }
633
+ const traceOptions = this.prepareTraceOptions(options);
634
+ return runWithTrace(traceOptions.traceId, async () => {
635
+ this.log('command dispatch', {
636
+ trace_id: traceOptions.traceId,
637
+ name
638
+ });
639
+ return normalizeMessage(await this.withConnectionRetry(async (client) => (
640
+ client.sendCommand(String(name), optionalText(text), {
641
+ timeoutMs: normalizeTimeout(traceOptions.timeoutMs || traceOptions.timeout_ms, this.timeoutMs),
642
+ traceId: traceOptions.traceId
643
+ })
644
+ ), traceOptions));
645
+ });
646
+ }
647
+
648
+ async sendText(text, options = {}) {
649
+ if (typeof text !== 'string' || text.length === 0) {
650
+ throw new Error('text is required');
651
+ }
652
+ const traceOptions = this.prepareTraceOptions(options);
653
+ return runWithTrace(traceOptions.traceId, async () => {
654
+ this.log('text dispatch', {
655
+ trace_id: traceOptions.traceId
656
+ });
657
+ return normalizeMessage(await this.withConnectionRetry(async (client) => (
658
+ client.sendText(text, {
659
+ timeoutMs: normalizeTimeout(traceOptions.timeoutMs || traceOptions.timeout_ms, this.timeoutMs),
660
+ traceId: traceOptions.traceId
661
+ })
662
+ ), traceOptions));
663
+ });
664
+ }
665
+
666
+ async sendOnlyCommand(name, text, options = {}) {
667
+ if (!cleanString(name)) {
668
+ throw new Error('command name is required');
669
+ }
670
+ const traceOptions = this.prepareTraceOptions(options);
671
+ return runWithTrace(traceOptions.traceId, async () => {
672
+ this.log('send-only command dispatch', {
673
+ trace_id: traceOptions.traceId,
674
+ name
675
+ });
676
+ return this.withConnectionRetry(
677
+ (client) => client.sendOnlyCommand(String(name), optionalText(text), {
678
+ traceId: traceOptions.traceId
679
+ }),
680
+ traceOptions
681
+ );
682
+ });
683
+ }
684
+
685
+ async sendJsonRpc(method, params = {}, options = {}) {
686
+ if (!cleanString(method)) {
687
+ throw new Error('json-rpc method is required');
688
+ }
689
+ const traceOptions = this.prepareTraceOptions(options);
690
+ return runWithTrace(traceOptions.traceId, async () => {
691
+ this.log('json-rpc dispatch', {
692
+ trace_id: traceOptions.traceId,
693
+ method
694
+ });
695
+ const result = await this.withConnectionRetry((client) => client.sendJsonRpc(String(method), params || {}, {
696
+ timeoutMs: normalizeTimeout(traceOptions.timeoutMs || traceOptions.timeout_ms, this.timeoutMs),
697
+ traceId: traceOptions.traceId
698
+ }), traceOptions);
699
+ if (result && result.ok === false) {
700
+ const message = result.error && (result.error.message || result.error.code)
701
+ ? `${result.error.code || 'json-rpc error'}: ${result.error.message || ''}`.trim()
702
+ : 'json-rpc request failed';
703
+ throw new Error(message);
704
+ }
705
+ return result;
706
+ });
707
+ }
708
+
709
+ async getDeviceContext(options = {}) {
710
+ const traceOptions = this.prepareTraceOptions(options);
711
+ try {
712
+ return normalizeDeviceContext(await this.sendJsonRpc('device.context', {}, traceOptions), traceOptions);
713
+ } catch (jsonRpcError) {
714
+ this.log('device.context json-rpc failed; trying command fallback', {
715
+ trace_id: traceOptions.traceId,
716
+ message: jsonRpcError.message || String(jsonRpcError)
717
+ });
718
+ return normalizeDeviceContext(await this.sendCommand('get-device-context', null, traceOptions), traceOptions);
719
+ }
720
+ }
721
+
722
+ taskMemory(args = {}) {
723
+ return taskMemoryOperation({
724
+ ...args,
725
+ path: this.taskMemoryPath
726
+ });
727
+ }
728
+
729
+ async inputTap(x, y, options = {}) {
730
+ return this.sendJsonRpc('input.tap', {
731
+ x: requireFiniteNumber(x, 'x'),
732
+ y: requireFiniteNumber(y, 'y'),
733
+ profile: cleanString(options.profile) || 'human'
734
+ }, options);
735
+ }
736
+
737
+ async inputLongPress(x, y, options = {}) {
738
+ return this.sendJsonRpc('input.longPress', {
739
+ x: requireFiniteNumber(x, 'x'),
740
+ y: requireFiniteNumber(y, 'y'),
741
+ durationMs: normalizeOptionalPositiveInt(options.durationMs || options.duration_ms),
742
+ profile: cleanString(options.profile) || 'human'
743
+ }, options);
744
+ }
745
+
746
+ async inputSwipe(args = {}) {
747
+ return this.sendJsonRpc('input.swipe', {
748
+ startX: requireFiniteNumber(args.startX, 'startX'),
749
+ startY: requireFiniteNumber(args.startY, 'startY'),
750
+ endX: requireFiniteNumber(args.endX, 'endX'),
751
+ endY: requireFiniteNumber(args.endY, 'endY'),
752
+ durationMs: normalizeOptionalPositiveInt(args.durationMs || args.duration_ms),
753
+ profile: cleanString(args.profile) || 'human'
754
+ }, args);
755
+ }
756
+
757
+ async inputGesture(args = {}) {
758
+ const rawPoints = Array.isArray(args.points) ? args.points : null;
759
+ if (!rawPoints || rawPoints.length < 2) {
760
+ throw new Error('points must be an array of at least 2 entries');
761
+ }
762
+ const points = rawPoints.map((p, i) => ({
763
+ x: requireFiniteNumber(p && p.x, `points[${i}].x`),
764
+ y: requireFiniteNumber(p && p.y, `points[${i}].y`),
765
+ tMs: requireFiniteNumber(p && (p.tMs ?? p.t_ms ?? p.t), `points[${i}].tMs`)
766
+ }));
767
+ return this.sendJsonRpc('input.gesture', {
768
+ points,
769
+ durationMs: normalizeOptionalPositiveInt(args.durationMs || args.duration_ms),
770
+ profile: cleanString(args.profile) || 'human'
771
+ }, args);
772
+ }
773
+
774
+ async inputText(text, options = {}) {
775
+ if (typeof text !== 'string') {
776
+ throw new Error('text is required');
777
+ }
778
+ return this.sendJsonRpc('input.text', {
779
+ text,
780
+ replace: Boolean(options.replace)
781
+ }, options);
782
+ }
783
+
784
+ async inputClear(options = {}) {
785
+ return this.sendJsonRpc('input.clear', {}, options);
786
+ }
787
+
788
+ async inputKey(key, options = {}) {
789
+ return this.sendJsonRpc('input.key', {
790
+ key: requireString(key, 'key')
791
+ }, options);
792
+ }
793
+
794
+ async globalAction(action, options = {}) {
795
+ return this.sendJsonRpc('device.globalAction', {
796
+ action: requireString(action, 'action')
797
+ }, options);
798
+ }
799
+
800
+ async appStart(packageName, options = {}) {
801
+ return this.sendJsonRpc('app.start', {
802
+ packageName: requireString(packageName, 'packageName'),
803
+ ...(cleanString(options.activity) ? { activity: cleanString(options.activity) } : {})
804
+ }, options);
805
+ }
806
+
807
+ async appStop(packageName, options = {}) {
808
+ return this.sendJsonRpc('app.stop', {
809
+ packageName: requireString(packageName, 'packageName')
810
+ }, options);
811
+ }
812
+
813
+ async listLaunchableApps(options = {}) {
814
+ return this.sendCommand('apps.listLaunchable', JSON.stringify({
815
+ includeSystem: options.includeSystem !== false
816
+ }), options);
817
+ }
818
+
819
+ async startScreenStream(text, options = {}) {
820
+ const traceOptions = this.prepareTraceOptions(options);
821
+ return runWithTrace(traceOptions.traceId, async () => {
822
+ const outputDir = path.resolve(options.outputDir || options.output_dir || this.outputDir);
823
+ fs.mkdirSync(outputDir, { recursive: true });
824
+ const startedAt = Date.now();
825
+ this.log('start h264 stream', {
826
+ trace_id: traceOptions.traceId,
827
+ outputDir,
828
+ hasText: optionalText(text) !== null
829
+ });
830
+ const file = await this.withConnectionRetry(
831
+ (client) => client.startH264Stream(optionalText(text), outputDir, {
832
+ traceId: traceOptions.traceId
833
+ }),
834
+ traceOptions
835
+ );
836
+ const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
837
+ this.log('h264 stream finished', {
838
+ file,
839
+ bytes: size,
840
+ durationMs: Date.now() - startedAt
841
+ });
842
+ return file;
843
+ });
844
+ }
845
+
846
+ async startLiveView(text, options = {}) {
847
+ const traceOptions = this.prepareTraceOptions(options);
848
+ const host = cleanString(options.host) || DEFAULT_VIEW_HOST;
849
+ const port = normalizePort(options.port, DEFAULT_VIEW_PORT);
850
+ const server = new H264LiveViewServer(this, text, { host, port, traceId: traceOptions.traceId });
851
+ await server.start();
852
+ return server;
853
+ }
854
+
855
+ async callTool(name, args = {}) {
856
+ const traceOptions = this.prepareTraceOptions(args);
857
+ return runWithTrace(traceOptions.traceId, async () => {
858
+ this.log('tool call received', {
859
+ trace_id: traceOptions.traceId,
860
+ name,
861
+ hasTicketOverride: Boolean(cleanString(traceOptions.ticket))
862
+ });
863
+ switch (name) {
864
+ case 'kln_set_api_key':
865
+ return this.setApiKey(traceOptions.apiKey || traceOptions.api_key, traceOptions);
866
+ case 'kln_list_devices':
867
+ return this.listServerDevices(traceOptions);
868
+ case 'kln_select_device':
869
+ return this.selectDevice(traceOptions.deviceId || traceOptions.device_id, traceOptions);
870
+ case 'kln_send_command':
871
+ return this.sendCommand(traceOptions.name, traceOptions.text, traceOptions);
872
+ case 'kln_send_text':
873
+ return this.sendText(traceOptions.text, traceOptions);
874
+ case 'kln_start_screen_stream': {
875
+ const file = await this.startScreenStream(traceOptions.text, traceOptions);
876
+ return {
877
+ ok: true,
878
+ trace_id: traceOptions.traceId,
879
+ file,
880
+ summary: `H264 stream saved to ${file}`
881
+ };
882
+ }
883
+ case 'kln_get_device_context':
884
+ return this.getDeviceContext(traceOptions);
885
+ case 'kln_task_memory':
886
+ return this.taskMemory(traceOptions);
887
+ case 'kln_input_tap':
888
+ return this.inputTap(traceOptions.x, traceOptions.y, traceOptions);
889
+ case 'kln_input_swipe':
890
+ return this.inputSwipe(traceOptions);
891
+ case 'kln_input_text':
892
+ return this.inputText(traceOptions.text, traceOptions);
893
+ case 'kln_input_clear':
894
+ return this.inputClear(traceOptions);
895
+ case 'kln_input_key':
896
+ return this.inputKey(traceOptions.key, traceOptions);
897
+ case 'kln_device_global_action':
898
+ return this.globalAction(traceOptions.action, traceOptions);
899
+ case 'kln_app_start':
900
+ return this.appStart(traceOptions.packageName, traceOptions);
901
+ case 'kln_app_stop':
902
+ return this.appStop(traceOptions.packageName, traceOptions);
903
+ case 'kln_apps_list_launchable':
904
+ return this.listLaunchableApps(traceOptions);
905
+ default:
906
+ throw new Error(`unknown tool: ${name}`);
907
+ }
908
+ });
909
+ }
910
+
911
+ async close() {
912
+ if (this.subscriptionTimer) {
913
+ clearTimeout(this.subscriptionTimer);
914
+ this.subscriptionTimer = null;
915
+ }
916
+ if (!this.client) {
917
+ return;
918
+ }
919
+
920
+ const client = this.client;
921
+ this.client = null;
922
+ this.clientTicket = null;
923
+ this.clientKey = null;
924
+ this.connectPromise = null;
925
+ this.connectKey = null;
926
+ this.log('closing P2P connection');
927
+ await client.close();
928
+ }
929
+
930
+ resolveApiKey(options = {}) {
931
+ const apiKey = cleanString(options.apiKey || options.api_key || this.apiKey);
932
+ if (!apiKey) {
933
+ throw new Error('apiKey is required. Pass --api-key, set KLN_API_KEY, or call kln_set_api_key.');
934
+ }
935
+ this.apiKey = apiKey;
936
+ return apiKey;
937
+ }
938
+
939
+ async serverRequest(pathname, options = {}) {
940
+ const traceOptions = this.prepareTraceOptions(options);
941
+ if (!this.apiUrl) {
942
+ throw new Error('api_url is required in mcp/config or KLN_API_URL.');
943
+ }
944
+
945
+ const apiKey = this.resolveApiKey(traceOptions);
946
+ const url = new URL(pathname, ensureTrailingSlash(this.apiUrl));
947
+ this.log('server request', {
948
+ trace_id: traceOptions.traceId,
949
+ method: 'GET',
950
+ url: url.toString()
951
+ });
952
+
953
+ const payload = await requestJson(url, {
954
+ headers: {
955
+ 'Api-Key': apiKey,
956
+ 'Accept': 'application/json',
957
+ 'X-Trace-Id': traceOptions.traceId
958
+ },
959
+ timeoutMs: normalizeTimeout(traceOptions.timeoutMs || traceOptions.timeout_ms, this.timeoutMs)
960
+ });
961
+
962
+ if (!payload || typeof payload !== 'object') {
963
+ throw new Error('server response is not JSON object');
964
+ }
965
+ if (payload.code !== 0) {
966
+ const message = payload.error || payload.message || 'server request failed';
967
+ if (isSubscriptionExpiredMessage(message)) {
968
+ this.recordSubscriptionStatus({ active: false, message: '订阅已到期' });
969
+ await this.close();
970
+ }
971
+ throw new Error(message);
972
+ }
973
+ return payload.data;
974
+ }
975
+
976
+ readCachedDeviceId() {
977
+ try {
978
+ const value = JSON.parse(fs.readFileSync(this.deviceCachePath, 'utf8'));
979
+ return cleanString(value && value.deviceId);
980
+ } catch (_) {
981
+ return '';
982
+ }
983
+ }
984
+
985
+ writeCachedDeviceId(deviceId) {
986
+ const normalized = cleanString(deviceId);
987
+ if (!normalized) {
988
+ return;
989
+ }
990
+ try {
991
+ fs.mkdirSync(path.dirname(this.deviceCachePath), { recursive: true });
992
+ fs.writeFileSync(this.deviceCachePath, `${JSON.stringify({
993
+ deviceId: normalized,
994
+ updatedAt: new Date().toISOString()
995
+ }, null, 2)}\n`);
996
+ } catch (error) {
997
+ this.log('write selected device cache failed', {
998
+ path: this.deviceCachePath,
999
+ message: error.message || String(error)
1000
+ });
1001
+ }
1002
+ }
1003
+
1004
+ log(message, fields) {
1005
+ writeDevLog(this.debug, message, fields);
1006
+ }
1007
+
1008
+ prepareTraceOptions(options = {}) {
1009
+ const traceId = resolveTraceId(options, this.traceId);
1010
+ return {
1011
+ ...options,
1012
+ traceId,
1013
+ trace_id: traceId
1014
+ };
1015
+ }
1016
+ }
1017
+
1018
+ class H264LiveViewServer {
1019
+ constructor(controller, text, options) {
1020
+ this.controller = controller;
1021
+ this.text = text;
1022
+ this.host = options.host;
1023
+ this.port = options.port;
1024
+ this.traceId = options.traceId;
1025
+ this.clients = new Set();
1026
+ this.clientBuffers = new Map();
1027
+ this.httpServer = null;
1028
+ this.streaming = false;
1029
+ this.connectionState = 'idle';
1030
+ this.deviceConnectPromise = null;
1031
+ this.cachedConfigPacket = null;
1032
+ this.cachedKeyPacket = null;
1033
+ this.closed = false;
1034
+ this.closePromise = new Promise((resolve) => {
1035
+ this.resolveClose = resolve;
1036
+ });
1037
+ }
1038
+
1039
+ get url() {
1040
+ return `http://${this.host}:${this.port}/`;
1041
+ }
1042
+
1043
+ async start() {
1044
+ this.httpServer = http.createServer((request, response) => {
1045
+ if (request.url === '/' || request.url === '/index.html') {
1046
+ const html = loadLiveViewHtml();
1047
+ response.writeHead(200, {
1048
+ 'content-type': 'text/html; charset=utf-8',
1049
+ 'content-length': Buffer.byteLength(html)
1050
+ });
1051
+ response.end(html);
1052
+ return;
1053
+ }
1054
+
1055
+ response.writeHead(404);
1056
+ response.end('not found');
1057
+ });
1058
+
1059
+ this.httpServer.on('upgrade', (request, socket) => {
1060
+ if (request.url !== '/ws') {
1061
+ socket.destroy();
1062
+ return;
1063
+ }
1064
+ this.acceptWebSocket(request, socket);
1065
+ });
1066
+
1067
+ await new Promise((resolve, reject) => {
1068
+ this.httpServer.once('error', reject);
1069
+ this.httpServer.listen(this.port, this.host, () => {
1070
+ this.httpServer.off('error', reject);
1071
+ resolve();
1072
+ });
1073
+ });
1074
+
1075
+ this.controller.log('live view server started', {
1076
+ url: this.url,
1077
+ note: 'P2P connection starts now; open this URL in a browser to view the H264 stream'
1078
+ });
1079
+ this.ensureDeviceConnection('startup').catch(() => {});
1080
+ this.controller.log('live view waiting for browser websocket', {
1081
+ wsUrl: `ws://${this.host}:${this.port}/ws`,
1082
+ note: 'browser websocket only starts the H264 view stream'
1083
+ });
1084
+ }
1085
+
1086
+ async ensureDeviceConnection(reason) {
1087
+ if (this.connectionState === 'connected') {
1088
+ return this.controller.connect();
1089
+ }
1090
+
1091
+ if (this.deviceConnectPromise) {
1092
+ this.controller.log('live view waiting for existing device connection', {
1093
+ reason,
1094
+ state: this.connectionState
1095
+ });
1096
+ return this.deviceConnectPromise;
1097
+ }
1098
+
1099
+ this.connectionState = 'connecting';
1100
+ const startedAt = Date.now();
1101
+ this.controller.log('live view establishing device connection', {
1102
+ reason
1103
+ });
1104
+ this.deviceConnectPromise = this.controller.connect()
1105
+ .then((client) => {
1106
+ this.connectionState = 'connected';
1107
+ this.controller.log('live view device connection ready', {
1108
+ reason,
1109
+ nodeId: client.nodeId,
1110
+ durationMs: Date.now() - startedAt
1111
+ });
1112
+ this.broadcastJson({
1113
+ type: 'connection',
1114
+ state: this.connectionState
1115
+ });
1116
+ return client;
1117
+ })
1118
+ .catch((error) => {
1119
+ this.connectionState = 'failed';
1120
+ this.controller.log('live view device connection failed', {
1121
+ reason,
1122
+ message: error.message || String(error),
1123
+ durationMs: Date.now() - startedAt
1124
+ });
1125
+ this.broadcastJson({
1126
+ type: 'error',
1127
+ message: `device connection failed: ${error.message || String(error)}`
1128
+ });
1129
+ throw error;
1130
+ })
1131
+ .finally(() => {
1132
+ this.deviceConnectPromise = null;
1133
+ });
1134
+
1135
+ return this.deviceConnectPromise;
1136
+ }
1137
+
1138
+ acceptWebSocket(request, socket) {
1139
+ const key = request.headers['sec-websocket-key'];
1140
+ if (!key) {
1141
+ socket.destroy();
1142
+ return;
1143
+ }
1144
+
1145
+ const accept = crypto
1146
+ .createHash('sha1')
1147
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
1148
+ .digest('base64');
1149
+
1150
+ socket.write([
1151
+ 'HTTP/1.1 101 Switching Protocols',
1152
+ 'Upgrade: websocket',
1153
+ 'Connection: Upgrade',
1154
+ `Sec-WebSocket-Accept: ${accept}`,
1155
+ '\r\n'
1156
+ ].join('\r\n'));
1157
+
1158
+ this.clients.add(socket);
1159
+ this.clientBuffers.set(socket, Buffer.alloc(0));
1160
+ this.controller.log('live view websocket connected', {
1161
+ clients: this.clients.size,
1162
+ remoteAddress: socket.remoteAddress || '<unknown>'
1163
+ });
1164
+ socket.on('close', () => {
1165
+ this.clients.delete(socket);
1166
+ this.clientBuffers.delete(socket);
1167
+ this.controller.log('live view websocket closed', {
1168
+ clients: this.clients.size
1169
+ });
1170
+ });
1171
+ socket.on('error', (error) => {
1172
+ this.clients.delete(socket);
1173
+ this.clientBuffers.delete(socket);
1174
+ this.controller.log('live view websocket error', {
1175
+ message: error.message || String(error),
1176
+ clients: this.clients.size
1177
+ });
1178
+ });
1179
+ socket.on('data', (chunk) => {
1180
+ this.handleWebSocketData(socket, chunk).catch((error) => {
1181
+ this.sendJson(socket, {
1182
+ type: 'input-error',
1183
+ message: error.message || String(error)
1184
+ });
1185
+ });
1186
+ });
1187
+
1188
+ this.sendJson(socket, {
1189
+ type: 'hello',
1190
+ url: this.url,
1191
+ connectionState: this.connectionState
1192
+ });
1193
+ this.sendDeviceContext(socket, 'websocket-connected').catch(() => {});
1194
+ this.replayCachedDecoderPackets(socket);
1195
+
1196
+ if (!this.streaming) {
1197
+ this.controller.log('live view starting stream after websocket connection');
1198
+ this.startStreaming().catch((error) => {
1199
+ this.broadcastJson({
1200
+ type: 'error',
1201
+ message: error.message || String(error)
1202
+ });
1203
+ this.controller.log('live view stream failed', {
1204
+ message: error.message || String(error)
1205
+ });
1206
+ }).finally(() => {
1207
+ this.streaming = false;
1208
+ });
1209
+ }
1210
+ }
1211
+
1212
+ async startStreaming() {
1213
+ this.streaming = true;
1214
+ this.controller.log('live h264 stream waiting for device connection');
1215
+ const client = await this.ensureDeviceConnection('stream');
1216
+ const startedAt = Date.now();
1217
+ this.controller.log('live h264 stream request dispatching', {
1218
+ hasText: optionalText(this.text) !== null
1219
+ });
1220
+ this.cachedConfigPacket = null;
1221
+ this.cachedKeyPacket = null;
1222
+
1223
+ const stats = await new Promise((resolve, reject) => {
1224
+ let packets = 0;
1225
+ let bytes = 0;
1226
+ let firstPacket = true;
1227
+ this.controller.log('live h264 stream waiting for packets');
1228
+ client.startH264Live(optionalText(this.text), (packet) => {
1229
+ if (firstPacket) {
1230
+ firstPacket = false;
1231
+ this.controller.log('live h264 first packet received', {
1232
+ kind: packet.kind,
1233
+ flags: packet.flags,
1234
+ bytes: packet.data ? packet.data.length : 0,
1235
+ width: packet.width,
1236
+ height: packet.height,
1237
+ endReason: packet.endReason || undefined
1238
+ });
1239
+ }
1240
+ if (packet.kind === 3) {
1241
+ resolve({
1242
+ packets,
1243
+ bytes,
1244
+ endReason: packet.endReason || 'ended'
1245
+ });
1246
+ return;
1247
+ }
1248
+ if (packet.kind === 4) {
1249
+ reject(new Error(packet.endReason || 'h264 stream error'));
1250
+ return;
1251
+ }
1252
+ packets += 1;
1253
+ bytes += packet.data ? packet.data.length : 0;
1254
+ const encodedPacket = encodeH264Packet(packet);
1255
+ this.rememberDecoderPacket(packet, encodedPacket);
1256
+ this.broadcastBinary(encodedPacket);
1257
+ }, { traceId: this.traceId });
1258
+ });
1259
+
1260
+ this.controller.log('live h264 stream finished', {
1261
+ packets: stats.packets,
1262
+ bytes: stats.bytes,
1263
+ endReason: stats.endReason,
1264
+ durationMs: Date.now() - startedAt
1265
+ });
1266
+ this.broadcastJson({
1267
+ type: 'end',
1268
+ reason: stats.endReason,
1269
+ packets: stats.packets,
1270
+ bytes: stats.bytes
1271
+ });
1272
+ this.cachedConfigPacket = null;
1273
+ this.cachedKeyPacket = null;
1274
+ }
1275
+
1276
+ rememberDecoderPacket(packet, encodedPacket) {
1277
+ if (packet.kind === 1 || (packet.flags & 2) === 2) {
1278
+ this.cachedConfigPacket = encodedPacket;
1279
+ }
1280
+ if (packet.kind === 2 && (packet.flags & 1) === 1) {
1281
+ this.cachedKeyPacket = encodedPacket;
1282
+ }
1283
+ }
1284
+
1285
+ replayCachedDecoderPackets(socket) {
1286
+ const packets = [];
1287
+ if (this.cachedConfigPacket) {
1288
+ packets.push(this.cachedConfigPacket);
1289
+ }
1290
+ if (this.cachedKeyPacket) {
1291
+ packets.push(this.cachedKeyPacket);
1292
+ }
1293
+ if (packets.length === 0) {
1294
+ return;
1295
+ }
1296
+
1297
+ this.controller.log('live view replay cached decoder packets', {
1298
+ packets: packets.length,
1299
+ hasConfig: Boolean(this.cachedConfigPacket),
1300
+ hasKeyFrame: Boolean(this.cachedKeyPacket)
1301
+ });
1302
+ for (const packet of packets) {
1303
+ sendWebSocketFrame(socket, packet, 0x2);
1304
+ }
1305
+ }
1306
+
1307
+ sendJson(socket, payload) {
1308
+ sendWebSocketFrame(socket, Buffer.from(JSON.stringify(payload)), 0x1);
1309
+ }
1310
+
1311
+ broadcastJson(payload) {
1312
+ const data = Buffer.from(JSON.stringify(payload));
1313
+ for (const socket of this.clients) {
1314
+ sendWebSocketFrame(socket, data, 0x1);
1315
+ }
1316
+ }
1317
+
1318
+ broadcastBinary(data) {
1319
+ for (const socket of this.clients) {
1320
+ sendWebSocketFrame(socket, data, 0x2);
1321
+ }
1322
+ }
1323
+
1324
+ async handleWebSocketData(socket, chunk) {
1325
+ let buffer = Buffer.concat([this.clientBuffers.get(socket) || Buffer.alloc(0), chunk]);
1326
+ while (buffer.length >= 2) {
1327
+ const parsed = parseClientWebSocketFrame(buffer);
1328
+ if (!parsed) {
1329
+ break;
1330
+ }
1331
+ buffer = buffer.slice(parsed.consumed);
1332
+ if (parsed.opcode === 0x8) {
1333
+ socket.end();
1334
+ return;
1335
+ }
1336
+ if (parsed.opcode !== 0x1) {
1337
+ continue;
1338
+ }
1339
+ const message = JSON.parse(parsed.payload.toString('utf8'));
1340
+ this.controller.log('live view input received from browser', inputLogFields(message));
1341
+ this.handleBrowserInput(socket, message).catch((error) => {
1342
+ this.sendJson(socket, {
1343
+ type: 'input-error',
1344
+ message: error.message || String(error)
1345
+ });
1346
+ });
1347
+ }
1348
+ this.clientBuffers.set(socket, buffer);
1349
+ }
1350
+
1351
+ async handleBrowserInput(socket, message) {
1352
+ if (!message || message.type !== 'input') {
1353
+ return;
1354
+ }
1355
+ const traceId = cleanString(message.traceId || message.trace_id) || this.traceId;
1356
+ const startedAt = Date.now();
1357
+ const ack = (payload) => {
1358
+ this.sendJson(socket, {
1359
+ ...payload,
1360
+ clientSentAtMs: message.clientSentAtMs,
1361
+ serverReceivedAtMs: startedAt,
1362
+ serverAckAtMs: Date.now()
1363
+ });
1364
+ };
1365
+ if (message.action === 'tap') {
1366
+ const result = await this.controller.inputTap(message.x, message.y, {
1367
+ profile: message.profile || 'human',
1368
+ traceId
1369
+ });
1370
+ this.controller.log('live view input ack from device', {
1371
+ ...inputLogFields(message),
1372
+ serverToAckMs: Date.now() - startedAt
1373
+ });
1374
+ ack({ type: 'input-ack', action: 'tap', result });
1375
+ return;
1376
+ }
1377
+ if (message.action === 'longPress') {
1378
+ const result = await this.controller.inputLongPress(message.x, message.y, {
1379
+ durationMs: message.durationMs,
1380
+ profile: message.profile || 'human',
1381
+ traceId
1382
+ });
1383
+ this.controller.log('live view input ack from device', {
1384
+ ...inputLogFields(message),
1385
+ durationMs: message.durationMs,
1386
+ serverToAckMs: Date.now() - startedAt
1387
+ });
1388
+ ack({ type: 'input-ack', action: 'longPress', result });
1389
+ return;
1390
+ }
1391
+ if (message.action === 'swipe') {
1392
+ const result = await this.controller.inputSwipe({
1393
+ startX: message.startX,
1394
+ startY: message.startY,
1395
+ endX: message.endX,
1396
+ endY: message.endY,
1397
+ durationMs: message.durationMs,
1398
+ profile: message.profile || 'human',
1399
+ traceId
1400
+ });
1401
+ this.controller.log('live view input ack from device', {
1402
+ ...inputLogFields(message),
1403
+ serverToAckMs: Date.now() - startedAt
1404
+ });
1405
+ ack({ type: 'input-ack', action: 'swipe', result });
1406
+ return;
1407
+ }
1408
+ if (message.action === 'gesture') {
1409
+ const result = await this.controller.inputGesture({
1410
+ points: message.points,
1411
+ durationMs: message.durationMs,
1412
+ profile: message.profile || 'human',
1413
+ traceId
1414
+ });
1415
+ this.controller.log('live view input ack from device', {
1416
+ ...inputLogFields(message),
1417
+ pointCount: Array.isArray(message.points) ? message.points.length : 0,
1418
+ serverToAckMs: Date.now() - startedAt
1419
+ });
1420
+ ack({ type: 'input-ack', action: 'gesture', result });
1421
+ return;
1422
+ }
1423
+ throw new Error(`unsupported browser input action: ${message.action}`);
1424
+ }
1425
+
1426
+ async sendDeviceContext(socket, reason) {
1427
+ try {
1428
+ const context = await this.controller.getDeviceContext({ traceId: this.traceId });
1429
+ this.sendJson(socket, {
1430
+ type: 'device-context',
1431
+ reason,
1432
+ context,
1433
+ serverSentAtMs: Date.now()
1434
+ });
1435
+ this.controller.log('live view device context sent', {
1436
+ reason
1437
+ });
1438
+ } catch (error) {
1439
+ this.sendJson(socket, {
1440
+ type: 'device-context-error',
1441
+ reason,
1442
+ message: error.message || String(error),
1443
+ serverSentAtMs: Date.now()
1444
+ });
1445
+ this.controller.log('live view device context query failed', {
1446
+ reason,
1447
+ message: error.message || String(error)
1448
+ });
1449
+ }
1450
+ }
1451
+
1452
+ async close() {
1453
+ if (this.closed) {
1454
+ return;
1455
+ }
1456
+ this.closed = true;
1457
+ for (const socket of this.clients) {
1458
+ socket.end();
1459
+ }
1460
+ this.clients.clear();
1461
+ if (this.httpServer) {
1462
+ await new Promise((resolve) => this.httpServer.close(resolve));
1463
+ }
1464
+ await this.controller.close();
1465
+ this.resolveClose();
1466
+ }
1467
+
1468
+ wait() {
1469
+ return this.closePromise;
1470
+ }
1471
+ }
1472
+
1473
+ class KlnMcpServer {
1474
+ constructor(options = {}) {
1475
+ this.controller = options.controller || new KlnMobileController(options);
1476
+ this.serverInfo = {
1477
+ name: options.name || 'kln-mobile-ctrl',
1478
+ version: options.version || '0.1.0'
1479
+ };
1480
+ }
1481
+
1482
+ async handleRequest(request) {
1483
+ const { id, method, params = {} } = request;
1484
+ const traceId = resolveTraceId(params && params.arguments ? params.arguments : params, this.controller.traceId);
1485
+ return runWithTrace(traceId, async () => {
1486
+ this.controller.log('mcp request received', {
1487
+ trace_id: traceId,
1488
+ id,
1489
+ method
1490
+ });
1491
+
1492
+ try {
1493
+ if (method === 'initialize') {
1494
+ return response(id, {
1495
+ protocolVersion: params.protocolVersion || '2024-11-05',
1496
+ capabilities: { tools: {} },
1497
+ serverInfo: this.serverInfo
1498
+ });
1499
+ }
1500
+
1501
+ if (method === 'notifications/initialized') {
1502
+ return null;
1503
+ }
1504
+
1505
+ if (method === 'tools/list') {
1506
+ return response(id, { tools: TOOL_DEFINITIONS });
1507
+ }
1508
+
1509
+ if (method === 'tools/call') {
1510
+ const args = {
1511
+ ...(params.arguments || {}),
1512
+ traceId: cleanString((params.arguments || {}).traceId || (params.arguments || {}).trace_id) || traceId
1513
+ };
1514
+ const result = await this.controller.callTool(params.name, args);
1515
+ return response(id, {
1516
+ content: [
1517
+ {
1518
+ type: 'text',
1519
+ text: formatToolResult(result)
1520
+ }
1521
+ ]
1522
+ });
1523
+ }
1524
+
1525
+ return errorResponse(id, -32601, `method not found: ${method}`, traceId);
1526
+ } catch (error) {
1527
+ return errorResponse(id, -32000, error.message || String(error), traceId);
1528
+ }
1529
+ });
1530
+ }
1531
+
1532
+ async listenStdio(input = process.stdin, output = process.stdout) {
1533
+ let buffer = Buffer.alloc(0);
1534
+ this.controller.log('mcp stdio server started; waiting for MCP client requests', {
1535
+ note: 'P2P connection is created on the first tool call, not at process startup'
1536
+ });
1537
+
1538
+ input.on('data', async (chunk) => {
1539
+ this.controller.log('stdio data received', {
1540
+ bytes: chunk.length
1541
+ });
1542
+ buffer = Buffer.concat([buffer, chunk]);
1543
+ const parsed = parseMessages(buffer);
1544
+ buffer = parsed.rest;
1545
+
1546
+ for (const message of parsed.messages) {
1547
+ const result = await this.handleRequest(message);
1548
+ if (result) {
1549
+ writeJsonRpc(output, result);
1550
+ }
1551
+ }
1552
+ });
1553
+
1554
+ const shutdown = async () => {
1555
+ await this.controller.close();
1556
+ };
1557
+ process.once('SIGINT', shutdown);
1558
+ process.once('SIGTERM', shutdown);
1559
+ }
1560
+ }
1561
+
1562
+ function createController(options) {
1563
+ return new KlnMobileController(options);
1564
+ }
1565
+
1566
+ function createMcpServer(options) {
1567
+ return new KlnMcpServer(options);
1568
+ }
1569
+
1570
+ function normalizeMessage(message) {
1571
+ if (!message || typeof message !== 'object') {
1572
+ return message;
1573
+ }
1574
+
1575
+ const body = normalizeBody(message.body);
1576
+ return {
1577
+ ...message,
1578
+ body,
1579
+ ok: responseOk(body),
1580
+ text: responseText(body),
1581
+ media: responseMedia(body)
1582
+ };
1583
+ }
1584
+
1585
+ function normalizeDeviceList(data) {
1586
+ const source = Array.isArray(data)
1587
+ ? data
1588
+ : Array.isArray(data && data.devices)
1589
+ ? data.devices
1590
+ : Array.isArray(data && data.list)
1591
+ ? data.list
1592
+ : [];
1593
+ return source.map(normalizeDevice).filter(Boolean);
1594
+ }
1595
+
1596
+ function normalizeDevice(data) {
1597
+ if (!data || typeof data !== 'object') {
1598
+ return null;
1599
+ }
1600
+ const deviceId = cleanString(data.deviceId || data.device_id || data.id);
1601
+ const ticket = cleanString(data.ticket);
1602
+ if (!deviceId) {
1603
+ return null;
1604
+ }
1605
+ return {
1606
+ deviceId,
1607
+ deviceInfo: data.deviceInfo || data.device_info || '',
1608
+ deviceRemark: data.deviceRemark || data.device_remark || data.remark || '',
1609
+ ticket,
1610
+ lastHeartbeatAt: data.lastHeartbeatAt || data.last_heartbeat_at || 0,
1611
+ status: data.status,
1612
+ subscription: normalizeSubscriptionStatus(data.subscription)
1613
+ };
1614
+ }
1615
+
1616
+ function normalizeSubscriptionStatus(data) {
1617
+ if (!data || typeof data !== 'object') {
1618
+ return null;
1619
+ }
1620
+ const expiresAt = cleanString(data.expiresAt || data.expires_at);
1621
+ const graceUntil = cleanString(data.graceUntil || data.grace_until);
1622
+ const nextCheckAt = cleanString(data.nextCheckAt || data.next_check_at);
1623
+ const expiresAtMs = parseDateMs(expiresAt);
1624
+ const graceUntilMs = parseDateMs(graceUntil);
1625
+ const nextCheckAtMs = parseDateMs(nextCheckAt);
1626
+ return {
1627
+ active: data.active !== false,
1628
+ userId: data.userId || data.user_id || 0,
1629
+ vipLevel: data.vipLevel || data.vip_level || 0,
1630
+ expiresAt,
1631
+ expiresAtMs,
1632
+ graceUntil,
1633
+ graceUntilMs,
1634
+ nextCheckAt,
1635
+ nextCheckAtMs,
1636
+ message: data.message || ''
1637
+ };
1638
+ }
1639
+
1640
+ function parseDateMs(value) {
1641
+ if (!value) {
1642
+ return 0;
1643
+ }
1644
+ const parsed = Date.parse(value);
1645
+ return Number.isFinite(parsed) ? parsed : 0;
1646
+ }
1647
+
1648
+ function requestJson(url, options = {}) {
1649
+ const timeoutMs = normalizeTimeout(options.timeoutMs || options.timeout_ms, DEFAULT_TIMEOUT_MS);
1650
+ return new Promise((resolve, reject) => {
1651
+ const transport = url.protocol === 'https:' ? https : http;
1652
+ const request = transport.request(url, {
1653
+ method: options.method || 'GET',
1654
+ headers: options.headers || {}
1655
+ }, (response) => {
1656
+ const chunks = [];
1657
+ response.on('data', (chunk) => chunks.push(chunk));
1658
+ response.on('end', () => {
1659
+ const text = Buffer.concat(chunks).toString('utf8');
1660
+ if (response.statusCode < 200 || response.statusCode >= 300) {
1661
+ reject(new Error(`server HTTP ${response.statusCode}: ${text}`));
1662
+ return;
1663
+ }
1664
+ try {
1665
+ resolve(text ? JSON.parse(text) : null);
1666
+ } catch (error) {
1667
+ reject(new Error(`server response JSON parse failed: ${error.message || error}`));
1668
+ }
1669
+ });
1670
+ });
1671
+ request.once('error', reject);
1672
+ request.setTimeout(timeoutMs, () => {
1673
+ request.destroy(new Error(`server request timed out after ${timeoutMs}ms`));
1674
+ });
1675
+ request.end(options.body || undefined);
1676
+ });
1677
+ }
1678
+
1679
+ function ensureTrailingSlash(value) {
1680
+ const text = String(value || '');
1681
+ return text.endsWith('/') ? text : `${text}/`;
1682
+ }
1683
+
1684
+ function delay(ms) {
1685
+ return new Promise((resolve) => setTimeout(resolve, ms));
1686
+ }
1687
+
1688
+ function isLikelyConnectionError(error) {
1689
+ const message = String(error && (error.message || error)).toLowerCase();
1690
+ return [
1691
+ 'iroh',
1692
+ 'p2p',
1693
+ '连接',
1694
+ 'quic',
1695
+ 'connection',
1696
+ 'connect',
1697
+ 'closed',
1698
+ 'reset',
1699
+ 'broken pipe',
1700
+ 'timed out',
1701
+ 'timeout',
1702
+ 'deadline',
1703
+ 'aborted',
1704
+ 'eof'
1705
+ ].some((needle) => message.includes(needle));
1706
+ }
1707
+
1708
+ function isSubscriptionExpiredError(error) {
1709
+ return isSubscriptionExpiredMessage(error && (error.message || error));
1710
+ }
1711
+
1712
+ function isSubscriptionExpiredMessage(message) {
1713
+ return String(message || '').includes('订阅已到期');
1714
+ }
1715
+
1716
+ function normalizeDeviceContext(value, options = {}) {
1717
+ const unwrapped = unwrapDeviceContext(value);
1718
+ const parsed = typeof unwrapped === 'string' ? parseJsonMaybe(unwrapped) : unwrapped;
1719
+ if (!parsed || typeof parsed !== 'object') {
1720
+ return parsed;
1721
+ }
1722
+ return compactDeviceContext(parsed, options);
1723
+ }
1724
+
1725
+ function unwrapDeviceContext(value) {
1726
+ if (!value || typeof value !== 'object') {
1727
+ return value;
1728
+ }
1729
+
1730
+ if (Object.prototype.hasOwnProperty.call(value, 'device_context')) {
1731
+ return value.device_context;
1732
+ }
1733
+ if (Object.prototype.hasOwnProperty.call(value, 'deviceContext')) {
1734
+ return value.deviceContext;
1735
+ }
1736
+ if (Object.prototype.hasOwnProperty.call(value, 'result')) {
1737
+ return unwrapDeviceContext(value.result);
1738
+ }
1739
+ if (Object.prototype.hasOwnProperty.call(value, 'data')) {
1740
+ return unwrapDeviceContext(value.data);
1741
+ }
1742
+ if (Object.prototype.hasOwnProperty.call(value, 'body')) {
1743
+ return unwrapDeviceContext(normalizeBody(value.body));
1744
+ }
1745
+ if (Object.prototype.hasOwnProperty.call(value, 'text')) {
1746
+ return parseJsonMaybe(value.text);
1747
+ }
1748
+ return value;
1749
+ }
1750
+
1751
+ function parseJsonMaybe(value) {
1752
+ if (typeof value !== 'string') {
1753
+ return value;
1754
+ }
1755
+ const trimmed = value.trim();
1756
+ if (!trimmed) {
1757
+ return value;
1758
+ }
1759
+ try {
1760
+ return unwrapDeviceContext(JSON.parse(trimmed));
1761
+ } catch {
1762
+ return value;
1763
+ }
1764
+ }
1765
+
1766
+ function compactDeviceContext(source, options = {}) {
1767
+ const limit = normalizeElementLimit(options.maxElements || options.max_elements, DEFAULT_CONTEXT_ELEMENT_LIMIT);
1768
+ const collected = collectContextElements(source, limit);
1769
+ const result = {};
1770
+ const app = firstString(
1771
+ source.app,
1772
+ source.package,
1773
+ source.packageName,
1774
+ source.package_name,
1775
+ source.currentPackage,
1776
+ source.current_package,
1777
+ source.topPackage,
1778
+ source.top_package
1779
+ );
1780
+ const activity = firstString(source.activity, source.activityName, source.activity_name, source.currentActivity, source.current_activity);
1781
+ const pageHint = firstString(source.page_hint, source.pageHint, source.page, activity);
1782
+ const screenBounds = findScreenBounds(source);
1783
+ const screenshotRef = firstString(source.screenshot_ref, source.screenshotRef, source.frame_ref, source.frameRef, source.frame_id, source.frameId);
1784
+
1785
+ if (app) result.app = app;
1786
+ if (pageHint) result.page_hint = pageHint;
1787
+ if (screenBounds) result.screen_bounds = screenBounds;
1788
+ result.elements = collected.elements;
1789
+ if (collected.truncated) result.truncated = true;
1790
+ if (screenshotRef) result.screenshot_ref = screenshotRef;
1791
+ return result;
1792
+ }
1793
+
1794
+ function collectContextElements(source, limit) {
1795
+ const elements = [];
1796
+ const seenObjects = new WeakSet();
1797
+ const seenElements = new Set();
1798
+ let truncated = false;
1799
+
1800
+ const visit = (node) => {
1801
+ if (node === null || typeof node !== 'object') {
1802
+ return;
1803
+ }
1804
+ if (seenObjects.has(node)) {
1805
+ return;
1806
+ }
1807
+ seenObjects.add(node);
1808
+
1809
+ const element = compactContextElement(node, elements.length);
1810
+ if (element) {
1811
+ const fingerprint = `${element.kind}|${element.text || ''}|${(element.bounds || []).join(',')}`;
1812
+ if (!seenElements.has(fingerprint)) {
1813
+ seenElements.add(fingerprint);
1814
+ if (elements.length < limit) {
1815
+ elements.push(element);
1816
+ } else {
1817
+ truncated = true;
1818
+ return;
1819
+ }
1820
+ }
1821
+ }
1822
+
1823
+ if (Array.isArray(node)) {
1824
+ for (const item of node) {
1825
+ visit(item);
1826
+ if (truncated) return;
1827
+ }
1828
+ return;
1829
+ }
1830
+
1831
+ for (const key of Object.keys(node)) {
1832
+ if (isScalarContextKey(key)) {
1833
+ continue;
1834
+ }
1835
+ visit(node[key]);
1836
+ if (truncated) return;
1837
+ }
1838
+ };
1839
+
1840
+ visit(source);
1841
+ return { elements, truncated };
1842
+ }
1843
+
1844
+ function compactContextElement(node, index) {
1845
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
1846
+ return null;
1847
+ }
1848
+ if (!isClickableElement(node) && !isAlreadyCompactElement(node)) {
1849
+ return null;
1850
+ }
1851
+
1852
+ const text = compactElementText(node);
1853
+ if (!text) {
1854
+ return null;
1855
+ }
1856
+
1857
+ const element = {
1858
+ i: index,
1859
+ kind: inferElementKind(node),
1860
+ text
1861
+ };
1862
+ const bounds = normalizeBounds(firstDefined(
1863
+ node.bounds,
1864
+ node.bound,
1865
+ node.rect,
1866
+ node.frame,
1867
+ node.visibleBounds,
1868
+ node.visible_bounds,
1869
+ node.screenBounds,
1870
+ node.screen_bounds
1871
+ ));
1872
+ if (bounds) {
1873
+ element.bounds = bounds;
1874
+ }
1875
+ return element;
1876
+ }
1877
+
1878
+ function compactElementText(node) {
1879
+ const text = [
1880
+ firstString(
1881
+ node.text,
1882
+ node.label,
1883
+ node.name,
1884
+ node.title,
1885
+ node.contentDescription,
1886
+ node.content_description,
1887
+ node.desc,
1888
+ node.description,
1889
+ node.accessibilityLabel,
1890
+ node.accessibility_label
1891
+ ),
1892
+ firstString(node.hint, node.placeholder)
1893
+ ].filter(Boolean).join(' ');
1894
+ return truncateText(text.replace(/\s+/g, ' ').trim(), MAX_CONTEXT_TEXT_LENGTH);
1895
+ }
1896
+
1897
+ function isClickableElement(node) {
1898
+ return booleanLike(node.clickable)
1899
+ || booleanLike(node.isClickable)
1900
+ || booleanLike(node.is_clickable)
1901
+ || booleanLike(node.longClickable)
1902
+ || booleanLike(node.long_clickable)
1903
+ || booleanLike(node.focusable)
1904
+ || booleanLike(node.actionable)
1905
+ || hasClickAction(node);
1906
+ }
1907
+
1908
+ function isAlreadyCompactElement(node) {
1909
+ return Object.prototype.hasOwnProperty.call(node, 'i')
1910
+ && (Object.prototype.hasOwnProperty.call(node, 'text') || Object.prototype.hasOwnProperty.call(node, 'desc'));
1911
+ }
1912
+
1913
+ function hasClickAction(node) {
1914
+ const actions = node.actions || node.actionList || node.action_list;
1915
+ if (Array.isArray(actions)) {
1916
+ return actions.some((action) => String(action && (action.name || action.id || action)).toLowerCase().includes('click'));
1917
+ }
1918
+ if (typeof actions === 'string') {
1919
+ return actions.toLowerCase().includes('click');
1920
+ }
1921
+ return false;
1922
+ }
1923
+
1924
+ function inferElementKind(node) {
1925
+ const explicit = firstString(node.kind, node.role, node.type);
1926
+ if (explicit) {
1927
+ return truncateText(explicit.toLowerCase(), 24);
1928
+ }
1929
+ const className = firstString(node.className, node.class_name, node.class, node.widget, node.nodeType, node.node_type) || '';
1930
+ const lower = className.toLowerCase();
1931
+ if (lower.includes('edittext') || lower.includes('textfield') || lower.includes('input')) return 'input';
1932
+ if (lower.includes('button')) return 'btn';
1933
+ if (lower.includes('image')) return 'img';
1934
+ if (lower.includes('video')) return 'video';
1935
+ if (lower.includes('text')) return 'text';
1936
+ if (booleanLike(node.longClickable) || booleanLike(node.long_clickable)) return 'long_press';
1937
+ return 'item';
1938
+ }
1939
+
1940
+ function findScreenBounds(source) {
1941
+ const direct = normalizeBounds(firstDefined(
1942
+ source.screen_bounds,
1943
+ source.screenBounds,
1944
+ source.displayBounds,
1945
+ source.display_bounds,
1946
+ source.windowBounds,
1947
+ source.window_bounds
1948
+ ));
1949
+ if (direct) {
1950
+ return direct;
1951
+ }
1952
+ const width = firstFiniteNumber(source.width, source.screenWidth, source.screen_width, source.displayWidth, source.display_width);
1953
+ const height = firstFiniteNumber(source.height, source.screenHeight, source.screen_height, source.displayHeight, source.display_height);
1954
+ if (width && height) {
1955
+ return [0, 0, Math.round(width), Math.round(height)];
1956
+ }
1957
+ const display = source.display || source.metrics || source.displayMetrics || source.display_metrics;
1958
+ if (display && typeof display === 'object') {
1959
+ const displayWidth = firstFiniteNumber(display.width, display.screenWidth, display.screen_width, display.displayWidth, display.display_width);
1960
+ const displayHeight = firstFiniteNumber(display.height, display.screenHeight, display.screen_height, display.displayHeight, display.display_height);
1961
+ if (displayWidth && displayHeight) {
1962
+ return [0, 0, Math.round(displayWidth), Math.round(displayHeight)];
1963
+ }
1964
+ }
1965
+ return null;
1966
+ }
1967
+
1968
+ function normalizeBounds(value) {
1969
+ if (!value) {
1970
+ return null;
1971
+ }
1972
+ if (Array.isArray(value) && value.length >= 4) {
1973
+ const numbers = value.slice(0, 4).map(Number);
1974
+ return numbers.every(Number.isFinite) ? numbers.map(Math.round) : null;
1975
+ }
1976
+ if (typeof value === 'string') {
1977
+ const numbers = value.match(/-?\d+(?:\.\d+)?/g);
1978
+ if (numbers && numbers.length >= 4) {
1979
+ return normalizeBounds(numbers.slice(0, 4));
1980
+ }
1981
+ return null;
1982
+ }
1983
+ if (typeof value !== 'object') {
1984
+ return null;
1985
+ }
1986
+ const left = firstFiniteNumber(value.left, value.x, value.l);
1987
+ const top = firstFiniteNumber(value.top, value.y, value.t);
1988
+ let right = firstFiniteNumber(value.right, value.r);
1989
+ let bottom = firstFiniteNumber(value.bottom, value.b);
1990
+ const width = firstFiniteNumber(value.width, value.w);
1991
+ const height = firstFiniteNumber(value.height, value.h);
1992
+ if (right === undefined && left !== undefined && width !== undefined) {
1993
+ right = left + width;
1994
+ }
1995
+ if (bottom === undefined && top !== undefined && height !== undefined) {
1996
+ bottom = top + height;
1997
+ }
1998
+ if ([left, top, right, bottom].every(Number.isFinite)) {
1999
+ return [left, top, right, bottom].map(Math.round);
2000
+ }
2001
+ return null;
2002
+ }
2003
+
2004
+ function normalizeElementLimit(value, fallback) {
2005
+ if (value === undefined || value === null || value === '') {
2006
+ return fallback;
2007
+ }
2008
+ const number = Number(value);
2009
+ if (!Number.isInteger(number) || number <= 0) {
2010
+ throw new Error(`invalid maxElements: ${value}`);
2011
+ }
2012
+ return number;
2013
+ }
2014
+
2015
+ function firstDefined(...values) {
2016
+ return values.find((value) => value !== undefined && value !== null);
2017
+ }
2018
+
2019
+ function firstString(...values) {
2020
+ for (const value of values) {
2021
+ if (typeof value === 'string' && value.trim()) {
2022
+ return value.trim();
2023
+ }
2024
+ if (typeof value === 'number' && Number.isFinite(value)) {
2025
+ return String(value);
2026
+ }
2027
+ }
2028
+ return '';
2029
+ }
2030
+
2031
+ function firstFiniteNumber(...values) {
2032
+ for (const value of values) {
2033
+ const number = Number(value);
2034
+ if (Number.isFinite(number)) {
2035
+ return number;
2036
+ }
2037
+ }
2038
+ return undefined;
2039
+ }
2040
+
2041
+ function booleanLike(value) {
2042
+ return value === true || value === 'true' || value === 1 || value === '1';
2043
+ }
2044
+
2045
+ function truncateText(value, maxLength) {
2046
+ if (!value || value.length <= maxLength) {
2047
+ return value || '';
2048
+ }
2049
+ return value.slice(0, Math.max(0, maxLength - 1)) + '…';
2050
+ }
2051
+
2052
+ function isScalarContextKey(key) {
2053
+ return [
2054
+ 'text',
2055
+ 'label',
2056
+ 'name',
2057
+ 'title',
2058
+ 'contentDescription',
2059
+ 'content_description',
2060
+ 'desc',
2061
+ 'description',
2062
+ 'bounds',
2063
+ 'bound',
2064
+ 'rect',
2065
+ 'frame',
2066
+ 'visibleBounds',
2067
+ 'visible_bounds',
2068
+ 'screenBounds',
2069
+ 'screen_bounds',
2070
+ 'displayBounds',
2071
+ 'display_bounds',
2072
+ 'windowBounds',
2073
+ 'window_bounds',
2074
+ 'display',
2075
+ 'metrics',
2076
+ 'displayMetrics',
2077
+ 'display_metrics'
2078
+ ].includes(key);
2079
+ }
2080
+
2081
+ function taskMemoryOperation(args = {}) {
2082
+ const op = requireString(args.op, 'op').toLowerCase();
2083
+ const memoryPath = cleanString(args.path) || DEFAULT_TASK_MEMORY_PATH;
2084
+ const store = readTaskMemoryStore(memoryPath);
2085
+
2086
+ if (op === 'list') {
2087
+ const tasks = Object.keys(store).sort((a, b) => {
2088
+ const left = parseDateMs(store[a] && store[a].last_updated);
2089
+ const right = parseDateMs(store[b] && store[b].last_updated);
2090
+ return right - left;
2091
+ }).map((key) => ({
2092
+ key,
2093
+ summary: String(store[key] && store[key].summary || ''),
2094
+ progress: String(store[key] && store[key].progress || ''),
2095
+ last_updated: String(store[key] && store[key].last_updated || '')
2096
+ }));
2097
+ return {
2098
+ ok: true,
2099
+ path: memoryPath,
2100
+ tasks
2101
+ };
2102
+ }
2103
+
2104
+ if (op === 'get') {
2105
+ const key = requireMemoryKey(args.key);
2106
+ const entry = store[key];
2107
+ if (!entry) {
2108
+ return {
2109
+ ok: false,
2110
+ path: memoryPath,
2111
+ key,
2112
+ error: 'task memory key not found'
2113
+ };
2114
+ }
2115
+ return {
2116
+ ok: true,
2117
+ path: memoryPath,
2118
+ key,
2119
+ ...entry
2120
+ };
2121
+ }
2122
+
2123
+ if (op === 'put') {
2124
+ const key = requireMemoryKey(args.key);
2125
+ const summary = requireString(args.summary, 'summary');
2126
+ const progress = requireString(args.progress, 'progress');
2127
+ if (typeof args.value !== 'string' || args.value.length === 0) {
2128
+ throw new Error('value is required and must be a non-empty string for put');
2129
+ }
2130
+ store[key] = {
2131
+ summary,
2132
+ progress,
2133
+ value: args.value,
2134
+ last_updated: new Date().toISOString()
2135
+ };
2136
+ writeTaskMemoryStore(memoryPath, store);
2137
+ return {
2138
+ ok: true,
2139
+ path: memoryPath,
2140
+ key,
2141
+ summary,
2142
+ progress,
2143
+ last_updated: store[key].last_updated
2144
+ };
2145
+ }
2146
+
2147
+ if (op === 'delete') {
2148
+ const key = requireMemoryKey(args.key);
2149
+ const existed = Object.prototype.hasOwnProperty.call(store, key);
2150
+ if (existed) {
2151
+ delete store[key];
2152
+ writeTaskMemoryStore(memoryPath, store);
2153
+ }
2154
+ return {
2155
+ ok: true,
2156
+ path: memoryPath,
2157
+ key,
2158
+ deleted: existed
2159
+ };
2160
+ }
2161
+
2162
+ throw new Error(`unsupported task memory op: ${op}`);
2163
+ }
2164
+
2165
+ function checkTaskMemoryFile(memoryPath = DEFAULT_TASK_MEMORY_PATH) {
2166
+ const targetPath = cleanString(memoryPath) || DEFAULT_TASK_MEMORY_PATH;
2167
+ const existed = fs.existsSync(targetPath);
2168
+ let raw = existed ? fs.readFileSync(targetPath, 'utf8') : '';
2169
+ let data = {};
2170
+ if (raw.trim()) {
2171
+ data = JSON.parse(raw);
2172
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
2173
+ throw new Error('task memory file must contain a JSON object');
2174
+ }
2175
+ }
2176
+ if (!existed) {
2177
+ raw = `${JSON.stringify(data, null, 2)}\n`;
2178
+ }
2179
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
2180
+ fs.writeFileSync(targetPath, raw || '{}\n', { mode: 0o600 });
2181
+ readTaskMemoryStore(targetPath);
2182
+ return {
2183
+ ok: true,
2184
+ path: targetPath,
2185
+ readable: true,
2186
+ writable: true
2187
+ };
2188
+ }
2189
+
2190
+ function readTaskMemoryStore(memoryPath) {
2191
+ if (!fs.existsSync(memoryPath)) {
2192
+ return {};
2193
+ }
2194
+ const raw = fs.readFileSync(memoryPath, 'utf8');
2195
+ if (!raw.trim()) {
2196
+ return {};
2197
+ }
2198
+ const parsed = JSON.parse(raw);
2199
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
2200
+ throw new Error('task memory file must contain a JSON object');
2201
+ }
2202
+ return parsed;
2203
+ }
2204
+
2205
+ function writeTaskMemoryStore(memoryPath, store) {
2206
+ fs.mkdirSync(path.dirname(memoryPath), { recursive: true });
2207
+ const tempPath = `${memoryPath}.${process.pid}.${Date.now()}.tmp`;
2208
+ fs.writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
2209
+ fs.renameSync(tempPath, memoryPath);
2210
+ }
2211
+
2212
+ function requireMemoryKey(value) {
2213
+ const key = requireString(value, 'key');
2214
+ if (!/^[A-Za-z0-9._:-]+$/.test(key)) {
2215
+ throw new Error('key may only contain letters, numbers, dot, underscore, colon, or hyphen');
2216
+ }
2217
+ return key;
2218
+ }
2219
+
2220
+ function normalizeBody(body) {
2221
+ if (!body || typeof body !== 'object') {
2222
+ return body;
2223
+ }
2224
+
2225
+ const keys = Object.keys(body);
2226
+ if (keys.length === 1) {
2227
+ const key = keys[0];
2228
+ return {
2229
+ kind: key.toLowerCase(),
2230
+ ...body[key]
2231
+ };
2232
+ }
2233
+ return body;
2234
+ }
2235
+
2236
+ function responseOk(body) {
2237
+ if (body && body.kind === 'response') {
2238
+ return Boolean(body.ok);
2239
+ }
2240
+ return body && body.kind === 'error' ? false : undefined;
2241
+ }
2242
+
2243
+ function responseText(body) {
2244
+ if (!body || typeof body !== 'object') {
2245
+ return undefined;
2246
+ }
2247
+ return body.text || body.message;
2248
+ }
2249
+
2250
+ function responseMedia(body) {
2251
+ if (!body || typeof body !== 'object') {
2252
+ return undefined;
2253
+ }
2254
+ return body.media;
2255
+ }
2256
+
2257
+ function writeJsonRpc(output, payload) {
2258
+ const data = Buffer.from(JSON.stringify(payload), 'utf8');
2259
+ output.write(`Content-Length: ${data.length}\r\n\r\n`);
2260
+ output.write(data);
2261
+ }
2262
+
2263
+ function parseMessages(buffer) {
2264
+ const messages = [];
2265
+ let offset = 0;
2266
+
2267
+ while (offset < buffer.length) {
2268
+ const headerEnd = buffer.indexOf('\r\n\r\n', offset);
2269
+ if (headerEnd === -1) {
2270
+ break;
2271
+ }
2272
+
2273
+ const header = buffer.slice(offset, headerEnd).toString('utf8');
2274
+ const match = /^content-length:\s*(\d+)$/im.exec(header);
2275
+ if (!match) {
2276
+ throw new Error('invalid MCP message header: missing Content-Length');
2277
+ }
2278
+
2279
+ const length = Number(match[1]);
2280
+ const bodyStart = headerEnd + 4;
2281
+ const bodyEnd = bodyStart + length;
2282
+ if (buffer.length < bodyEnd) {
2283
+ break;
2284
+ }
2285
+
2286
+ const raw = buffer.slice(bodyStart, bodyEnd).toString('utf8');
2287
+ messages.push(JSON.parse(raw));
2288
+ offset = bodyEnd;
2289
+ }
2290
+
2291
+ return {
2292
+ messages,
2293
+ rest: buffer.slice(offset)
2294
+ };
2295
+ }
2296
+
2297
+ function response(id, result) {
2298
+ return {
2299
+ jsonrpc: '2.0',
2300
+ id,
2301
+ result
2302
+ };
2303
+ }
2304
+
2305
+ function errorResponse(id, code, message, traceId) {
2306
+ return {
2307
+ jsonrpc: '2.0',
2308
+ id,
2309
+ error: {
2310
+ code,
2311
+ message,
2312
+ ...(traceId ? { data: { trace_id: traceId } } : {})
2313
+ }
2314
+ };
2315
+ }
2316
+
2317
+ function formatToolResult(result) {
2318
+ if (typeof result === 'string') {
2319
+ return result;
2320
+ }
2321
+
2322
+ return JSON.stringify(result, null, 2);
2323
+ }
2324
+
2325
+ function cleanString(value) {
2326
+ if (typeof value !== 'string') {
2327
+ return undefined;
2328
+ }
2329
+ const trimmed = value.trim();
2330
+ return trimmed || undefined;
2331
+ }
2332
+
2333
+ function resolveTraceId(options = {}, fallback) {
2334
+ return cleanString(options.traceId || options.trace_id)
2335
+ || cleanString(fallback)
2336
+ || currentTraceId()
2337
+ || createTraceId();
2338
+ }
2339
+
2340
+ function currentTraceId() {
2341
+ return cleanString(traceStorage.getStore());
2342
+ }
2343
+
2344
+ function runWithTrace(traceId, fn) {
2345
+ return traceStorage.run(traceId, fn);
2346
+ }
2347
+
2348
+ function createTraceId() {
2349
+ return crypto.randomBytes(16).toString('hex');
2350
+ }
2351
+
2352
+ function normalizePort(value, fallback) {
2353
+ if (value === undefined || value === null || value === '') {
2354
+ return fallback;
2355
+ }
2356
+ const port = Number(value);
2357
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
2358
+ throw new Error(`invalid port: ${value}`);
2359
+ }
2360
+ return port;
2361
+ }
2362
+
2363
+ function requireFiniteNumber(value, name) {
2364
+ const number = Number(value);
2365
+ if (!Number.isFinite(number)) {
2366
+ throw new Error(`${name} must be a finite number`);
2367
+ }
2368
+ return number;
2369
+ }
2370
+
2371
+ function requireString(value, name) {
2372
+ const text = cleanString(value);
2373
+ if (!text) {
2374
+ throw new Error(`${name} is required`);
2375
+ }
2376
+ return text;
2377
+ }
2378
+
2379
+ function normalizeOptionalPositiveInt(value) {
2380
+ if (value === undefined || value === null || value === '') {
2381
+ return undefined;
2382
+ }
2383
+ const number = Number(value);
2384
+ if (!Number.isInteger(number) || number <= 0) {
2385
+ throw new Error(`expected positive integer, got ${value}`);
2386
+ }
2387
+ return number;
2388
+ }
2389
+
2390
+ function inputLogFields(message) {
2391
+ const clientSentAtMs = Number(message && message.clientSentAtMs);
2392
+ return {
2393
+ action: message && message.action,
2394
+ clientToServerMs: Number.isFinite(clientSentAtMs) ? Date.now() - clientSentAtMs : undefined
2395
+ };
2396
+ }
2397
+
2398
+ function encodeH264Packet(packet) {
2399
+ const data = Buffer.from(packet.data || []);
2400
+ const header = Buffer.alloc(33);
2401
+ header.writeUInt8(packet.kind, 0);
2402
+ header.writeUInt32LE(packet.flags || 0, 1);
2403
+ header.writeUInt32LE(packet.width || 0, 5);
2404
+ header.writeUInt32LE(packet.height || 0, 9);
2405
+ header.writeBigInt64LE(BigInt(Math.trunc(packet.presentationTimeUs || 0)), 13);
2406
+ header.writeBigUInt64LE(BigInt(Math.trunc(packet.capturedAtMs || 0)), 21);
2407
+ header.writeUInt32LE(data.length, 29);
2408
+ return Buffer.concat([header, data]);
2409
+ }
2410
+
2411
+ function sendWebSocketFrame(socket, data, opcode) {
2412
+ if (socket.destroyed || !socket.writable) {
2413
+ return;
2414
+ }
2415
+ const length = data.length;
2416
+ let header;
2417
+ if (length < 126) {
2418
+ header = Buffer.alloc(2);
2419
+ header[1] = length;
2420
+ } else if (length <= 0xffff) {
2421
+ header = Buffer.alloc(4);
2422
+ header[1] = 126;
2423
+ header.writeUInt16BE(length, 2);
2424
+ } else {
2425
+ header = Buffer.alloc(10);
2426
+ header[1] = 127;
2427
+ header.writeBigUInt64BE(BigInt(length), 2);
2428
+ }
2429
+ header[0] = 0x80 | opcode;
2430
+ socket.write(Buffer.concat([header, data]));
2431
+ }
2432
+
2433
+ function parseClientWebSocketFrame(buffer) {
2434
+ if (buffer.length < 2) {
2435
+ return null;
2436
+ }
2437
+ const first = buffer[0];
2438
+ const second = buffer[1];
2439
+ const opcode = first & 0x0f;
2440
+ const masked = (second & 0x80) === 0x80;
2441
+ let length = second & 0x7f;
2442
+ let offset = 2;
2443
+ if (length === 126) {
2444
+ if (buffer.length < offset + 2) return null;
2445
+ length = buffer.readUInt16BE(offset);
2446
+ offset += 2;
2447
+ } else if (length === 127) {
2448
+ if (buffer.length < offset + 8) return null;
2449
+ const bigLength = buffer.readBigUInt64BE(offset);
2450
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
2451
+ throw new Error('websocket frame too large');
2452
+ }
2453
+ length = Number(bigLength);
2454
+ offset += 8;
2455
+ }
2456
+ if (!masked) {
2457
+ throw new Error('browser websocket frame was not masked');
2458
+ }
2459
+ if (buffer.length < offset + 4 + length) {
2460
+ return null;
2461
+ }
2462
+ const mask = buffer.slice(offset, offset + 4);
2463
+ offset += 4;
2464
+ const payload = Buffer.from(buffer.slice(offset, offset + length));
2465
+ for (let index = 0; index < payload.length; index += 1) {
2466
+ payload[index] ^= mask[index % 4];
2467
+ }
2468
+ return {
2469
+ opcode,
2470
+ payload,
2471
+ consumed: offset + length
2472
+ };
2473
+ }
2474
+
2475
+ function loadLiveViewHtml() {
2476
+ return fs.readFileSync(LIVE_VIEW_HTML_PATH, 'utf8');
2477
+ }
2478
+
2479
+ function summarizeTicket(ticket) {
2480
+ const value = cleanString(ticket);
2481
+ if (!value) {
2482
+ return '<missing>';
2483
+ }
2484
+ if (value.length <= 16) {
2485
+ return `<${value.length} chars>`;
2486
+ }
2487
+ return `${value.slice(0, 8)}...${value.slice(-8)} (${value.length} chars)`;
2488
+ }
2489
+
2490
+ function writeDevLog(enabled, message, fields) {
2491
+ if (!enabled) {
2492
+ return;
2493
+ }
2494
+ const timestamp = new Date().toISOString();
2495
+ const traceId = fields && (fields.trace_id || fields.traceId) ? (fields.trace_id || fields.traceId) : currentTraceId();
2496
+ const fieldCopy = fields ? { ...fields } : undefined;
2497
+ if (fieldCopy && fieldCopy.traceId) {
2498
+ delete fieldCopy.traceId;
2499
+ }
2500
+ const normalizedFields = traceId
2501
+ ? { trace_id: traceId, ...(fieldCopy || {}) }
2502
+ : fieldCopy;
2503
+ const details = normalizedFields ? ` ${JSON.stringify(normalizedFields)}` : '';
2504
+ process.stderr.write(`[kln:mcp:dev] ${timestamp} ${message}${details}\n`);
2505
+ }
2506
+
2507
+ function optionalText(value) {
2508
+ const text = cleanString(value);
2509
+ return text === undefined ? null : text;
2510
+ }
2511
+
2512
+ function requireTicket(ticket) {
2513
+ const normalized = cleanString(ticket);
2514
+ if (!normalized) {
2515
+ throw new Error('ticket is required. Pass --ticket or set KLN_TICKET.');
2516
+ }
2517
+ return normalized;
2518
+ }
2519
+
2520
+ function normalizeRelayUrls(value) {
2521
+ if (Array.isArray(value)) {
2522
+ return value.filter(Boolean).map(String);
2523
+ }
2524
+ if (typeof value === 'string' && value.trim()) {
2525
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
2526
+ }
2527
+ return [];
2528
+ }
2529
+
2530
+ function normalizeTimeout(value, fallback) {
2531
+ if (value === undefined || value === null || value === '') {
2532
+ return fallback;
2533
+ }
2534
+ const timeout = Number(value);
2535
+ if (!Number.isFinite(timeout) || timeout < 0) {
2536
+ throw new Error(`invalid timeoutMs: ${value}`);
2537
+ }
2538
+ return Math.trunc(timeout);
2539
+ }
2540
+
2541
+ module.exports = {
2542
+ KlnMobileController,
2543
+ KlnMcpServer,
2544
+ H264LiveViewServer,
2545
+ TOOL_DEFINITIONS,
2546
+ createController,
2547
+ createMcpServer,
2548
+ normalizeMessage,
2549
+ taskMemoryOperation,
2550
+ checkTaskMemoryFile,
2551
+ parseMessages,
2552
+ writeJsonRpc
2553
+ };