made-bridge 0.2.2 → 0.3.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/src/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import { loadConfig, packageRoots, ROOT, ensureUserConfig } from './config.mjs';
11
11
  import { JobStore } from './jobs.mjs';
12
12
  import { VIBE_SESSION_ID } from './sessions.mjs';
13
- import { createRunner, providerStates, isBuiltinAgent } from './providers.mjs';
13
+ import { createRunner, providerStates } from './providers.mjs';
14
14
  import { loadLanguageSettings, saveLanguageSettings, validateLocale } from './language-settings.mjs';
15
15
  import { translateKnownMessage, localizeStatus } from './i18n.mjs';
16
16
  import { transcribe as transcribeVoice, transcriptionState, validateVoiceWav } from './transcribe.mjs';
@@ -22,11 +22,13 @@ import { TunnelManager } from './tunnel.mjs';
22
22
  import { UsbReceiver, USB_DIRECT_AUTHORITY, USB_RECEIVER_AUTHORITY } from './usb-receiver.mjs';
23
23
 
24
24
  import { createPluginRegistry, loadConfiguredPlugins } from './plugins/registry.mjs';
25
+ import { watchPlugins } from './plugins/hot-reload.mjs';
25
26
  import { createEnhancement } from './plugins/enhance.mjs';
26
27
  import { listInstalled, listThemes, removePlugin, removeTheme, savePluginSettings, setPluginEnabled, themeExists } from './installed.mjs';
27
28
  import { createBoardLink } from './board-link.mjs';
28
29
  import { spriteChunk } from './agent-sprites.mjs';
29
30
  import { createScreenshotStore, rgb565ToPng, validateScreenshotParams, MAX_UPLOAD } from './board-screenshot.mjs';
31
+ import { createFirmwareStore } from './firmware-store.mjs';
30
32
  import { initializeSpeechSettings, saveSpeechSettings } from './speech-settings.mjs';
31
33
  import { builtinSpeechRecognizers } from './speech/index.mjs';
32
34
  const PAIRED_DISCOVERY_REQUEST = /^VIBE_DISCOVER_PAIRED_V2 ([0-9a-f]{12}) ([0-9a-f]{32})$/;
@@ -228,8 +230,10 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
228
230
  const publicSession = (session, locale = config.locale) => ({ ...session,
229
231
  ...(session.autoTitlePending ? { title: message(session.title, locale) } : {}) });
230
232
  const publicJob = (job, locale = config.locale) => {
231
- const builtin = isBuiltinAgent(config.pluginsRuntime.agent(job.provider));
232
- return { ...job, ...(builtin ? { progress: message(job.progress, locale), error: message(job.error, locale),
233
+ // 桥接器规范文案(bridgeStrings 插件)在输出边界翻译;第三方插件自有的
234
+ // 进度与错误原样返回。
235
+ const canonical = config.pluginsRuntime.agent(job.provider)?.bridgeStrings === true;
236
+ return { ...job, ...(canonical ? { progress: message(job.progress, locale), error: message(job.error, locale),
233
237
  ...(job.handoffReadError ? { handoffReadError: message(job.handoffReadError, locale) } : {}) } : {}) };
234
238
  };
235
239
  const publicVoice = () => {
@@ -256,6 +260,13 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
256
260
  const pairing = new PairingStore(config.stateDir);
257
261
  const boardLink = createBoardLink();
258
262
  const screenshots = createScreenshotStore(path.join(config.stateDir, 'screenshots'));
263
+ const firmware = createFirmwareStore(path.join(config.stateDir, 'firmware'));
264
+ let firmwareSync = null;
265
+ const ensureLocalFirmware = async () => {
266
+ if (firmware.deviceOffer().path || !firmware.summary().otaUrl) return;
267
+ firmwareSync ||= firmware.syncFromUrl().finally(() => { firmwareSync = null; });
268
+ await firmwareSync;
269
+ };
259
270
  // 增强运行时:回合管道与生命周期通知给 JobStore 用,bridge 句柄给插件上下文用。
260
271
  const enhance = createEnhancement(config);
261
272
  const tunnel = providedTunnel || new TunnelManager({
@@ -310,12 +321,12 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
310
321
  defaultProvider: config.defaultProvider,
311
322
  maxParallel: config.maxParallelJobs,
312
323
  maxAutoRetries: config.maxAutoRetries,
313
- pluginsRuntime: config.pluginsRuntime,
324
+ pluginsRuntime: () => config.pluginsRuntime,
314
325
  run: run || createRunner(config),
315
326
  hooks: enhance,
316
327
  // Keep stored built-in failures canonical; each client localizes at its output boundary.
317
328
  providerAvailable: (id) => ({ ...providerStates(config, 'zh-CN').find((item) => item.id === id),
318
- preserveMessage: !isBuiltinAgent(config.pluginsRuntime.agent(id)) }),
329
+ preserveMessage: config.pluginsRuntime.agent(id)?.bridgeStrings !== true }),
319
330
  });
320
331
  // 任务存储与板侧链路就绪后,把句柄露给管控插件。
321
332
  enhance.attach({ jobs: store, board: boardLink, themes: () => listThemes(config) });
@@ -358,6 +369,10 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
358
369
  send(res, 200, await listInstalled(config), false);
359
370
  return;
360
371
  }
372
+ if (req.method === 'GET' && url.pathname === '/api/firmware') {
373
+ send(res, 200, firmware.summary(), false);
374
+ return;
375
+ }
361
376
  if (req.method === 'GET' && url.pathname === '/api/state') {
362
377
  send(res, 200, {
363
378
  locale: config.locale,
@@ -371,6 +386,8 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
371
386
  tunnel: publicTunnel(),
372
387
  voice: publicVoice(),
373
388
  models: publicModels(config),
389
+ // 行动系统:deviceId → 可用动作名列表(板侧上报的最新一份)。
390
+ deviceActions: boardLink.allActions(),
374
391
  });
375
392
  return;
376
393
  }
@@ -584,6 +601,42 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
584
601
  }
585
602
  throw new Error('码得未在 15 秒内回传截图(固件需要更新到支持 debug.screenshot 的版本)');
586
603
  }
604
+ if (url.pathname === '/api/firmware') {
605
+ const type = String(req.headers['content-type'] || '');
606
+ if (type.startsWith('application/octet-stream')) {
607
+ const version = String(req.headers['x-firmware-version'] || '');
608
+ let bytes;
609
+ try {
610
+ bytes = await binaryBody(req, 8 * 1024 * 1024);
611
+ } catch (error) {
612
+ if (error.message === '录音超过 30 秒上限') throw new Error('固件文件超过 8MB');
613
+ throw error;
614
+ }
615
+ send(res, 200, firmware.importBuffer({
616
+ version, bytes, notes: String(req.headers['x-firmware-notes'] || ''),
617
+ }), false);
618
+ return;
619
+ }
620
+ const payload = await body(req);
621
+ const action = payload && typeof payload.action === 'string' ? payload.action : '';
622
+ if (action === 'url') {
623
+ send(res, 200, firmware.setOtaUrl(payload.otaUrl), false);
624
+ return;
625
+ }
626
+ if (action === 'sync') {
627
+ send(res, 200, await firmware.syncFromUrl(), false);
628
+ return;
629
+ }
630
+ if (action === 'current') {
631
+ send(res, 200, firmware.setCurrent(payload.id), false);
632
+ return;
633
+ }
634
+ if (action === 'delete') {
635
+ send(res, 200, firmware.remove(payload.id), false);
636
+ return;
637
+ }
638
+ throw new Error('固件操作无效');
639
+ }
587
640
  const pairAction = url.pathname.match(/^\/api\/pair\/([0-9a-f]{12})\/(confirm|reject)$/);
588
641
  if (pairAction) {
589
642
  const { nonce } = await body(req);
@@ -724,7 +777,37 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
724
777
  deviceSend(res, 401, { error: '设备未授权' });
725
778
  return;
726
779
  }
780
+ if (req.method === 'GET' && url.pathname === '/device/firmware/current') {
781
+ try { await ensureLocalFirmware(); } catch (error) {
782
+ console.error(`[trace] fw-sync ${error?.message || error} ${new Date().toISOString()}`);
783
+ }
784
+ const offset = Number(url.searchParams.get('offset') || '0');
785
+ const length = Number(url.searchParams.get('length') || '131072');
786
+ if (!Number.isInteger(offset) || offset < 0 || offset > 8 * 1024 * 1024) {
787
+ console.error(`[trace] fw-get off=${url.searchParams.get('offset') ?? '-'} len=${url.searchParams.get('length') ?? '-'} -> 400 ${new Date().toISOString()}`);
788
+ deviceSend(res, 400, { error: '固件偏移无效' });
789
+ return;
790
+ }
791
+ const slice = firmware.readCurrent(offset, Number.isInteger(length) ? length : 128 * 1024);
792
+ console.error(`[trace] fw-get off=${offset} len=${Number.isInteger(length) ? length : 131072} -> ${slice ? slice.bytes.length : -1}B total=${slice ? slice.item.size : '-'} ${new Date().toISOString()}`);
793
+ if (!slice) {
794
+ deviceSend(res, 404, { error: '没有可更新的固件' });
795
+ return;
796
+ }
797
+ res.writeHead(200, {
798
+ 'Content-Type': 'application/octet-stream',
799
+ 'Content-Length': slice.bytes.length,
800
+ 'Cache-Control': 'no-store',
801
+ 'X-Content-Type-Options': 'nosniff',
802
+ 'X-Firmware-Offset': String(slice.offset),
803
+ 'X-Firmware-Size': String(slice.item.size),
804
+ 'X-Firmware-Sha256': slice.item.sha256,
805
+ });
806
+ res.end(slice.bytes);
807
+ return;
808
+ }
727
809
  if (req.method === 'GET' && url.pathname === '/device/heartbeat') {
810
+ console.error(`[trace] hb upMs=${url.searchParams.get('upMs') ?? '-'} wifiReason=${url.searchParams.get('wifiReason') ?? '-'} wifiDrops=${url.searchParams.get('wifiDrops') ?? '-'} fwStage=${url.searchParams.get('fwStage') ?? '-'} fwWhy=${url.searchParams.get('fwWhy') ?? '-'} fwResult=${url.searchParams.get('fwResult') ?? '-'} fwWritten=${url.searchParams.get('fwWritten') ?? '-'} fwSize=${url.searchParams.get('fwSize') ?? '-'} fwHttp=${url.searchParams.get('fwHttp') ?? '-'} fwTasks=${url.searchParams.get('fwTasks') ?? '-'} fwTask=${url.searchParams.get('fwTask') ?? '-'} ${new Date().toISOString()}`);
728
811
  if (process.env.VIBE_UI_DIAGNOSTICS === '1') {
729
812
  const fields = Object.fromEntries(['uiStage', 'uiAge', 'bootAge', 'exit', 'provider', 'displayStage', 'displayCount']
730
813
  .map((key) => [key, url.searchParams.get(key)])
@@ -742,6 +825,8 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
742
825
  deviceSend(res, 200, { events: boardLink.subscription(deviceId, config.pluginsRuntime, config) });
743
826
  return;
744
827
  }
828
+ // 行动系统:可用动作由设备并入命令清单(action.<名> 条目)上报,
829
+ // 不单开通道;名单从清单派生(boardLink.actionsOf)。
745
830
  if (req.method === 'POST' && url.pathname === '/device/events') {
746
831
  await boardLink.handleEvent(deviceId, await body(req), config.pluginsRuntime, config, enhance.bridge());
747
832
  deviceSend(res, 200, { accepted: true });
@@ -797,6 +882,11 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
797
882
  result: result.text,
798
883
  resultTruncated: result.truncated,
799
884
  error: devicePreview(job.error.slice(-200), 96),
885
+ attachments: (job.attachments || []).map((a) => ({
886
+ type: a.type === 'image' ? 'image' : 'link',
887
+ url: String(a.url || '').slice(0, 2048),
888
+ text: String(a.text || '').slice(0, 120),
889
+ })).filter((a) => a.url),
800
890
  };
801
891
  }),
802
892
  });
@@ -831,6 +921,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
831
921
  }
832
922
  if (req.method === 'GET' && url.pathname === '/device/config') {
833
923
  const voice = transcriptionState(config);
924
+ const firmwareSummary = firmware.summary();
834
925
  // Firmware creates sessions in the default project; the full project
835
926
  // picker lives on the desktop. Keep room for twelve bounded bitmaps.
836
927
  const payload = {
@@ -839,11 +930,16 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
839
930
  projects: config.projects.filter(({ id }) => id === config.defaultProject).map(({ id, label }) => ({ id, label: devicePreview(label, 64) })),
840
931
  defaultProject: config.defaultProject,
841
932
  voice: { mode: voice.mode, available: voice.available },
933
+ firmwareUrl: firmwareSummary.otaUrl,
934
+ firmwareVersion: firmwareSummary.current?.version || '',
935
+ firmwareSha256: firmwareSummary.current?.sha256 || '',
936
+ firmwareSize: firmwareSummary.current?.size || 0,
842
937
  board: { events: boardLink.subscription(pairing.deviceIdForToken(token), config.pluginsRuntime, config) },
843
938
  // 桥接器重启会丢掉板侧命令清单:让板子看到 announce 后重新上报。
844
939
  announce: !boardLink.hasCatalog(pairing.deviceIdForToken(token)),
845
940
  };
846
941
  if (Buffer.byteLength(JSON.stringify(payload)) > 32 * 1024) throw new Error('码得配置超过 32 KB,请检查助手元数据');
942
+ console.error(`[trace] fw-config url=${payload.firmwareUrl || '-'} ver=${payload.firmwareVersion || '-'} sha=${(payload.firmwareSha256 || '-').slice(0, 12)} size=${payload.firmwareSize || 0} ${new Date().toISOString()}`);
847
943
  if (process.env.VIBE_UI_DIAGNOSTICS === '1') console.info('[device-config]', deviceId, 'announce=', payload.announce,
848
944
  'providers=', payload.providers.map((p, i) => `${i}:${p.id}${p.sprite ? '@' + p.sprite.version : ''}`).join(' ') || 'none');
849
945
  deviceSend(res, 200, payload);
@@ -862,6 +958,8 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
862
958
  deviceSend(res, 200, {
863
959
  version: plugin.sprite.version, canvas: plugin.sprite.canvas,
864
960
  layers: plugin.sprite.layers, ...(plugin.sprite.motion ? { motion: plugin.sprite.motion } : {}),
961
+ ...(plugin.sprite.emotions ? { emotions: plugin.sprite.emotions } : {}),
962
+ ...(plugin.sprite.actions ? { actions: plugin.sprite.actions } : {}),
865
963
  });
866
964
  return;
867
965
  }
@@ -874,6 +972,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
874
972
  const chunk = plugin?.sprite ? spriteChunk(plugin.sprite, spriteMatch[2],
875
973
  Number(url.searchParams.get('offset')) || 0,
876
974
  requested === undefined ? SPRITE_DEVICE_CHUNK : Math.min(requested, SPRITE_DEVICE_CHUNK)) : null;
975
+ console.error(`[trace] sprite ${spriteMatch[1]}/${spriteMatch[2]} off=${url.searchParams.get('offset') ?? 0} -> ${chunk ? chunk.length : -1}B ${new Date().toISOString()}`);
877
976
  if (process.env.VIBE_UI_DIAGNOSTICS) {
878
977
  console.log(`[device-sprite] ${spriteMatch[2]} offset=${url.searchParams.get('offset') ?? 0} len=${url.searchParams.get('len') ?? '-'} -> ${chunk ? chunk.length : -1}B`);
879
978
  }
@@ -1024,11 +1123,13 @@ if (isEntry) {
1024
1123
  }
1025
1124
  const config = loadConfig();
1026
1125
  config.pluginsRuntime = await loadConfiguredPlugins(config);
1126
+ const pluginsWatch = watchPlugins(config);
1027
1127
  const app = createApp(config, { enableUsb: true });
1028
1128
  let shuttingDown = false;
1029
1129
  const shutdown = () => {
1030
1130
  if (shuttingDown) return;
1031
1131
  shuttingDown = true;
1132
+ pluginsWatch.close();
1032
1133
  app.close().then(() => { process.exitCode = 0; }).catch((error) => {
1033
1134
  console.error(error.message);
1034
1135
  process.exitCode = 1;
package/src/sessions.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { builtinAgent } from './agents/index.mjs';
5
4
 
6
5
  export const VIBE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
6
  export const CLI_SESSION_ID = /^[A-Za-z0-9_-]{6,128}$/;
@@ -33,7 +32,7 @@ function firstTurnTitle(instruction) {
33
32
 
34
33
  export class SessionStore {
35
34
  constructor({ file, projects, defaultProject, defaultProvider, pluginsRuntime, agent }) {
36
- this.agent = agent || (pluginsRuntime ? (id) => pluginsRuntime.agent(id) : builtinAgent);
35
+ this.agent = agent || (pluginsRuntime ? (id) => pluginsRuntime.agent(id) : () => undefined);
37
36
  this.file = file;
38
37
  this.projects = projects;
39
38
  this.defaultProject = defaultProject;
@@ -25,6 +25,8 @@ const VOICE_TIMEOUT_MS = 150_000;
25
25
  const HELLO_TIMEOUT_MS = 7_000;
26
26
  const DISCOVERY_INTERVAL_MS = 2_000;
27
27
  const REJECTED_PORT_COOLDOWN_MS = 60_000;
28
+ // 没握手的端口(码得平时只把 USB 当日志口)不能一直占着,否则烧录和串口监视都打不开。
29
+ const IDLE_PORT_COOLDOWN_MS = 3_000;
28
30
  const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
29
31
  // Probe only the native ESP USB device and the WCH bridge verified on the
30
32
  // supported C3 receiver. A matching USB ID is a candidate, not a ready peer:
@@ -52,25 +54,45 @@ function limitedText(value, limit = 128) {
52
54
  return typeof value === 'string' && value.length <= limit && !/[\r\n\x00-\x1f\x7f]/.test(value);
53
55
  }
54
56
 
55
- export function parseMacUsbSerialPorts(output) {
56
- const ports = new Set();
57
+ function usbId(vendor, product) {
58
+ return `${vendor.toString(16)}:${product.toString(16)}`;
59
+ }
60
+
61
+ // 码得的原生 USB Serial/JTAG 不看波特率。对它跑 stty 会改 DTR/RTS,
62
+ // ESP32-S3 因此复位,正在用的 Wi-Fi 连接被拔掉。WCH 才是真串口,要设波特率。
63
+ export function usbSerialNeedsLineCoding(id) {
64
+ return id !== '303a:1001';
65
+ }
66
+
67
+ export function parseMacUsbSerialDevices(output) {
68
+ const devices = [];
69
+ const seen = new Set();
57
70
  const blocks = String(output).split(/(?=^[\s|]*\+-o [^\n]*<class IOUSBHostDevice,)/m);
58
71
  for (const block of blocks) {
59
72
  if (!block.includes('<class IOUSBHostDevice,')) continue;
60
73
  const vendor = block.match(/"idVendor"\s*=\s*(\d+)/);
61
74
  const product = block.match(/"idProduct"\s*=\s*(\d+)/);
62
- if (!supportedUsbDevice(Number(vendor?.[1]), Number(product?.[1]))) continue;
75
+ const vendorId = Number(vendor?.[1]);
76
+ const productId = Number(product?.[1]);
77
+ if (!supportedUsbDevice(vendorId, productId)) continue;
78
+ const id = usbId(vendorId, productId);
63
79
  for (const match of block.matchAll(/"IOCalloutDevice"\s*=\s*"(\/dev\/cu\.[A-Za-z0-9._-]+)"/g)) {
64
- ports.add(match[1]);
80
+ if (seen.has(match[1])) continue;
81
+ seen.add(match[1]);
82
+ devices.push({ path: match[1], id });
65
83
  }
66
84
  }
67
- return [...ports];
85
+ return devices;
86
+ }
87
+
88
+ export function parseMacUsbSerialPorts(output) {
89
+ return parseMacUsbSerialDevices(output).map((device) => device.path);
68
90
  }
69
91
 
70
- export function linuxUsbSerialPorts({ sysTtyRoot = '/sys/class/tty', devRoot = '/dev' } = {}) {
71
- const ports = [];
92
+ export function linuxUsbSerialDevices({ sysTtyRoot = '/sys/class/tty', devRoot = '/dev' } = {}) {
93
+ const devices = [];
72
94
  let names;
73
- try { names = fs.readdirSync(sysTtyRoot); } catch { return ports; }
95
+ try { names = fs.readdirSync(sysTtyRoot); } catch { return devices; }
74
96
  for (const name of names) {
75
97
  if (!/^tty(?:ACM|USB)\d+$/.test(name)) continue;
76
98
  let directory;
@@ -82,29 +104,37 @@ export function linuxUsbSerialPorts({ sysTtyRoot = '/sys/class/tty', devRoot = '
82
104
  const product = Number.parseInt(fs.readFileSync(path.join(directory, 'idProduct'), 'utf8'), 16);
83
105
  if (supportedUsbDevice(vendor, product)) {
84
106
  const device = path.join(devRoot, name);
85
- if (fs.existsSync(device)) ports.push(device);
107
+ if (fs.existsSync(device)) devices.push({ path: device, id: usbId(vendor, product) });
86
108
  break;
87
109
  }
88
110
  } catch { /* Walk to the USB device parent. */ }
89
111
  directory = path.dirname(directory);
90
112
  }
91
113
  }
92
- return ports;
114
+ return devices;
93
115
  }
94
116
 
95
- export async function discoverUsbSerialPorts(platform = process.platform) {
117
+ export function linuxUsbSerialPorts(options) {
118
+ return linuxUsbSerialDevices(options).map((device) => device.path);
119
+ }
120
+
121
+ export async function discoverUsbSerialDevices(platform = process.platform) {
96
122
  if (platform === 'darwin') {
97
123
  try {
98
124
  const result = await execFileAsync('ioreg', ['-p', 'IOService', '-r', '-c', 'IOUSBHostDevice', '-l', '-t', '-w0'], {
99
125
  encoding: 'utf8', timeout: 3000, maxBuffer: 8 * 1024 * 1024,
100
126
  });
101
- return parseMacUsbSerialPorts(result.stdout).filter((port) => fs.existsSync(port));
127
+ return parseMacUsbSerialDevices(result.stdout).filter((device) => fs.existsSync(device.path));
102
128
  } catch { return []; }
103
129
  }
104
- if (platform === 'linux') return linuxUsbSerialPorts();
130
+ if (platform === 'linux') return linuxUsbSerialDevices();
105
131
  return [];
106
132
  }
107
133
 
134
+ export async function discoverUsbSerialPorts(platform = process.platform) {
135
+ return (await discoverUsbSerialDevices(platform)).map((device) => device.path);
136
+ }
137
+
108
138
  async function configureSerial(port, platform = process.platform) {
109
139
  if (!['darwin', 'linux'].includes(platform)) return;
110
140
  const settings = ['921600', 'raw', '-echo', 'cs8', '-parenb', '-cstopb',
@@ -228,6 +258,9 @@ export class UsbReceiverProtocol {
228
258
  // 会插进来。只有「看着像帧」的坏行才算协议错误;日志必须原样放行,
229
259
  // 否则板子一打日志就会掐掉正在传输的响应(素材分片下载失败即此因)。
230
260
  if (!line.startsWith('{')) {
261
+ if (/ESP-ROM|rst:0x|boot:0x/.test(line)) {
262
+ console.error(`[trace] usb-boot ${new Date().toISOString()} ${line.slice(0, 180)}`);
263
+ }
231
264
  console.error(`[usb-link] log: ${line.slice(0, 240)}`);
232
265
  return;
233
266
  }
@@ -541,16 +574,17 @@ export class UsbReceiver {
541
574
  if (this.polling || this.stopped || this.state.status === 'disabled') return;
542
575
  this.polling = true;
543
576
  try {
544
- const candidates = this.portPath ? [this.portPath] : await discoverUsbSerialPorts();
577
+ const devices = this.portPath ? [{ path: this.portPath, id: '' }] : await discoverUsbSerialDevices();
545
578
  if (this.stopped) return;
546
- if (!candidates.length) {
579
+ if (!devices.length) {
547
580
  if (!this.links.size) {
548
581
  this.state.port = this.portPath;
549
582
  this.state.error = this.portPath ? 'USB 设备未连接' : '未发现受支持的 USB 设备;可设置 VIBE_USB_PORT';
550
583
  }
551
584
  return;
552
585
  }
553
- for (const port of candidates) {
586
+ for (const device of devices) {
587
+ const port = device.path;
554
588
  if (this.stopped) break;
555
589
  if (this.links.has(port)) continue;
556
590
  if (Date.now() < (this.cooldown.get(port) || 0)) continue;
@@ -562,9 +596,12 @@ export class UsbReceiver {
562
596
  const fd = fs.openSync(port, fs.constants.O_RDWR | fs.constants.O_NONBLOCK |
563
597
  (fs.constants.O_NOCTTY || 0));
564
598
  unregisteredFd = fd;
599
+ const lineCoding = usbSerialNeedsLineCoding(device.id);
600
+ console.error(`[trace] usb-open port=${port} id=${device.id || '-'} stty=${lineCoding} ${new Date().toISOString()}`);
565
601
  // WCH resets the baud rate when its final handle closes. Keep our
566
602
  // handle open while stty opens, configures and closes its own handle.
567
- await configureSerial(port);
603
+ // 码得自己的口跳过这一步,避免插入 USB 时把 Wi-Fi 连接复位掉。
604
+ if (lineCoding) await configureSerial(port);
568
605
  if (this.stopped) break;
569
606
  const link = { fd, port, helloTimer: null, inputTimer: null,
570
607
  protocol: null, writeQueue: Promise.resolve() };
@@ -624,6 +661,10 @@ export class UsbReceiver {
624
661
  refreshAuthenticatedVoice: this.refreshAuthenticatedVoice,
625
662
  });
626
663
  const inputBuffer = Buffer.allocUnsafe(4096);
664
+ // macOS 的 CDC 驱动偶发返回 0 字节读(并非拔线)。连续空读超过
665
+ // ~1s 才判死,否则一次抖动就把链路拆掉重连(实机表现为「时不时断开」)。
666
+ const ZERO_READ_LIMIT = 50;
667
+ link.zeroReads = 0;
627
668
  const readAvailable = () => {
628
669
  if (this.links.get(port) !== link) return;
629
670
  // TTY drivers may return only 1 KiB even when more data is ready.
@@ -631,11 +672,16 @@ export class UsbReceiver {
631
672
  for (let reads = 0; reads < 64; reads += 1) {
632
673
  try {
633
674
  const length = fs.readSync(fd, inputBuffer, 0, inputBuffer.length, null);
634
- if (length > 0) link.protocol.feed(inputBuffer.subarray(0, length));
635
- else { this.disconnect(link, 'USB 设备已断开'); return; }
675
+ if (length > 0) {
676
+ link.zeroReads = 0;
677
+ link.protocol.feed(inputBuffer.subarray(0, length));
678
+ } else if (this.links.get(port) === link && ++link.zeroReads >= ZERO_READ_LIMIT) {
679
+ this.disconnect(link, 'USB 设备已断开');
680
+ return;
681
+ } else break;
636
682
  } catch (error) {
637
683
  if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') break;
638
- this.disconnect(link, 'USB 设备已断开');
684
+ this.disconnect(link, `USB 读取错误 ${error.code || error.message}`);
639
685
  return;
640
686
  }
641
687
  }
@@ -647,12 +693,11 @@ export class UsbReceiver {
647
693
  link.inputTimer = setTimeout(readAvailable, 20);
648
694
  link.inputTimer.unref?.();
649
695
  link.helloTimer = setTimeout(() => {
650
- if (this.link === link && this.state.status !== 'connected') {
651
- // A round display only says hello while Vibe is open. Keep this
652
- // nonblocking reader alive so opening Vibe is detected at once,
653
- // without reopening/resetting the board or delaying other ports.
654
- this.state.error = 'USB 已连接,等待打开码得应用的 USB 直连';
655
- }
696
+ if (this.links.get(port) !== link || this.state.status === 'connected') return;
697
+ // 没握手就先放开串口,烧录可以插空占用。冷却只有几秒,板子每 3 秒
698
+ // 发一次 hello,下一轮打开就能接上,不用对准打开的时刻。
699
+ this.cooldown.set(port, Date.now() + IDLE_PORT_COOLDOWN_MS);
700
+ this.disconnect(link, '未握手,已暂时释放串口,马上会再试');
656
701
  }, HELLO_TIMEOUT_MS);
657
702
  link.helloTimer.unref?.();
658
703
  } catch {
@@ -672,6 +717,9 @@ export class UsbReceiver {
672
717
 
673
718
  disconnect(link, error = '') {
674
719
  if (this.links.get(link.port) !== link) return;
720
+ // 断开原因必须留痕:连接状态只存最后一条 error,没有这行日志就查不到
721
+ // 「时不时断开」到底是空读判死、写超时还是心跳超时。
722
+ console.error(`[usb-link] disconnect port=${link.port} reason=${error || '未指定'} lastSeen=${this.state.lastSeen || 'never'}`);
675
723
  this.links.delete(link.port);
676
724
  clearTimeout(link.helloTimer);
677
725
  link.protocol?.dispose();
@@ -1,94 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { builtinAgentIcons } from '../agent-icons.mjs';
5
- import { probeCli, runCli, gitState } from './shared.mjs';
6
- import { jsonLineEvents, toolActivity } from './events.mjs';
7
-
8
- // `codex login status` 只认 ChatGPT/OAuth 登录。当用户在 ~/.codex/config.toml 里配置了
9
- // 自定义 model_provider(experimental_bearer_token 或 env_key 指向的环境变量)时,
10
- // CLI 实际可用但登录状态仍显示未登录。这里补充识别这类鉴权方式,避免误报“请先登录”。
11
- function hasCustomProviderAuth() {
12
- try {
13
- const config = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf8');
14
- const bearer = config.match(/experimental_bearer_token\s*=\s*"([^"]+)"/);
15
- if (bearer?.[1]) return true;
16
- const envKey = config.match(/env_key\s*=\s*"([^"]+)"/);
17
- return Boolean(envKey?.[1] && process.env[envKey[1]]);
18
- } catch {
19
- return false;
20
- }
21
- }
22
-
23
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
-
25
- export function createEvents(onProgress, onSession) {
26
- return jsonLineEvents((event, state) => {
27
- const sessionId = event.type === 'thread.started' ? event.thread_id : undefined;
28
- if (event.item?.type === 'agent_message' && event.type === 'item.completed' && typeof event.item.text === 'string') state.lastMessage = event.item.text;
29
- if (event.type === 'turn.failed' || event.type === 'error') state.terminalError = '编程工具报告任务失败';
30
- let activity = '';
31
- if (event.type === 'turn.started') activity = '正在分析任务';
32
- if (event.type === 'turn.completed') activity = '正在整理结果';
33
- if (event.type.startsWith('item.')) {
34
- const completed = event.type === 'item.completed';
35
- switch (event.item?.type) {
36
- case 'command_execution': activity = toolActivity('command', completed); break;
37
- case 'file_change': activity = toolActivity('edit', completed); break;
38
- case 'web_search': activity = completed ? '资料检索完成' : '正在查找资料'; break;
39
- case 'mcp_tool_call': case 'collab_tool_call': activity = toolActivity('', completed); break;
40
- case 'todo_list': if (completed) activity = '已更新任务步骤'; break;
41
- case 'agent_message': if (completed) activity = '正在整理答复'; break;
42
- }
43
- }
44
- return { sessionId, activity };
45
- }, onProgress, onSession);
46
- }
47
-
48
- const adapter = {
49
- label: 'Codex', createEvents,
50
- command({ cwd, instruction, config, model, sessionId }) {
51
- const args = ['exec', '--json', '--sandbox', 'workspace-write', '-C', cwd];
52
- if (sessionId) args.push('resume');
53
- const selected = model ?? config.codexModel;
54
- if (selected) args.push('-m', selected);
55
- if (sessionId) args.push(sessionId);
56
- args.push('-');
57
- return { command: 'codex', args, stdin: instruction };
58
- },
59
- classifyExit({ job, project, stderr, output, startingGitSnapshot }) {
60
- const activeWriter = /Failed to create session:\s*thread-store conflict:\s*thread [A-Za-z0-9_-]{6,128} already has an active writer/;
61
- if (job.sessionId && activeWriter.test(`${stderr}\n${output.rawTail}`)) {
62
- let unchanged = false;
63
- try { unchanged = gitState(project.path).snapshot === startingGitSnapshot; }
64
- catch { /* An unreadable or changed repository cannot be retried safely. */ }
65
- if (!unchanged) return new Error('Codex 对话正被使用,项目文件也发生变化。请先检查改动。');
66
- const error = new Error('此 Codex 对话正在另一窗口或进程使用;结束后再次确认即可继续');
67
- error.code = 'CODEX_SESSION_ACTIVE_WRITER';
68
- error.retryable = true;
69
- return error;
70
- }
71
- if (/model is not supported when using Codex|Model metadata for/.test(output.rawTail)) {
72
- return new Error('Codex 当前模型不能用于这个登录账号。请在电脑桥接页面的“编程助手模型”中选择该账号可用的模型。');
73
- }
74
- },
75
- };
76
-
77
- export default {
78
- apiVersion: 1, kind: 'coding-agent', id: 'codex', label: 'Codex', icon: builtinAgentIcons.fox,
79
- capabilities: { session: 'native', model: true, cancel: true, progress: true },
80
- model: { default: 'gpt-6-astra', required: true },
81
- sessionIdPattern: UUID,
82
- probe: () => {
83
- const state = probeCli('codex', ['login', 'status']);
84
- if (state.available || !hasCustomProviderAuth()) return state;
85
- return { available: true, mode: 'local_cli', reason: '' };
86
- },
87
- run: (context) => runCli(context, adapter),
88
- createEvents,
89
- syncJobState(job) {
90
- if (job.error === 'Codex 当前模型不能用于这个登录账号。请设置 VIBE_CODEX_MODEL 为该账号在 CLI 中可用的模型。') {
91
- job.error = 'Codex 当前模型不能用于这个登录账号。请在电脑桥接页面的“编程助手模型”中选择该账号可用的模型。';
92
- }
93
- },
94
- };
@@ -1,31 +0,0 @@
1
- import { builtinAgentIcons } from '../agent-icons.mjs';
2
- import { probeCli, runCli } from './shared.mjs';
3
- import { jsonLineEvents, assistantEvent, streamActivity } from './events.mjs';
4
-
5
- export function createEvents(onProgress, onSession) {
6
- return jsonLineEvents((event, state) => {
7
- assistantEvent(event, state, { append: true });
8
- return { sessionId: event.session_id, activity: streamActivity(event) };
9
- }, onProgress, onSession);
10
- }
11
-
12
- const adapter = {
13
- label: 'Cursor', createEvents,
14
- command({ cwd, instruction, model, sessionId }) {
15
- const args = ['--print', '--trust', '--output-format', 'stream-json', '--stream-partial-output', '--sandbox', 'enabled', '--auto-review', '--workspace', cwd];
16
- if (sessionId) args.push('--resume', sessionId);
17
- if (model) args.push('--model', model);
18
- args.push(instruction);
19
- return { command: 'cursor-agent', args };
20
- },
21
- };
22
-
23
- export default {
24
- apiVersion: 1, kind: 'coding-agent', id: 'cursor', label: 'Cursor', icon: builtinAgentIcons.rabbit,
25
- capabilities: { session: 'native', model: true, cancel: true, progress: true },
26
- model: { default: '', required: false },
27
- sessionIdPattern: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
28
- probe: () => probeCli('cursor-agent', ['status']),
29
- run: (context) => runCli(context, adapter),
30
- createEvents,
31
- };
@@ -1,7 +0,0 @@
1
- import codex from './codex.mjs';
2
- import cursor from './cursor.mjs';
3
- import qoder from './qoder.mjs';
4
- import workbuddy from './workbuddy.mjs';
5
-
6
- export const builtinAgents = Object.freeze([codex, cursor, qoder, workbuddy]);
7
- export function builtinAgent(id) { return builtinAgents.find((plugin) => plugin.id === id); }
@@ -1,34 +0,0 @@
1
- import { builtinAgentIcons } from '../agent-icons.mjs';
2
- import { probeCli, runCli } from './shared.mjs';
3
- import { jsonLineEvents, assistantEvent, streamActivity } from './events.mjs';
4
-
5
- export function createEvents(onProgress, onSession) {
6
- return jsonLineEvents((event, state) => {
7
- assistantEvent(event, state);
8
- return {
9
- sessionId: event.type === 'system' && event.subtype === 'init' ? event.session_id || event.data?.session_id : undefined,
10
- activity: streamActivity(event),
11
- };
12
- }, onProgress, onSession);
13
- }
14
-
15
- const adapter = {
16
- label: 'Qoder', createEvents,
17
- command({ cwd, instruction, model, sessionId }) {
18
- const args = ['--print', '--output-format', 'stream-json', '--permission-mode', 'accept_edits', '--cwd', cwd];
19
- if (sessionId) args.push('--session-id', sessionId);
20
- if (model) args.push('--model', model);
21
- args.push(instruction);
22
- return { command: 'qoder', args };
23
- },
24
- };
25
-
26
- export default {
27
- apiVersion: 1, kind: 'coding-agent', id: 'qoder', label: 'Qoder', icon: builtinAgentIcons.owl,
28
- capabilities: { session: 'native', model: true, cancel: true, progress: true },
29
- model: { default: '', required: false },
30
- sessionIdPattern: /^[A-Za-z0-9_-]{6,128}$/,
31
- probe: () => probeCli('qoder'),
32
- run: (context) => runCli(context, adapter),
33
- createEvents,
34
- };