@sstar/boardlinker_host 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1514 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { createAgentClient } from './grpc.js';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { getHostLogger, logHostDebug, LogLevel } from './logger.js';
10
+ const DEBUG_ENABLED = process.env.BOARDLINKER_HOST_DEBUG === '1' || process.env.BOARDLINKER_DEBUG === '1';
11
+ const HOST_INSTANCE_ID = `${os.hostname()}/${process.pid}`;
12
+ function logDebug(...args) {
13
+ if (!DEBUG_ENABLED)
14
+ return;
15
+ // eslint-disable-next-line no-console
16
+ console.error('[boardlinker-host]', ...args);
17
+ // 同时写入文件日志
18
+ logHostDebug('debug', { args });
19
+ }
20
+ // 辅助函数:格式化字节大小
21
+ function formatBytes(bytes) {
22
+ if (bytes === 0)
23
+ return '0 Bytes';
24
+ const k = 1024;
25
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
26
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
27
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
28
+ }
29
+ function getLocalAgentConfigPath() {
30
+ const home = os.homedir();
31
+ return path.join(home, '.config', 'board_linker', 'agent.json');
32
+ }
33
+ function loadAgentDiscovery() {
34
+ const p = getLocalAgentConfigPath();
35
+ if (!fs.existsSync(p)) {
36
+ throw new Error(`BoardLinker agent.json not found at ${p} (please start boardlinker-agent on this machine first)`);
37
+ }
38
+ const raw = fs.readFileSync(p, 'utf8');
39
+ let j;
40
+ try {
41
+ j = JSON.parse(raw);
42
+ }
43
+ catch (e) {
44
+ throw new Error(`Failed to parse agent.json at ${p}: ${e?.message || String(e)}`);
45
+ }
46
+ const host = j.endpoint?.grpc?.host || '127.0.0.1';
47
+ const port = j.endpoint?.grpc?.port || j.agent?.hostGrpcPort;
48
+ const pairCode = j.agent?.pairCode;
49
+ if (!port || !Number.isFinite(port)) {
50
+ throw new Error(`Invalid agent grpc port in agent.json at ${p}`);
51
+ }
52
+ if (!pairCode) {
53
+ throw new Error(`Missing pairCode in agent.json at ${p}`);
54
+ }
55
+ const tftpInfo = j.services?.tftp;
56
+ const tftpEnabled = tftpInfo ? tftpInfo.enabled !== false && tftpInfo.mode !== 'disabled' : true;
57
+ const explicit = process.env.BOARDLINKER_AGENT_ADDR;
58
+ const addr = (explicit && explicit.trim()) || `${host}:${port}`;
59
+ logDebug('loadAgentDiscovery', {
60
+ path: p,
61
+ addr,
62
+ agentId: j.agent?.id,
63
+ pairCode: pairCode.slice(0, 8) + '...',
64
+ });
65
+ return { addr, pairCode, id: j.agent?.id, tftpEnabled, tftpMode: tftpInfo?.mode };
66
+ }
67
+ function isNetworkError(err) {
68
+ const code = (err && err.code) || '';
69
+ const msg = (err && err.message) || '';
70
+ const s = String(msg || '').toLowerCase();
71
+ if (code === 'UNAVAILABLE' || code === 14)
72
+ return true;
73
+ if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT')
74
+ return true;
75
+ if (s.includes('connect') || s.includes('unavailable') || s.includes('deadline exceeded')) {
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ let agent;
81
+ let agentAddr;
82
+ let agentPairCode;
83
+ let defaultBoardUartSessionId = null;
84
+ let tftpEnabled = true;
85
+ let hostLogCall = null;
86
+ // 错误类型枚举
87
+ var ConnectionErrorType;
88
+ (function (ConnectionErrorType) {
89
+ ConnectionErrorType["UNAVAILABLE"] = "UNAVAILABLE";
90
+ ConnectionErrorType["ECONNREFUSED"] = "ECONNREFUSED";
91
+ ConnectionErrorType["ECONNRESET"] = "ECONNRESET";
92
+ ConnectionErrorType["ETIMEDOUT"] = "ETIMEDOUT";
93
+ ConnectionErrorType["DEADLINE_EXCEEDED"] = "DEADLINE_EXCEEDED";
94
+ })(ConnectionErrorType || (ConnectionErrorType = {}));
95
+ // 友好的提示信息映射
96
+ const getFriendlyErrorMessage = (errorType, toolName, originalError) => {
97
+ // 如果Agent端提供了错误信息,直接透传
98
+ const agentMessage = originalError?.message || originalError?.details || '';
99
+ if (agentMessage) {
100
+ return agentMessage;
101
+ }
102
+ // 只有在完全没有Agent错误信息时,才使用通用的连接错误提示
103
+ switch (errorType) {
104
+ case ConnectionErrorType.UNAVAILABLE:
105
+ return `🔌 Agent连接不可用,需要检查agent侧状态`;
106
+ case ConnectionErrorType.ECONNREFUSED:
107
+ return `❌ 连接被拒绝,boardlinker-agent可能未启动或端口配置错误`;
108
+ case ConnectionErrorType.ETIMEDOUT:
109
+ return `⏰ 连接超时,网络可能不稳定或Agent响应过慢`;
110
+ case ConnectionErrorType.DEADLINE_EXCEEDED:
111
+ return `⏰ 请求超时,Agent响应时间过长`;
112
+ default:
113
+ return `🔧 网络连接问题 (${errorType}),请检查Agent服务状态`;
114
+ }
115
+ };
116
+ // 错误分类函数
117
+ function classifyError(err, toolName) {
118
+ const code = err?.code;
119
+ const message = err?.message?.toLowerCase() || '';
120
+ const details = err?.details?.toLowerCase() || '';
121
+ // gRPC错误码14对应UNAVAILABLE
122
+ if (code === 14 || code === 'UNAVAILABLE')
123
+ return ConnectionErrorType.UNAVAILABLE;
124
+ if (code === 'ECONNREFUSED')
125
+ return ConnectionErrorType.ECONNREFUSED;
126
+ if (code === 'ECONNRESET')
127
+ return ConnectionErrorType.ECONNRESET;
128
+ if (code === 'ETIMEDOUT')
129
+ return ConnectionErrorType.ETIMEDOUT;
130
+ if (message.includes('deadline exceeded') || details.includes('deadline exceeded'))
131
+ return ConnectionErrorType.DEADLINE_EXCEEDED;
132
+ return ConnectionErrorType.UNAVAILABLE; // 默认类型
133
+ }
134
+ // 创建友好的错误对象
135
+ function createFriendlyError(originalError, errorType, toolName) {
136
+ const friendlyMessage = getFriendlyErrorMessage(errorType, toolName, originalError);
137
+ // 创建简洁的友好错误
138
+ const friendlyError = new Error(friendlyMessage);
139
+ // 附加原始错误信息用于调试,但不显示给用户
140
+ friendlyError.code = originalError?.code;
141
+ friendlyError.type = errorType;
142
+ friendlyError.originalError = originalError;
143
+ friendlyError.isFriendlyError = true;
144
+ friendlyError.debugInfo =
145
+ originalError?.message || originalError?.details || String(originalError);
146
+ return friendlyError;
147
+ }
148
+ // Control Signal Parser Class
149
+ class ControlSignalParser {
150
+ // 解析控制信号字符串
151
+ static parseSignal(input) {
152
+ const trimmed = input.trim();
153
+ // 检查是否为长按格式(包含冒号)
154
+ const colonIndex = trimmed.indexOf(':');
155
+ if (colonIndex > 0) {
156
+ const signalName = trimmed.substring(0, colonIndex);
157
+ const durationStr = trimmed.substring(colonIndex + 1);
158
+ const duration = this.parseDuration(durationStr);
159
+ if (isNaN(duration) || duration <= 0) {
160
+ throw new Error(`无效的持续时间: ${durationStr}`);
161
+ }
162
+ return {
163
+ type: 'hold',
164
+ signal: this.normalizeSignalName(signalName),
165
+ duration,
166
+ };
167
+ }
168
+ // 单次信号
169
+ return {
170
+ type: 'single',
171
+ signal: this.normalizeSignalName(trimmed),
172
+ };
173
+ }
174
+ // 将信号名称转换为大写进行匹配
175
+ static normalizeSignalName(name) {
176
+ // 处理Ctrl组合键的特殊格式
177
+ if (name.toLowerCase().startsWith('ctrl+')) {
178
+ const key = name.substring(5); // 移除 'ctrl+'
179
+ return 'CTRL+' + key.toUpperCase();
180
+ }
181
+ return name.toUpperCase();
182
+ }
183
+ // 解析持续时间字符串
184
+ static parseDuration(durationStr) {
185
+ const trimmed = durationStr.trim().toLowerCase();
186
+ // 检查单位后缀
187
+ if (trimmed.endsWith('s')) {
188
+ const seconds = parseFloat(trimmed.slice(0, -1));
189
+ return Math.round(seconds * 1000);
190
+ }
191
+ if (trimmed.endsWith('m')) {
192
+ const minutes = parseFloat(trimmed.slice(0, -1));
193
+ return Math.round(minutes * 60 * 1000);
194
+ }
195
+ // 默认为毫秒
196
+ const milliseconds = parseFloat(trimmed);
197
+ return Math.round(milliseconds);
198
+ }
199
+ // 将信号转换为字节数据
200
+ static signalToBytes(signal) {
201
+ const signalCode = this.SIGNAL_MAP[signal.signal];
202
+ if (signalCode === undefined) {
203
+ throw new Error(`不支持的控制信号: ${signal.signal}`);
204
+ }
205
+ return [Buffer.from([signalCode])];
206
+ }
207
+ // 验证信号名称是否有效
208
+ static isValidSignal(signalName) {
209
+ const normalized = this.normalizeSignalName(signalName);
210
+ return this.SIGNAL_MAP.hasOwnProperty(normalized);
211
+ }
212
+ }
213
+ // 标准控制信号映射(统一使用大写键名)
214
+ ControlSignalParser.SIGNAL_MAP = {
215
+ 'CTRL+C': 0x03,
216
+ 'CTRL+Z': 0x1a,
217
+ 'CTRL+D': 0x04,
218
+ HOME: 0x01,
219
+ END: 0x05,
220
+ UP: 0x1b5b41,
221
+ DOWN: 0x1b5b42,
222
+ LEFT: 0x1b5b44,
223
+ RIGHT: 0x1b5b43,
224
+ ESC: 0x1b,
225
+ TAB: 0x09,
226
+ BACKSPACE: 0x08,
227
+ DELETE: 0x7f,
228
+ ENTER: 0x0d,
229
+ SPACE: 0x20,
230
+ };
231
+ const PROCEDURES = [
232
+ // 板卡 UART 多会话与读写
233
+ {
234
+ name: 'board_uart-sessions',
235
+ description: 'List BoardUart (board-side UART) sessions managed by the agent.',
236
+ params: {},
237
+ },
238
+ {
239
+ name: 'board_uart-run_command',
240
+ description: 'Write command to BoardUart. ',
241
+ params: {
242
+ command: {
243
+ type: 'string',
244
+ description: 'Command string (auto-appends newline). Empty string means press ENTER.',
245
+ },
246
+ session_id: {
247
+ type: 'string',
248
+ optional: true,
249
+ description: 'Target session id; use default if omitted.',
250
+ },
251
+ wait_for: {
252
+ type: 'string',
253
+ optional: true,
254
+ description: 'String pattern to wait for (e.g. "#", ">"). Agent blocks until found.',
255
+ },
256
+ timeout_ms: {
257
+ type: 'number',
258
+ optional: true,
259
+ description: 'Max time to wait for pattern (default 5000ms).',
260
+ },
261
+ },
262
+ },
263
+ {
264
+ name: 'board_uart-send_signal',
265
+ description: 'Send control signals to a BoardUart session.',
266
+ params: {
267
+ signal: {
268
+ type: 'string',
269
+ description: 'Control signal to send, e.g., ENTER, Ctrl+C, ENTER:5000 for 5-second hold.',
270
+ },
271
+ session_id: {
272
+ type: 'string',
273
+ optional: true,
274
+ description: 'Target session id; use default if omitted.',
275
+ },
276
+ repeat: {
277
+ type: 'number',
278
+ optional: true,
279
+ description: 'Number of times to repeat the signal, default is 1.',
280
+ },
281
+ },
282
+ },
283
+ {
284
+ name: 'board_uart-read',
285
+ description: 'Read from a BoardUart session with quiet and timeout controls.',
286
+ params: {
287
+ session_id: {
288
+ type: 'string',
289
+ optional: true,
290
+ description: 'Target session id; use default if omitted.',
291
+ },
292
+ max_bytes: {
293
+ type: 'number',
294
+ optional: true,
295
+ description: 'Maximum bytes to read in this call.',
296
+ },
297
+ quiet_ms: {
298
+ type: 'number',
299
+ optional: true,
300
+ description: 'Stop when no new data arrives for this many milliseconds.',
301
+ },
302
+ timeout_ms: {
303
+ type: 'number',
304
+ optional: true,
305
+ description: 'Overall timeout in milliseconds.',
306
+ },
307
+ },
308
+ },
309
+ {
310
+ name: 'board_uart-status',
311
+ description: 'Inspect BoardUart hardware status and attached tools.You should use `docs-read(board-interaction)` to get the UART SOP.',
312
+ params: {},
313
+ },
314
+ {
315
+ name: 'files-put',
316
+ description: 'Upload file or directory to Agent (Host → Agent). `src` must be an absolute path; `dst` must be a path relative to the Agent files root.',
317
+ params: {
318
+ src: {
319
+ type: 'string',
320
+ description: 'Source file/directory path (must be absolute)',
321
+ },
322
+ dst: {
323
+ type: 'string',
324
+ description: 'Destination path (relative to Agent files root; default uses source name)',
325
+ optional: true,
326
+ },
327
+ chunk_size: {
328
+ type: 'number',
329
+ description: 'Chunk size (bytes, optional, default 65536)',
330
+ optional: true,
331
+ },
332
+ overwrite: {
333
+ type: 'number',
334
+ description: 'Whether to overwrite existing files: 1 for overwrite, 0 for not overwrite (default: overwrite)',
335
+ optional: true,
336
+ },
337
+ },
338
+ },
339
+ {
340
+ name: 'files-get',
341
+ description: 'Download file or directory from Agent (Agent → Host). `src` must be relative to the Agent files root; `dst` must be an absolute path on the host.',
342
+ params: {
343
+ src: {
344
+ type: 'string',
345
+ description: 'Agent-side file/directory path (relative to Agent files root)',
346
+ },
347
+ dst: {
348
+ type: 'string',
349
+ description: 'Absolute local save path',
350
+ optional: true,
351
+ },
352
+ chunk_size: {
353
+ type: 'number',
354
+ description: 'Chunk size (bytes, optional, default 65536)',
355
+ optional: true,
356
+ },
357
+ skip_hidden: {
358
+ type: 'number',
359
+ description: 'Whether to skip hidden files, 1 to skip, 0 not to skip (default: not skip)',
360
+ optional: true,
361
+ },
362
+ },
363
+ },
364
+ {
365
+ name: 'files-list',
366
+ description: 'List files/directories on Agent files root. `path` must be relative to Agent files root (empty for root).',
367
+ params: {
368
+ path: {
369
+ type: 'string',
370
+ description: 'Relative path on Agent (optional, defaults to root)',
371
+ optional: true,
372
+ },
373
+ recursive: {
374
+ type: 'number',
375
+ description: '1 to list recursively, 0 for non-recursive (default 0)',
376
+ optional: true,
377
+ },
378
+ all: {
379
+ type: 'number',
380
+ description: '1 to include hidden files, 0 to skip (default include)',
381
+ optional: true,
382
+ },
383
+ pattern: {
384
+ type: 'string',
385
+ description: 'Optional glob-like pattern filter',
386
+ optional: true,
387
+ },
388
+ },
389
+ },
390
+ {
391
+ name: 'files-stat',
392
+ description: 'Get stat of a path on Agent files root. `path` must be relative to Agent files root.',
393
+ params: {
394
+ path: {
395
+ type: 'string',
396
+ description: 'Relative path on Agent',
397
+ },
398
+ },
399
+ },
400
+ {
401
+ name: 'files-mkdir',
402
+ description: 'Create directory on Agent files root. `path` must be relative to Agent files root.',
403
+ params: {
404
+ path: {
405
+ type: 'string',
406
+ description: 'Relative directory path to create',
407
+ },
408
+ parents: {
409
+ type: 'number',
410
+ description: '1 to create parents recursively (default 0)',
411
+ optional: true,
412
+ },
413
+ },
414
+ },
415
+ {
416
+ name: 'files-rm',
417
+ description: 'Remove file or directory on Agent files root. `path` must be relative; recursive deletion only if explicitly set.',
418
+ params: {
419
+ path: {
420
+ type: 'string',
421
+ description: 'Relative path to remove',
422
+ },
423
+ recursive: {
424
+ type: 'number',
425
+ description: '1 to remove recursively (use with care), 0 to remove non-recursively',
426
+ optional: true,
427
+ },
428
+ },
429
+ },
430
+ {
431
+ name: 'firmware-prepare_images',
432
+ description: 'Prepare firmware images on the agent by uploading local images directory to the TFTP images_agent subdirectory and returning metadata.',
433
+ params: {
434
+ localImagesDir: {
435
+ type: 'string',
436
+ description: 'Local directory on the host that contains firmware images/bins and partition_layout configure file.',
437
+ },
438
+ },
439
+ },
440
+ {
441
+ name: 'firmware-burn_recover',
442
+ description: 'Invoke the agent FlashTool-based firmware recovery using explicit segments derived from images metadata.',
443
+ params: {
444
+ imagesRoot: {
445
+ type: 'string',
446
+ description: 'Relative images root under the agent TFTP directory, e.g. "images_agent"',
447
+ },
448
+ args: {
449
+ type: 'string',
450
+ description: 'JSON-encoded array of FlashTool CLI arguments. MUST use dosc-read to get the userguide',
451
+ },
452
+ timeout_ms: {
453
+ type: 'number',
454
+ optional: true,
455
+ description: 'Optional timeout in milliseconds for the ISP tool; if omitted the agent will apply its own default.',
456
+ },
457
+ force: {
458
+ type: 'string',
459
+ optional: true,
460
+ description: 'Optional flag to force recovery, encoded as string (e.g. "true" or "false").',
461
+ },
462
+ },
463
+ },
464
+ {
465
+ name: 'agent-status',
466
+ description: 'Get agent status, version, network interfaces, and share hints (shareUsername/sharePassword/nfsExportPath) for CIFS/NFS mounting.',
467
+ params: {},
468
+ },
469
+ {
470
+ name: 'docs-list',
471
+ description: 'List available documentation categories.',
472
+ params: {},
473
+ },
474
+ {
475
+ name: 'docs-read',
476
+ description: 'Read a specific documentation by its ID. IDs can be found via `core-docs-list`.',
477
+ params: {
478
+ id: {
479
+ type: 'string',
480
+ description: 'ID of the documentation to read.',
481
+ },
482
+ board: {
483
+ type: 'string',
484
+ optional: true,
485
+ description: 'Optional board model for board_notes doc.',
486
+ },
487
+ section: {
488
+ type: 'string',
489
+ optional: true,
490
+ description: 'Optional section for board_notes doc.',
491
+ },
492
+ },
493
+ },
494
+ ];
495
+ function listProcedures() {
496
+ // TFTP/NFS related procedures are removed from here.
497
+ // The list should now accurately reflect only the exposed procedures.
498
+ return PROCEDURES;
499
+ }
500
+ function paramsToJsonSchema(params) {
501
+ const properties = {};
502
+ const required = [];
503
+ for (const [key, def] of Object.entries(params)) {
504
+ const type = def.type;
505
+ if (!def.optional)
506
+ required.push(key);
507
+ const schema = {};
508
+ if (type === 'number') {
509
+ schema.type = 'number';
510
+ }
511
+ else if (type === 'base64') {
512
+ schema.type = 'string';
513
+ schema.contentEncoding = 'base64';
514
+ }
515
+ else {
516
+ schema.type = 'string';
517
+ }
518
+ if (def.description)
519
+ schema.description = def.description;
520
+ properties[key] = schema;
521
+ }
522
+ return {
523
+ type: 'object',
524
+ properties,
525
+ required,
526
+ additionalProperties: false,
527
+ };
528
+ }
529
+ function toMcpToolName(procName) {
530
+ // 如果已经是新格式(不包含点号),直接返回
531
+ if (!procName.includes('.')) {
532
+ return procName;
533
+ }
534
+ // OpenAI tools.name 只能使用字母、数字、下划线和中划线
535
+ const normalized = procName.split('.').join('-');
536
+ return normalized;
537
+ }
538
+ function fromMcpToolName(toolName) {
539
+ // 将连字符格式转换为点号格式,用于调用agent端
540
+ return toolName.split('-').join('.');
541
+ }
542
+ function normalizeBoardUartWriteText(text) {
543
+ const s = text ?? '';
544
+ if (!s.length) {
545
+ return '\r\n';
546
+ }
547
+ // 处理尾部转义字符的兼容性
548
+ const len = s.length;
549
+ // 处理 ...\\r\\n 的情况(需要至少4个字符:\ r \ n)
550
+ if (len >= 4 && s.slice(-4) === '\\r\\n') {
551
+ return s.slice(0, -4) + '\r\n';
552
+ }
553
+ // 处理 ...\\n 的情况(需要至少2个字符:\ n)
554
+ if (len >= 2 && s.slice(-2) === '\\n') {
555
+ return s.slice(0, -2) + '\n';
556
+ }
557
+ // 原有逻辑:如果尾部是 \n,直接返回
558
+ const last = s.charAt(s.length - 1);
559
+ if (last === '\n') {
560
+ return s;
561
+ }
562
+ // 否则在尾部添加 \r\n
563
+ return s + '\r\n';
564
+ }
565
+ // 预编译的正则表达式,提高性能
566
+ const ANSI_ESCAPE_PATTERN = /\x1b(?:[@-Z\\-_]|[[0-?]*[ -/]*[@-~])/g;
567
+ // 处理可能的ANSI转义序列变体(包括ESC字符被省略的情况)
568
+ const ANSI_ESCAPE_VARIANT_PATTERN = /\[(?:[0-9;]*m|[0-9;]*[HJK]|[0-?]*[hl]|[@-Z\\-_])/g;
569
+ /**
570
+ * 过滤控制字符(ASCII控制字符 + ANSI转义序列)
571
+ * 保留常用的格式化字符:TAB(\t), LF(\n), CR(\r)
572
+ * @param data 原始Buffer数据
573
+ * @returns 过滤后的UTF-8字符串
574
+ */
575
+ export function filterAsciiControlChars(data) {
576
+ if (!Buffer.isBuffer(data) || data.length === 0) {
577
+ return '';
578
+ }
579
+ // 将Buffer转换为字符串
580
+ let text = data.toString('utf8');
581
+ // 1. 过滤ASCII控制字符(保持原有逻辑)
582
+ // 过滤范围:0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F
583
+ // 保留常用的格式化字符:TAB(\t), LF(\n), CR(\r)
584
+ text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
585
+ // 2. 过滤所有ANSI转义序列
586
+ // 包括颜色控制、光标控制、屏幕清除等所有ANSI序列
587
+ // 先处理标准的ANSI转义序列(包含ESC字符)
588
+ text = text.replace(ANSI_ESCAPE_PATTERN, '');
589
+ // 3. 处理可能的ANSI转义序列变体(ESC字符被省略的情况)
590
+ // 这能解决类似 [0;0mcmdline[0m 中的颜色控制符问题
591
+ text = text.replace(ANSI_ESCAPE_VARIANT_PATTERN, '');
592
+ return text;
593
+ }
594
+ async function callProcedureInternal(name, params) {
595
+ async function withAgentRetry(stage, fn, toolName) {
596
+ let attempt = 0;
597
+ while (true) {
598
+ try {
599
+ if (attempt === 0) {
600
+ logDebug('call', stage, 'using agent', agentAddr);
601
+ }
602
+ else {
603
+ logDebug('retry', stage, 'attempt', attempt, 'agent', agentAddr);
604
+ }
605
+ return await fn(agent);
606
+ }
607
+ catch (err) {
608
+ const errorType = classifyError(err, toolName);
609
+ // 如果不是网络错误或重试次数超限,抛出友好的错误信息
610
+ if (!isNetworkError(err) || attempt >= 8) {
611
+ throw createFriendlyError(err, errorType, toolName || stage);
612
+ }
613
+ attempt += 1;
614
+ // 第一次重试时给出用户提示
615
+ if (attempt === 1) {
616
+ logDebug('connection issue detected, will retry', getFriendlyErrorMessage(errorType, toolName || stage, err));
617
+ }
618
+ // 简单重试延迟:每次重试等待1秒
619
+ await new Promise((resolve) => setTimeout(resolve, 1000));
620
+ try {
621
+ const info = loadAgentDiscovery();
622
+ agentAddr = info.addr;
623
+ agentPairCode = info.pairCode;
624
+ agent = createAgentClient(agentAddr, { hostInstanceId: HOST_INSTANCE_ID });
625
+ const res = await agent.pairCheck({
626
+ pairCode: agentPairCode,
627
+ clientId: `boardlinker-host-retry-${stage}`,
628
+ timeoutMs: 3000,
629
+ });
630
+ if (!res.accepted) {
631
+ throw createFriendlyError(new Error(`PairCheck failed: ${res.reason || 'PAIR_CODE_MISMATCH'}`), ConnectionErrorType.UNAVAILABLE, toolName || stage);
632
+ }
633
+ }
634
+ catch (pairErr) {
635
+ // ���对失败也使用友好错误
636
+ throw createFriendlyError(pairErr, classifyError(pairErr, toolName), toolName || stage);
637
+ }
638
+ }
639
+ }
640
+ }
641
+ // 持续信号发送逻辑
642
+ async function sendHoldSignal(signal, sessionId) {
643
+ const signalBytes = ControlSignalParser.signalToBytes(signal);
644
+ if (!signalBytes || signalBytes.length === 0) {
645
+ return;
646
+ }
647
+ const startTime = Date.now();
648
+ const duration = signal.duration;
649
+ // 以固定间隔持续发送信号字节
650
+ while (Date.now() - startTime < duration) {
651
+ await withAgentRetry('board_uart.send_signal', (cli) => cli.boardUartSessionWrite({
652
+ sessionId,
653
+ data: signalBytes[0],
654
+ }), 'board_uart-send_signal');
655
+ // 发送间隔,避免过于频繁
656
+ await new Promise((resolve) => setTimeout(resolve, 50)); // 50ms间隔
657
+ }
658
+ }
659
+ // 主信号发送函数
660
+ async function boardUartSendSignal(params) {
661
+ try {
662
+ // 验证必要参数
663
+ if (!params.signal || typeof params.signal !== 'string') {
664
+ return {
665
+ ok: false,
666
+ details: 'signal���数是必需的字符串',
667
+ bytesSent: 0,
668
+ remains: 0,
669
+ };
670
+ }
671
+ // 解析控制信号
672
+ const controlSignal = ControlSignalParser.parseSignal(params.signal);
673
+ controlSignal.repeat = params.repeat ?? 1;
674
+ // 验证信号是否有效
675
+ if (!ControlSignalParser.isValidSignal(controlSignal.signal)) {
676
+ return {
677
+ ok: false,
678
+ details: `不支持的控制信号: ${controlSignal.signal}`,
679
+ bytesSent: 0,
680
+ remains: 0,
681
+ };
682
+ }
683
+ // 获取会话ID
684
+ let sessionId = params.session_id;
685
+ if (!sessionId) {
686
+ if (!defaultBoardUartSessionId) {
687
+ const s = await withAgentRetry('board_uart.sessions', (cli) => cli.boardUartListSessions(), 'board_uart-sessions');
688
+ const def = s.sessions.find((it) => it.isDefault) || s.sessions[0];
689
+ if (!def) {
690
+ throw new Error('no board-uart session available for send_signal');
691
+ }
692
+ defaultBoardUartSessionId = def.id;
693
+ }
694
+ sessionId = defaultBoardUartSessionId || undefined;
695
+ }
696
+ // 转换为字节数据
697
+ const signalDataList = ControlSignalParser.signalToBytes(controlSignal);
698
+ let totalBytesSent = 0;
699
+ // 按重复次数发送
700
+ for (let i = 0; i < (controlSignal.repeat || 1); i++) {
701
+ for (const data of signalDataList) {
702
+ const result = await withAgentRetry('board_uart.send_signal', (cli) => cli.boardUartSessionWrite({
703
+ sessionId: sessionId,
704
+ data: data,
705
+ }), 'board_uart-send_signal');
706
+ totalBytesSent += result.bytesWritten;
707
+ // 如果是持续信号,需要按照持续时间发送
708
+ if (controlSignal.type === 'hold' && controlSignal.duration) {
709
+ await sendHoldSignal(controlSignal, sessionId);
710
+ }
711
+ }
712
+ }
713
+ const logger = getHostLogger();
714
+ logger.write(LogLevel.INFO, 'control signal sent', {
715
+ signal: params.signal,
716
+ sessionId,
717
+ repeat: controlSignal.repeat,
718
+ totalBytesSent,
719
+ });
720
+ return {
721
+ ok: true,
722
+ bytesSent: totalBytesSent,
723
+ details: `成功发送信号: ${params.signal} (重复${controlSignal.repeat}次)`,
724
+ remains: 0, // 信号发送操作不会产生待读取数据
725
+ };
726
+ }
727
+ catch (error) {
728
+ const logger = getHostLogger();
729
+ logger.write(LogLevel.ERROR, '控制信号发送失败', {
730
+ signal: params.signal,
731
+ error: error?.message || String(error),
732
+ });
733
+ return {
734
+ ok: false,
735
+ bytesSent: 0,
736
+ details: `发送失败: ${error?.message || String(error)}`,
737
+ remains: 0, // 错误情况下,剩余数据设为0
738
+ };
739
+ }
740
+ }
741
+ if (name === 'files-put') {
742
+ const fs = await import('node:fs');
743
+ const fsp = await import('node:fs/promises');
744
+ const path = await import('node:path');
745
+ const localPath = params.src;
746
+ const remotePath = params.dst;
747
+ const chunkSize = typeof params.chunk_size === 'number' && params.chunk_size > 0
748
+ ? params.chunk_size
749
+ : 64 * 1024;
750
+ const overwrite = params.overwrite !== 0 && params.overwrite !== false;
751
+ if (!path.isAbsolute(localPath)) {
752
+ return "Parameter 'src' must be an absolute path. Please retry with an absolute src.";
753
+ }
754
+ if (remotePath && path.isAbsolute(remotePath)) {
755
+ return "Parameter 'dst' must be a relative path (relative to Agent files root). Please provide a relative dst.";
756
+ }
757
+ if (!fs.existsSync(localPath)) {
758
+ throw new Error(`本地路径不存在: ${localPath}`);
759
+ }
760
+ const stats = fs.lstatSync(localPath);
761
+ const collectFiles = async (root) => {
762
+ const entries = await fsp.readdir(root, { withFileTypes: true });
763
+ const result = [];
764
+ for (const entry of entries) {
765
+ const abs = path.join(root, entry.name);
766
+ const rel = path.relative(localPath, abs).replace(/\\/g, '/');
767
+ if (entry.isDirectory()) {
768
+ const nested = await collectFiles(abs);
769
+ result.push(...nested);
770
+ }
771
+ else if (entry.isFile()) {
772
+ const st = await fsp.stat(abs);
773
+ result.push({ relative: rel || entry.name, abs, size: st.size });
774
+ }
775
+ }
776
+ return result;
777
+ };
778
+ // 生成唯一请求ID
779
+ const requestId = `files_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
780
+ // 详细记录Host端路径信息
781
+ logHostDebug('files.put.start', {
782
+ requestId,
783
+ operation: 'put',
784
+ originalSourcePath: localPath, // Host端原始源路径(绝对路径)
785
+ originalTargetPath: remotePath, // Host端原始目标路径(相对路径)
786
+ overwrite: overwrite,
787
+ chunkSize: chunkSize,
788
+ hostWorkingDirectory: process.cwd(), // Host端工作目录
789
+ hostUser: process.env.USER, // Host端用户信息
790
+ timestamp: Date.now(),
791
+ });
792
+ const { call, response } = await withAgentRetry('files.putPath', (cli) => Promise.resolve(cli.filesPutPath()), 'files-put');
793
+ const sendMeta = (meta) => {
794
+ call.write({
795
+ meta: {
796
+ ...meta,
797
+ requestId: requestId,
798
+ // 传递路径上下文信息给Agent端
799
+ pathContext: {
800
+ hostOriginalSourcePath: localPath,
801
+ hostOriginalTargetPath: remotePath,
802
+ hostTimestamp: Date.now(),
803
+ hostUser: process.env.USER,
804
+ },
805
+ },
806
+ });
807
+ };
808
+ const sendFile = async (relativePath, absPath) => new Promise((resolve, reject) => {
809
+ const rs = fs.createReadStream(absPath, { highWaterMark: chunkSize });
810
+ rs.on('data', (buf) => {
811
+ call.write({
812
+ data: {
813
+ relative_path: relativePath,
814
+ content: buf,
815
+ is_file_complete: false,
816
+ },
817
+ });
818
+ });
819
+ rs.on('end', () => {
820
+ call.write({
821
+ data: {
822
+ relative_path: relativePath,
823
+ content: Buffer.alloc(0),
824
+ is_file_complete: true,
825
+ },
826
+ });
827
+ resolve();
828
+ });
829
+ rs.on('error', reject);
830
+ call.on('error', reject);
831
+ });
832
+ try {
833
+ if (stats.isDirectory()) {
834
+ const files = await collectFiles(localPath);
835
+ const totalBytes = files.reduce((acc, cur) => acc + cur.size, 0);
836
+ const dstBase = remotePath || path.basename(localPath);
837
+ sendMeta({
838
+ src_type: 2, // DIRECTORY
839
+ dst_path: dstBase,
840
+ total_files: files.length,
841
+ total_bytes: totalBytes,
842
+ overwrite,
843
+ });
844
+ for (const f of files) {
845
+ await sendFile(f.relative, f.abs);
846
+ }
847
+ }
848
+ else {
849
+ const fileName = remotePath || path.basename(localPath);
850
+ sendMeta({
851
+ src_type: 1, // FILE
852
+ dst_path: fileName,
853
+ total_files: 1,
854
+ total_bytes: stats.size,
855
+ overwrite,
856
+ });
857
+ await sendFile(path.basename(fileName), localPath);
858
+ }
859
+ call.end();
860
+ const res = await response;
861
+ // 记录上传成功
862
+ logHostDebug('files.put.success', {
863
+ requestId,
864
+ result: {
865
+ success: res.success,
866
+ filesWritten: res.files_written,
867
+ bytesWritten: res.bytes_written,
868
+ failedFiles: res.failed_files,
869
+ },
870
+ });
871
+ // 返回简化的结构化结果
872
+ const simpleResult = {
873
+ result: res.success ? 'done' : 'fail',
874
+ nextstep: '文件已存储在Agent中转站,需通过TFTP或NFS等方式传输到板卡访问,(通过board_linker docs工具获取用法)',
875
+ };
876
+ return simpleResult;
877
+ }
878
+ catch (err) {
879
+ // 记录上传失败
880
+ logHostDebug('files.put.error', {
881
+ requestId,
882
+ error: err?.message || String(err),
883
+ stack: err?.stack,
884
+ });
885
+ call.destroy();
886
+ // 返回失败结果而不是抛出异常
887
+ let nextstep = '检查文件路径和权限后重试';
888
+ if (err?.message?.includes('space') || err?.message?.includes('ENOSPC')) {
889
+ nextstep = '清理Agent侧存储空间后重试';
890
+ }
891
+ else if (err?.message?.includes('timeout')) {
892
+ nextstep = '检查网络连接后重试';
893
+ }
894
+ const simpleResult = {
895
+ result: 'fail',
896
+ nextstep: nextstep,
897
+ };
898
+ return simpleResult;
899
+ }
900
+ }
901
+ if (name === 'files-get') {
902
+ const fs = await import('node:fs');
903
+ const fsp = await import('node:fs/promises');
904
+ const path = await import('node:path');
905
+ const srcPath = params.src;
906
+ const dstPath = params.dst;
907
+ const chunkSize = typeof params.chunk_size === 'number' && params.chunk_size > 0
908
+ ? params.chunk_size
909
+ : 64 * 1024;
910
+ const skipHidden = params.skip_hidden === 1 || params.skip_hidden === true;
911
+ if (!dstPath || !path.isAbsolute(dstPath)) {
912
+ return "Parameter 'dst' is required and must be an absolute path. Please provide an absolute dst.";
913
+ }
914
+ if (path.isAbsolute(srcPath)) {
915
+ return "Parameter 'src' must be a relative path (relative to Agent files root). Please provide a relative src.";
916
+ }
917
+ // 生成唯一请求ID
918
+ const requestId = `files_get_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
919
+ // 详细记录Host端路径信息
920
+ logHostDebug('files.get.start', {
921
+ requestId,
922
+ operation: 'get',
923
+ originalSourcePath: srcPath, // Agent端源路径(相对路径)
924
+ originalTargetPath: dstPath, // Host端目标路径(绝对路径)
925
+ chunkSize: chunkSize,
926
+ skipHidden: skipHidden,
927
+ hostWorkingDirectory: process.cwd(),
928
+ hostUser: process.env.USER,
929
+ timestamp: Date.now(),
930
+ });
931
+ const call = await withAgentRetry('files.getPath', (cli) => cli.filesGetPath({
932
+ src_path: srcPath,
933
+ recursive: true,
934
+ chunk_size: chunkSize,
935
+ skip_hidden: skipHidden,
936
+ // 添加额外字段用于路径追踪
937
+ ...(requestId && { requestId }),
938
+ ...(dstPath && {
939
+ pathContext: {
940
+ hostOriginalSourcePath: srcPath,
941
+ hostOriginalTargetPath: dstPath,
942
+ hostTimestamp: Date.now(),
943
+ hostUser: process.env.USER,
944
+ },
945
+ }),
946
+ }), 'files-get');
947
+ return await new Promise((resolve, reject) => {
948
+ let srcType = null;
949
+ let baseDir = dstPath;
950
+ let rootDisplay = '';
951
+ let currentWriter = null;
952
+ let currentRelative = '';
953
+ let totalBytes = 0;
954
+ let completedFiles = 0;
955
+ let totalFiles = 0;
956
+ let resolved = false;
957
+ let processing = Promise.resolve();
958
+ const closeWriter = async () => {
959
+ if (!currentWriter)
960
+ return;
961
+ await new Promise((res, rej) => currentWriter.end((err) => (err ? rej(err) : res())));
962
+ currentWriter = null;
963
+ };
964
+ const handleError = async (err) => {
965
+ if (resolved)
966
+ return;
967
+ resolved = true;
968
+ // 记录下载失败
969
+ logHostDebug('files.get.error', {
970
+ requestId,
971
+ error: err?.message || String(err),
972
+ stack: err?.stack,
973
+ });
974
+ try {
975
+ await closeWriter();
976
+ }
977
+ catch {
978
+ // ignore
979
+ }
980
+ // 返回失败结果而不是抛出异常
981
+ let nextstep = '检查文件路径和权限后重试';
982
+ if (err?.message?.includes('not found') || err?.message?.includes('ENOENT')) {
983
+ nextstep = '检查Agent侧文件路径后重试';
984
+ }
985
+ else if (err?.message?.includes('timeout')) {
986
+ nextstep = '检查网络连接后重试';
987
+ }
988
+ const simpleResult = {
989
+ result: 'fail',
990
+ nextstep: nextstep,
991
+ };
992
+ resolve(simpleResult);
993
+ };
994
+ const normalizeSrcType = (v) => {
995
+ if (v === 2 || v === 'FILE_TYPE_DIRECTORY' || v === 'DIRECTORY')
996
+ return 2;
997
+ if (v === 1 || v === 'FILE_TYPE_FILE' || v === 'FILE')
998
+ return 1;
999
+ return 0;
1000
+ };
1001
+ const processChunk = async (chunk) => {
1002
+ if (!srcType) {
1003
+ srcType = normalizeSrcType(chunk.src_type);
1004
+ if (srcType === 2) {
1005
+ baseDir = baseDir || path.basename(srcPath);
1006
+ await fsp.mkdir(baseDir, { recursive: true });
1007
+ rootDisplay = baseDir;
1008
+ }
1009
+ else {
1010
+ const targetFile = dstPath || path.basename(srcPath);
1011
+ baseDir = path.dirname(targetFile);
1012
+ await fsp.mkdir(baseDir || '.', { recursive: true });
1013
+ rootDisplay = targetFile;
1014
+ }
1015
+ }
1016
+ const relativePath = chunk.relative_path || path.basename(srcPath);
1017
+ totalFiles = chunk.total_files || totalFiles;
1018
+ const targetPath = srcType === 2
1019
+ ? path.join(baseDir || '.', relativePath)
1020
+ : dstPath || path.basename(srcPath);
1021
+ if (relativePath !== currentRelative) {
1022
+ await closeWriter();
1023
+ const parentDir = path.dirname(targetPath);
1024
+ await fsp.mkdir(parentDir, { recursive: true });
1025
+ currentWriter = fs.createWriteStream(targetPath);
1026
+ currentRelative = relativePath;
1027
+ }
1028
+ if (chunk.content && chunk.content.length > 0) {
1029
+ const buf = Buffer.from(chunk.content);
1030
+ currentWriter.write(buf);
1031
+ totalBytes += buf.length;
1032
+ }
1033
+ if (chunk.is_file_complete) {
1034
+ await closeWriter();
1035
+ currentRelative = '';
1036
+ completedFiles = chunk.files_completed || completedFiles + 1;
1037
+ }
1038
+ const done = chunk.is_directory_complete ||
1039
+ (srcType === 1 && chunk.is_file_complete) ||
1040
+ (srcType === 1 && chunk.is_directory_complete);
1041
+ if (done && !resolved) {
1042
+ resolved = true;
1043
+ // 记录下载成功
1044
+ logHostDebug('files.get.success', {
1045
+ requestId,
1046
+ result: {
1047
+ remotePath: srcPath,
1048
+ localPath: rootDisplay || baseDir || dstPath || path.basename(srcPath),
1049
+ downloadedFiles: completedFiles || totalFiles || 1,
1050
+ totalBytes: totalBytes,
1051
+ },
1052
+ });
1053
+ // 返回简化的结构化结果
1054
+ const simpleResult = {
1055
+ result: 'done',
1056
+ nextstep: '文件已下载到本地,可直接访问',
1057
+ };
1058
+ resolve(simpleResult);
1059
+ }
1060
+ };
1061
+ call.on('data', (chunk) => {
1062
+ processing = processing.then(() => processChunk(chunk)).catch(handleError);
1063
+ });
1064
+ call.on('error', handleError);
1065
+ call.on('end', () => {
1066
+ processing
1067
+ .then(async () => {
1068
+ if (resolved)
1069
+ return;
1070
+ resolved = true;
1071
+ await closeWriter();
1072
+ // 记录下载成功
1073
+ logHostDebug('files.get.success', {
1074
+ requestId,
1075
+ result: {
1076
+ remotePath: srcPath,
1077
+ localPath: rootDisplay || baseDir || dstPath || path.basename(srcPath),
1078
+ downloadedFiles: completedFiles || totalFiles || 1,
1079
+ totalBytes: totalBytes,
1080
+ },
1081
+ });
1082
+ // 返回简化的结构化结果
1083
+ const simpleResult = {
1084
+ result: 'done',
1085
+ nextstep: '文件已下载到本地,可直接访问',
1086
+ };
1087
+ resolve(simpleResult);
1088
+ })
1089
+ .catch(handleError);
1090
+ });
1091
+ });
1092
+ }
1093
+ if (name === 'files-list') {
1094
+ const path = await import('node:path');
1095
+ const rel = params.path || '';
1096
+ if (rel && path.isAbsolute(rel)) {
1097
+ return "Parameter 'path' must be relative to Agent files root.";
1098
+ }
1099
+ const recursive = params.recursive === 1 || params.recursive === true;
1100
+ const all = params.all === 1 || params.all === true;
1101
+ const pattern = params.pattern;
1102
+ const res = await withAgentRetry('files.list', (cli) => cli.filesList({ path: rel || undefined, recursive, all, pattern }), 'files-list');
1103
+ return res;
1104
+ }
1105
+ if (name === 'files-stat') {
1106
+ const path = await import('node:path');
1107
+ const rel = params.path;
1108
+ if (!rel) {
1109
+ return "Parameter 'path' is required and must be relative to Agent files root.";
1110
+ }
1111
+ if (path.isAbsolute(rel)) {
1112
+ return "Parameter 'path' must be relative to Agent files root.";
1113
+ }
1114
+ const res = await withAgentRetry('files.stat', (cli) => cli.filesGetStat({ path: rel }), 'files-stat');
1115
+ return res;
1116
+ }
1117
+ if (name === 'files-mkdir') {
1118
+ const path = await import('node:path');
1119
+ const rel = params.path;
1120
+ if (!rel) {
1121
+ return "Parameter 'path' is required and must be relative to Agent files root.";
1122
+ }
1123
+ if (path.isAbsolute(rel)) {
1124
+ return "Parameter 'path' must be relative to Agent files root.";
1125
+ }
1126
+ const parents = params.parents === 1 || params.parents === true;
1127
+ const res = await withAgentRetry('files.mkdir', (cli) => cli.filesMakeDir({ path: rel, parents }), 'files-mkdir');
1128
+ return res;
1129
+ }
1130
+ if (name === 'files-rm') {
1131
+ const path = await import('node:path');
1132
+ const rel = params.path;
1133
+ if (!rel) {
1134
+ return "Parameter 'path' is required and must be relative to Agent files root.";
1135
+ }
1136
+ if (path.isAbsolute(rel)) {
1137
+ return "Parameter 'path' must be relative to Agent files root.";
1138
+ }
1139
+ const recursive = params.recursive === 1 || params.recursive === true;
1140
+ const res = await withAgentRetry('files.rm', (cli) => cli.filesRemove({ path: rel, recursive }), 'files-rm');
1141
+ return res;
1142
+ }
1143
+ if (name === 'board_uart-sessions') {
1144
+ return withAgentRetry('board_uart.sessions', (cli) => cli.boardUartListSessions(), 'board_uart-sessions');
1145
+ }
1146
+ if (name === 'board_uart-run_command') {
1147
+ const hasCommand = params && Object.prototype.hasOwnProperty.call(params, 'command');
1148
+ const hasData = params && Object.prototype.hasOwnProperty.call(params, 'data');
1149
+ if (!hasCommand && !hasData) {
1150
+ throw new Error('board_uart.run_command requires argument `command` (string, can be empty).');
1151
+ }
1152
+ const rawValue = hasCommand ? params.command : params.data;
1153
+ const raw = typeof rawValue === 'string' ? rawValue : String(rawValue ?? '');
1154
+ const normalized = normalizeBoardUartWriteText(raw);
1155
+ const buf = Buffer.from(normalized, 'utf8');
1156
+ // 如果未提供 sessionId,留空(表示 Default Session)
1157
+ // Host 不再做任何智能解析,完全依赖 Agent 端逻辑
1158
+ const sessionId = params.session_id || undefined;
1159
+ const r2 = await withAgentRetry('board_uart.run_command', (cli) => cli.boardUartWrite({
1160
+ sessionId,
1161
+ data: buf,
1162
+ waitFor: params.wait_for,
1163
+ timeoutMs: params.timeout_ms,
1164
+ }), 'board_uart-run_command');
1165
+ return r2;
1166
+ }
1167
+ if (name === 'board_uart-send_signal') {
1168
+ return await boardUartSendSignal(params);
1169
+ }
1170
+ if (name === 'board_uart-read') {
1171
+ const result = await withAgentRetry('board_uart.read', (cli) => cli.boardUartSessionRead({
1172
+ sessionId: params.session_id || defaultBoardUartSessionId || undefined,
1173
+ maxBytes: params.max_bytes,
1174
+ quietMs: params.quiet_ms,
1175
+ timeoutMs: params.timeout_ms,
1176
+ }), 'board_uart-read');
1177
+ // 过滤ASCII控制字符,将Buffer转换为干净的字符串
1178
+ if (result && result.data && Buffer.isBuffer(result.data)) {
1179
+ const filteredText = filterAsciiControlChars(result.data);
1180
+ return {
1181
+ ...result,
1182
+ data: filteredText,
1183
+ // 保留原始bytes信息,但说明数据已被处理
1184
+ originalBytes: result.bytes,
1185
+ filtered: true,
1186
+ };
1187
+ }
1188
+ return result;
1189
+ }
1190
+ if (name === 'board_uart-status') {
1191
+ return withAgentRetry('board_uart.status', (cli) => cli.boardUartStatus(), 'board_uart-status');
1192
+ }
1193
+ if (name === 'firmware-prepare_images') {
1194
+ // Upload local images directory to agent TFTP images_agent subdirectory
1195
+ const fs = await import('node:fs/promises');
1196
+ const pathMod = await import('node:path');
1197
+ const localDir = params.localImagesDir;
1198
+ if (!localDir || typeof localDir !== 'string') {
1199
+ throw new Error('firmware.prepare_images: localImagesDir is required');
1200
+ }
1201
+ const stat = await fs.stat(localDir);
1202
+ if (!stat.isDirectory()) {
1203
+ throw new Error(`firmware.prepare_images: localImagesDir must be a directory, got ${localDir}`);
1204
+ }
1205
+ const imagesRoot = 'images_agent';
1206
+ // 复用 tftp.upload 逻辑将整个目录同步到 agent 端 TFTP_ROOT/images_agent
1207
+ const { default: path } = pathMod;
1208
+ const entries = [];
1209
+ const walk = async (base, dir) => {
1210
+ const full = path.join(base, dir);
1211
+ const items = await fs.readdir(full, { withFileTypes: true });
1212
+ for (const ent of items) {
1213
+ const entPath = path.join(full, ent.name);
1214
+ const rel = path.relative(localDir, entPath);
1215
+ if (ent.isDirectory()) {
1216
+ await walk(base, path.join(dir, ent.name));
1217
+ }
1218
+ else if (ent.isFile()) {
1219
+ entries.push({ full: entPath, rel });
1220
+ }
1221
+ }
1222
+ };
1223
+ await walk(localDir, '.');
1224
+ let totalBytes = 0;
1225
+ for (let i = 0; i < entries.length; i += 1) {
1226
+ const ent = entries[i];
1227
+ logDebug('firmware.prepare_images.upload', `${i + 1}/${entries.length}`, ent.rel);
1228
+ const buf = await fs.readFile(ent.full);
1229
+ const remotePath = [imagesRoot, ent.rel].filter(Boolean).join('/');
1230
+ const res = await withAgentRetry('tftp.upload', (cli) => cli.tftpUpload({ fileName: remotePath, content: buf }), 'tftp-upload');
1231
+ totalBytes += res.size;
1232
+ }
1233
+ // 调用 Agent 端 FirmwarePrepareImages,让其解析 images_root 下的元数据
1234
+ const prepareRes = await withAgentRetry('firmware.prepare_images', (cli) => cli.firmwarePrepareImages({ imagesRoot }), 'firmware-prepare_images');
1235
+ return prepareRes.meta;
1236
+ }
1237
+ if (name === 'firmware-burn_recover') {
1238
+ logDebug('firmware-burn_recover.start', {
1239
+ imagesRoot: params.imagesRoot,
1240
+ args: params.args,
1241
+ timeoutMs: params.timeout_ms,
1242
+ force: params.force,
1243
+ });
1244
+ let argsArr = params.args;
1245
+ if (typeof argsArr === 'string') {
1246
+ try {
1247
+ argsArr = JSON.parse(argsArr);
1248
+ }
1249
+ catch (e) {
1250
+ const errorMsg = `firmware.burn_recover: failed to parse args JSON: ${e?.message || String(e)}`;
1251
+ logDebug('firmware-burn_recover.parse_error', { args: params.args, error: errorMsg });
1252
+ throw new Error(errorMsg);
1253
+ }
1254
+ }
1255
+ if (!Array.isArray(argsArr)) {
1256
+ const errorMsg = 'firmware.burn_recover: args must be an array';
1257
+ logDebug('firmware-burn_recover.validate_error', { args: argsArr, error: errorMsg });
1258
+ throw new Error(errorMsg);
1259
+ }
1260
+ const args = [];
1261
+ for (const v of argsArr) {
1262
+ if (typeof v !== 'string') {
1263
+ const errorMsg = 'firmware.burn_recover: every arg must be a string';
1264
+ logDebug('firmware-burn_recover.validate_error', { args: argsArr, error: errorMsg });
1265
+ throw new Error(errorMsg);
1266
+ }
1267
+ args.push(v);
1268
+ }
1269
+ const timeoutMs = typeof params.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : 0;
1270
+ let force = false;
1271
+ if (typeof params.force === 'string') {
1272
+ const v = params.force.toLowerCase();
1273
+ force = v === 'true' || v === '1' || v === 'yes';
1274
+ }
1275
+ logDebug('firmware-burn_recover.calling_agent', {
1276
+ imagesRoot: params.imagesRoot,
1277
+ args,
1278
+ timeoutMs,
1279
+ force,
1280
+ agentAddr,
1281
+ });
1282
+ try {
1283
+ const r = await withAgentRetry('firmware.burn_recover', (cli) => cli.firmwareBurnRecover({
1284
+ imagesRoot: params.imagesRoot,
1285
+ args,
1286
+ timeoutMs,
1287
+ force,
1288
+ }), 'firmware-burn_recover');
1289
+ logDebug('firmware-burn_recover.success', {
1290
+ imagesRoot: params.imagesRoot,
1291
+ result: { ok: r?.ok, exitCode: r?.exitCode, detailsLength: r?.details?.length },
1292
+ });
1293
+ return r;
1294
+ }
1295
+ catch (error) {
1296
+ logDebug('firmware-burn_recover.error', {
1297
+ imagesRoot: params.imagesRoot,
1298
+ error: error?.message || String(error),
1299
+ code: error?.code,
1300
+ details: error?.details,
1301
+ stack: error?.stack,
1302
+ });
1303
+ throw error;
1304
+ }
1305
+ }
1306
+ if (name === 'agent-status') {
1307
+ return withAgentRetry('agent.status', (cli) => cli.status(), 'agent-status');
1308
+ }
1309
+ if (name === 'docs-list') {
1310
+ return [
1311
+ { id: 'tftp-transfer', description: 'TFTP transfer setup and usage guide' },
1312
+ { id: 'nfs-mount', description: 'NFS mounting and troubleshooting' },
1313
+ {
1314
+ id: 'board-interaction',
1315
+ description: 'Board interaction protocol and hardware information',
1316
+ },
1317
+ { id: 'firmware-upgrade', description: 'Firmware lifecycle burn/recover user guide' },
1318
+ ];
1319
+ }
1320
+ if (name === 'docs-read') {
1321
+ const docId = params.id;
1322
+ switch (docId) {
1323
+ case 'tftp-transfer':
1324
+ return withAgentRetry('tftp.userguide', (cli) => cli.tftpUserguide(), 'tftp-userguide');
1325
+ case 'nfs-mount':
1326
+ return withAgentRetry('nfs.userguide', (cli) => cli.nfsUserguide(), 'nfs-userguide');
1327
+ case 'board-interaction':
1328
+ return withAgentRetry('agent.board_notes', (cli) => cli.boardNotes({ board: params.board, section: params.section }), 'agent-board_notes');
1329
+ case 'firmware-upgrade': {
1330
+ const r = await withAgentRetry('docs.read.firmware', (cli) => cli.firmwareUserGuide(), 'docs-read-firmware');
1331
+ return { content: r.json, format: 'markdown' };
1332
+ }
1333
+ default:
1334
+ throw new Error(`Unknown documentation ID: ${docId}`);
1335
+ }
1336
+ }
1337
+ throw new Error(`unknown procedure: ${name}`);
1338
+ }
1339
+ async function callProcedure(name, params) {
1340
+ try {
1341
+ logDebug('tools/call', name);
1342
+ const logger = getHostLogger();
1343
+ logger.write(LogLevel.DEBUG, 'tools.call.start', { name, params });
1344
+ try {
1345
+ hostLogCall?.write({
1346
+ ts: Date.now(),
1347
+ level: LogLevel.DEBUG,
1348
+ event: 'tools.call.start',
1349
+ data_json: JSON.stringify({ name, params }),
1350
+ pid: process.pid,
1351
+ });
1352
+ }
1353
+ catch { }
1354
+ const result = await callProcedureInternal(name, params);
1355
+ logger.write(LogLevel.DEBUG, 'tools.call.success', { name, resultType: typeof result });
1356
+ try {
1357
+ hostLogCall?.write({
1358
+ ts: Date.now(),
1359
+ level: LogLevel.DEBUG,
1360
+ event: 'tools.call.success',
1361
+ data_json: JSON.stringify({ name, resultType: typeof result }),
1362
+ pid: process.pid,
1363
+ });
1364
+ }
1365
+ catch { }
1366
+ return result;
1367
+ }
1368
+ catch (err) {
1369
+ const raw = (err && (err.message || err.details)) || String(err);
1370
+ logDebug('error in procedure', name, raw);
1371
+ const logger = getHostLogger();
1372
+ logger.write(LogLevel.ERROR, 'tools.call.failed', {
1373
+ name,
1374
+ error: raw,
1375
+ stack: err instanceof Error ? err.stack : undefined,
1376
+ });
1377
+ try {
1378
+ hostLogCall?.write({
1379
+ ts: Date.now(),
1380
+ level: LogLevel.ERROR,
1381
+ event: 'tools.call.failed',
1382
+ data_json: JSON.stringify({
1383
+ name,
1384
+ error: raw,
1385
+ stack: err instanceof Error ? err.stack : undefined,
1386
+ }),
1387
+ pid: process.pid,
1388
+ });
1389
+ }
1390
+ catch { }
1391
+ throw err instanceof Error ? err : new Error(raw);
1392
+ }
1393
+ }
1394
+ async function main() {
1395
+ // 初始化日志系统
1396
+ const logger = getHostLogger();
1397
+ logDebug('starting host process, pid', process.pid);
1398
+ // 记录启动信息
1399
+ logger.write(LogLevel.INFO, 'host.starting', {
1400
+ pid: process.pid,
1401
+ nodeVersion: process.version,
1402
+ platform: os.platform(),
1403
+ debugEnabled: DEBUG_ENABLED,
1404
+ });
1405
+ const info = loadAgentDiscovery();
1406
+ agentAddr = info.addr;
1407
+ agentPairCode = info.pairCode;
1408
+ tftpEnabled = info.tftpEnabled ?? true;
1409
+ agent = createAgentClient(agentAddr, { hostInstanceId: HOST_INSTANCE_ID });
1410
+ // 记录Agent连接信息
1411
+ logger.write(LogLevel.INFO, 'agent.connecting', {
1412
+ agentAddr,
1413
+ agentId: info.id,
1414
+ pairCode: agentPairCode.slice(0, 8) + '...',
1415
+ });
1416
+ const res = await agent.pairCheck({ pairCode: agentPairCode, timeoutMs: 3000 });
1417
+ if (!res.accepted) {
1418
+ logger.write(LogLevel.ERROR, 'agent.pair_failed', {
1419
+ agentAddr,
1420
+ reason: res.reason || 'PAIR_CODE_MISMATCH',
1421
+ });
1422
+ throw new Error(`PairCheck rejected for agent at ${agentAddr}: ${res.reason || 'PAIR_CODE_MISMATCH'}`);
1423
+ }
1424
+ logger.write(LogLevel.INFO, 'agent.pair_accepted', {
1425
+ agentAddr,
1426
+ agentId: info.id,
1427
+ });
1428
+ // Host -> Agent 日志流(best-effort)
1429
+ try {
1430
+ const s = agent.hostLogStream();
1431
+ hostLogCall = s.call;
1432
+ s.response.catch(() => { });
1433
+ }
1434
+ catch {
1435
+ hostLogCall = null;
1436
+ }
1437
+ logDebug('PairCheck accepted', {
1438
+ agentAddr,
1439
+ agentId: info.id,
1440
+ pairCode: agentPairCode.slice(0, 8) + '...',
1441
+ });
1442
+ const transport = new StdioServerTransport();
1443
+ const server = new Server({
1444
+ name: 'boardlinker-host',
1445
+ version: '0.1.0',
1446
+ }, {
1447
+ capabilities: {
1448
+ tools: {},
1449
+ },
1450
+ });
1451
+ server.fallbackRequestHandler = async (request) => {
1452
+ if (request.method === 'tools/list') {
1453
+ const parsed = ListToolsRequestSchema.parse(request);
1454
+ logDebug('tools/list', parsed.params ?? {});
1455
+ const tools = listProcedures().map((p) => ({
1456
+ name: toMcpToolName(p.name),
1457
+ description: p.description ?? '',
1458
+ inputSchema: paramsToJsonSchema(p.params),
1459
+ }));
1460
+ return { tools };
1461
+ }
1462
+ if (request.method === 'tools/call') {
1463
+ const parsed = CallToolRequestSchema.parse(request);
1464
+ const name = parsed.params.name;
1465
+ const args = parsed.params.arguments ?? {};
1466
+ logDebug('tools/call', 'calling', name);
1467
+ const result = await callProcedure(name, args);
1468
+ const formattedResult = {
1469
+ content: [
1470
+ {
1471
+ type: 'text',
1472
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
1473
+ },
1474
+ ],
1475
+ };
1476
+ return formattedResult;
1477
+ }
1478
+ logDebug('fallback request handler invoked for unknown method', request.method);
1479
+ throw new Error(`Method not found: ${request.method}`);
1480
+ };
1481
+ await server.connect(transport);
1482
+ // 记录服务启动成功
1483
+ logger.write(LogLevel.INFO, 'host.started', {
1484
+ pid: process.pid,
1485
+ agentAddr,
1486
+ agentId: info.id,
1487
+ });
1488
+ // 启动心跳保活循环,确保Agent端能感知到Host在线状态
1489
+ setInterval(() => {
1490
+ agent.status().catch((err) => {
1491
+ // 仅在调试模式记录心跳失败,避免刷屏
1492
+ logDebug('heartbeat failed', err.message);
1493
+ });
1494
+ }, 5000).unref();
1495
+ }
1496
+ main().catch((err) => {
1497
+ // 记录启动失败
1498
+ try {
1499
+ const logger = getHostLogger();
1500
+ logger.write(LogLevel.ERROR, 'host.start_failed', {
1501
+ error: err?.message || String(err),
1502
+ stack: err instanceof Error ? err.stack : undefined,
1503
+ });
1504
+ }
1505
+ catch (logError) {
1506
+ // 忽略日志错误,优先输出原始错误
1507
+ }
1508
+ // MCP Host 启动失败时直接输出错误并退出
1509
+ // eslint-disable-next-line no-console
1510
+ console.error('boardlinker-host failed to initialize:', err?.message || String(err));
1511
+ process.exit(1);
1512
+ });
1513
+ // 导出控制信号解析器供测试使用
1514
+ export { ControlSignalParser };