made-bridge 0.2.1 → 0.2.2

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.
@@ -4,6 +4,8 @@
4
4
  "defaultProject": "demo",
5
5
  "defaultProvider": "codex",
6
6
  "codexModel": "gpt-6-astra",
7
+ "stuckIdleMinutes": 5,
8
+ "maxAutoRetries": 1,
7
9
  "projects": [
8
10
  {
9
11
  "id": "demo",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "made-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "码得(made)独立语音编程设备的电脑侧桥接器:工作台 API、设备接口、任务调度与插件运行时。一条命令启动,配合 ESP32 固件使用,源码在 github.com/jobop/made。",
5
5
  "type": "module",
6
6
  "license": "MIT",
Binary file
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Agent 形象素材(sprite/)读取与校验。
3
+ *
4
+ * 规范真源是主仓库 docs/SPRITES.md:agent 插件包可选携带 sprite/ 目录
5
+ * (manifest.json + 分层 PNG),桥接器把素材清单暴露给码得,素材本体走
6
+ * /device/sprite 分片拉取端点(单次 ≤192KB,兼容 USB 帧转发 256KB 上限)。
7
+ *
8
+ * 这里只做「读、验、切片」:不改素材、不缓存像素。清单结果按目录 mtime
9
+ * 缓存,避免每次 /device/config 都重算哈希。
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createHash } from 'node:crypto';
14
+
15
+ export const SPRITE_MAX_TOTAL_BYTES = 512 * 1024;
16
+ export const SPRITE_MAX_CHUNK = 192 * 1024;
17
+ export const SPRITE_MAX_FILE_BYTES = 256 * 1024;
18
+ const MANIFEST_MAX_BYTES = 16 * 1024;
19
+ const MAX_LAYERS = 8;
20
+ const MAX_FRAMES = 4;
21
+ // ≤20 字符:固件 SPIFFS 缓存对象名 = '/' + "s<hash6>_" + 文件名 + "~",
22
+ // 而 SPIFFS 只给 31 字节,20 是留了 1 字节余量的上限。
23
+ const FILE_NAME = /^[A-Za-z0-9_-]{1,16}\.png$/i;
24
+ const LAYER_ID = /^[A-Za-z][A-Za-z0-9_]{0,15}$/;
25
+ const RESERVED_IDS = new Set(['__proto__', 'constructor', 'prototype']);
26
+
27
+ class SpriteError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.preserveMessage = true;
31
+ }
32
+ }
33
+
34
+ const isInt = (value, min, max) => Number.isInteger(value) && value >= min && value <= max;
35
+
36
+ function readManifestFile(dir) {
37
+ const file = path.join(dir, 'manifest.json');
38
+ let text;
39
+ try {
40
+ text = fs.readFileSync(file, 'utf8');
41
+ } catch {
42
+ return null; // 包没带 sprite/ —— 合法,用固件兜底形象。
43
+ }
44
+ if (Buffer.byteLength(text) > MANIFEST_MAX_BYTES) throw new SpriteError('sprite/manifest.json 超过 16 KB');
45
+ try {
46
+ return JSON.parse(text);
47
+ } catch {
48
+ throw new SpriteError('sprite/manifest.json 不是合法 JSON');
49
+ }
50
+ }
51
+
52
+ function validateManifest(raw) {
53
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpriteError('manifest 必须是对象');
54
+ if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion'].includes(key))) {
55
+ throw new SpriteError('manifest 包含不支持的字段');
56
+ }
57
+ if (!isInt(raw.version, 1, 2147483647)) throw new SpriteError('manifest.version 必须是正整数');
58
+ const canvas = raw.canvas;
59
+ if (!canvas || typeof canvas !== 'object' || Array.isArray(canvas) ||
60
+ !isInt(canvas.w, 1, 512) || !isInt(canvas.h, 1, 512) ||
61
+ Object.keys(canvas).some((key) => !['w', 'h'].includes(key))) {
62
+ throw new SpriteError('manifest.canvas 必须是 {w, h}(各 1–512)');
63
+ }
64
+ if (!Array.isArray(raw.layers) || raw.layers.length < 1 || raw.layers.length > MAX_LAYERS) {
65
+ throw new SpriteError(`manifest.layers 需要 1–${MAX_LAYERS} 层`);
66
+ }
67
+ const seen = new Set();
68
+ const referenced = new Set();
69
+ for (const layer of raw.layers) {
70
+ if (!layer || typeof layer !== 'object' || Array.isArray(layer)) throw new SpriteError('layers 条目必须是对象');
71
+ const keys = Object.keys(layer);
72
+ if (keys.some((key) => !['id', 'file', 'frames', 'x', 'y', 'z', 'blink', 'talk', 'alert'].includes(key))) {
73
+ throw new SpriteError(`层 ${layer.id || '?'} 包含不支持的字段`);
74
+ }
75
+ if (typeof layer.id !== 'string' || !LAYER_ID.test(layer.id) || RESERVED_IDS.has(layer.id) || seen.has(layer.id)) {
76
+ throw new SpriteError('层 id 必须是 1–16 位字母开头的唯一标识');
77
+ }
78
+ seen.add(layer.id);
79
+ const files = layer.frames !== undefined ? layer.frames : [layer.file];
80
+ if (!Array.isArray(files) || files.length < 1 || files.length > MAX_FRAMES ||
81
+ files.some((file) => typeof file !== 'string' || !FILE_NAME.test(file))) {
82
+ throw new SpriteError(`层 ${layer.id} 需要单帧 file 或 1–${MAX_FRAMES} 帧 frames(PNG 文件名)`);
83
+ }
84
+ for (const file of files) {
85
+ if (referenced.has(file)) throw new SpriteError(`素材文件被多层引用:${file}`);
86
+ referenced.add(file);
87
+ }
88
+ if (!isInt(layer.x, 0, canvas.w) || !isInt(layer.y, 0, canvas.h) || !isInt(layer.z, 0, 100)) {
89
+ throw new SpriteError(`层 ${layer.id} 的 x/y/z 超出画布或范围`);
90
+ }
91
+ if (layer.blink !== undefined) {
92
+ const blink = layer.blink;
93
+ if (!blink || typeof blink !== 'object' || Array.isArray(blink) ||
94
+ !isInt(blink.period_ms, 200, 60000) || !isInt(blink.closed_ms, 20, 2000) ||
95
+ Object.keys(blink).some((key) => !['period_ms', 'closed_ms'].includes(key))) {
96
+ throw new SpriteError(`层 ${layer.id} 的 blink 必须是 {period_ms, closed_ms}`);
97
+ }
98
+ }
99
+ if (layer.talk !== undefined && typeof layer.talk !== 'boolean') {
100
+ throw new SpriteError(`层 ${layer.id} 的 talk 必须是布尔值`);
101
+ }
102
+ if (layer.alert !== undefined && layer.alert !== 'pulse') {
103
+ throw new SpriteError(`层 ${layer.id} 的 alert 只支持 "pulse"`);
104
+ }
105
+ }
106
+ if (raw.motion !== undefined) {
107
+ const motion = raw.motion;
108
+ if (!motion || typeof motion !== 'object' || Array.isArray(motion)) throw new SpriteError('motion 必须是对象');
109
+ const keys = Object.keys(motion);
110
+ if (keys.some((key) => !['breath', 'sway_deg', 'sway_period_ms'].includes(key))) {
111
+ throw new SpriteError('motion 包含不支持的字段');
112
+ }
113
+ if (motion.breath !== undefined) {
114
+ const breath = motion.breath;
115
+ if (!breath || typeof breath !== 'object' || Array.isArray(breath) ||
116
+ !isInt(breath.scale_pct, 100, 120) || !isInt(breath.period_ms, 500, 10000) ||
117
+ Object.keys(breath).some((key) => !['scale_pct', 'period_ms'].includes(key))) {
118
+ throw new SpriteError('motion.breath 必须是 {scale_pct, period_ms}');
119
+ }
120
+ }
121
+ if (motion.sway_deg !== undefined && !isInt(motion.sway_deg, 0, 15)) throw new SpriteError('motion.sway_deg 范围 0–15');
122
+ if (motion.sway_period_ms !== undefined && !isInt(motion.sway_period_ms, 200, 10000)) {
123
+ throw new SpriteError('motion.sway_period_ms 范围 200–10000');
124
+ }
125
+ }
126
+ return { version: raw.version, canvas: canvas, layers: raw.layers, motion: raw.motion ?? null };
127
+ }
128
+
129
+ /** 按目录内容 mtime 缓存读取结果;素材被替换时自动失效。
130
+ * 键要覆盖目录内每个文件:原地覆盖文件不会更新目录自身的 mtime。 */
131
+ const cache = new Map();
132
+
133
+ function spriteStamp(spriteDir) {
134
+ let stamp;
135
+ try {
136
+ stamp = `${fs.statSync(spriteDir).mtimeMs}`;
137
+ for (const name of fs.readdirSync(spriteDir).sort()) {
138
+ stamp += `|${name}:${fs.statSync(path.join(spriteDir, name)).mtimeMs}`;
139
+ }
140
+ } catch {
141
+ return null;
142
+ }
143
+ return stamp;
144
+ }
145
+
146
+ /**
147
+ * 读取并校验一个插件包的 sprite/ 目录。
148
+ * @returns 清单对象(含每个文件的 bytes/sha256);包没带 sprite/ 时返回 null;
149
+ * manifest 或素材不合法时抛错(调用方决定是拒绝加载还是降级兜底)。
150
+ */
151
+ export function readSprite(packageDir) {
152
+ const spriteDir = path.join(packageDir, 'sprite');
153
+ const stamp = spriteStamp(spriteDir);
154
+ if (!stamp) return null; // 目录不存在:没有素材,合法。
155
+ const hit = cache.get(spriteDir);
156
+ if (hit && hit.stamp === stamp) return hit.value;
157
+ const raw = readManifestFile(spriteDir);
158
+ if (!raw) return null;
159
+ const manifest = validateManifest(raw);
160
+ // 引用完整性:目录里的 PNG 必须全部被 manifest 引用,帮开发者抓住文件名拼写错误。
161
+ for (const name of fs.readdirSync(spriteDir)) {
162
+ if (!name.toLowerCase().endsWith('.png')) continue;
163
+ const referenced = manifest.layers.some((layer) =>
164
+ (layer.frames !== undefined ? layer.frames : [layer.file]).includes(name));
165
+ if (!referenced) throw new SpriteError(`sprite/${name} 未被 manifest 引用`);
166
+ }
167
+ let total = 0;
168
+ const files = [];
169
+ for (const layer of manifest.layers) {
170
+ for (const file of layer.frames !== undefined ? layer.frames : [layer.file]) {
171
+ const target = path.join(spriteDir, file);
172
+ let bytes;
173
+ try {
174
+ bytes = fs.statSync(target).size;
175
+ } catch {
176
+ throw new SpriteError(`manifest 引用的素材不存在:sprite/${file}`);
177
+ }
178
+ if (bytes > SPRITE_MAX_FILE_BYTES) throw new SpriteError(`素材单文件超过 256 KB:${file}`);
179
+ total += bytes;
180
+ if (total > SPRITE_MAX_TOTAL_BYTES) throw new SpriteError(`sprite/ 整包超过 512 KB`);
181
+ const data = fs.readFileSync(target);
182
+ files.push({ file, bytes, sha256: createHash('sha256').update(data).digest('hex') });
183
+ }
184
+ }
185
+ const value = Object.freeze({
186
+ dir: spriteDir, version: manifest.version, canvas: manifest.canvas,
187
+ layers: manifest.layers, motion: manifest.motion,
188
+ files: Object.freeze(files.map((file) => Object.freeze(file))),
189
+ });
190
+ cache.set(spriteDir, { stamp, value });
191
+ return value;
192
+ }
193
+
194
+ /**
195
+ * 从已校验的 sprite 目录切一片文件数据。越界与超长都会收敛到合法范围,
196
+ * 返回空 Buffer 表示 offset 已到文件尾(调用方据此判断下载完成)。
197
+ */
198
+ export function spriteChunk(sprite, file, offset, length) {
199
+ const entry = sprite?.files?.find((item) => item.file === file);
200
+ if (!entry) return null;
201
+ const start = Math.max(0, Math.min(Math.trunc(offset) || 0, entry.bytes));
202
+ const wanted = length === undefined || length === null ? SPRITE_MAX_CHUNK : Math.trunc(length) || 0;
203
+ const end = Math.min(entry.bytes, start + Math.max(1, Math.min(wanted, SPRITE_MAX_CHUNK)));
204
+ const data = fs.readFileSync(path.join(sprite.dir, file));
205
+ return data.subarray(start, end);
206
+ }
@@ -134,6 +134,22 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
134
134
  let stderr = '';
135
135
  let settled = false;
136
136
  let timer;
137
+ // 卡死看门狗:编程工具进程连续 idleMs 没有任何输出(进度、流式、stderr 都算)
138
+ // 视为卡死,杀掉进程树并把错误标记为可自动重跑,由任务车道决定是否重试。
139
+ const idleMs = Number(config?.stuckIdleMs)
140
+ || Math.max(1, Number(config?.stuckIdleMinutes) || 5) * 60 * 1000;
141
+ let idleTimer;
142
+ const bumpIdle = () => {
143
+ clearTimeout(idleTimer);
144
+ idleTimer = setTimeout(() => {
145
+ stop();
146
+ const error = new Error(`任务 ${Math.round(idleMs / 60000)} 分钟没有任何输出,疑似卡死:已终止编程工具进程`);
147
+ error.retryable = true;
148
+ error.autoRetry = true;
149
+ finish(error);
150
+ }, idleMs);
151
+ if (idleTimer.unref) idleTimer.unref();
152
+ };
137
153
  const stop = () => {
138
154
  if (child.pid && process.platform !== 'win32') {
139
155
  try { process.kill(-child.pid, 'SIGTERM'); } catch { /* Already exited. */ }
@@ -151,19 +167,28 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
151
167
  if (settled) return;
152
168
  settled = true;
153
169
  clearTimeout(timer);
170
+ clearTimeout(idleTimer);
154
171
  signal.removeEventListener('abort', stop);
155
172
  if (error) reject(error); else resolve(value);
156
173
  };
157
174
  signal.addEventListener('abort', stop, { once: true });
158
175
  if (signal.aborted) stop();
176
+ bumpIdle();
159
177
  timer = setTimeout(() => {
160
178
  stop();
161
- finish(new Error('任务运行超过 20 分钟,已停止'));
179
+ const error = new Error('任务运行超过 20 分钟,已停止');
180
+ error.retryable = true;
181
+ error.autoRetry = true;
182
+ finish(error);
162
183
  }, 20 * 60 * 1000);
163
184
  child.on('error', (error) => finish(error));
164
185
  child.stdout.setEncoding('utf8');
165
- child.stdout.on('data', (chunk) => events.write(chunk));
186
+ child.stdout.on('data', (chunk) => {
187
+ bumpIdle();
188
+ events.write(chunk);
189
+ });
166
190
  child.stderr.on('data', (chunk) => {
191
+ bumpIdle();
167
192
  stderr = `${stderr}${chunk.toString('utf8')}`.slice(-8000);
168
193
  });
169
194
  child.on('close', (code) => {
@@ -182,6 +182,9 @@ export function createBoardLink() {
182
182
  deviceIds() {
183
183
  return [...catalogs.keys()];
184
184
  },
185
+ hasCatalog(deviceId) {
186
+ return catalogs.has(deviceId);
187
+ },
185
188
  connect(deviceId, runtime, config, bridge) {
186
189
  if (!runtime?.boardPlugins) return;
187
190
  for (const plugin of runtime.boardPlugins()) {
@@ -0,0 +1,132 @@
1
+ // 板端截屏:固件把当前 LVGL 画面以 RGB565 裸帧 POST 上来,这里转 PNG 落盘。
2
+ // 触发端走桌面本地 API(POST /api/screenshot),向设备队列塞 debug.screenshot 命令。
3
+ // 仅调试用途:命令在固件能力清单里声明(esp_brookesia_app_vibe.cpp announceBoardCatalog)。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import zlib from 'node:zlib';
7
+
8
+ const MAX_WIDTH = 1024;
9
+ const MAX_HEIGHT = 1024;
10
+ const MAX_UPLOAD = 2 * 1024 * 1024;
11
+ const KEEP_FILES = 20;
12
+
13
+ const crcTable = (() => {
14
+ const table = new Uint32Array(256);
15
+ for (let n = 0; n < 256; n += 1) {
16
+ let c = n;
17
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
18
+ table[n] = c >>> 0;
19
+ }
20
+ return table;
21
+ })();
22
+
23
+ function crc32(buffer) {
24
+ let crc = 0xffffffff;
25
+ for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
26
+ return (crc ^ 0xffffffff) >>> 0;
27
+ }
28
+
29
+ function chunk(type, data) {
30
+ const length = Buffer.alloc(4);
31
+ length.writeUInt32BE(data.length, 0);
32
+ const typeBuffer = Buffer.from(type, 'ascii');
33
+ const crc = Buffer.alloc(4);
34
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
35
+ return Buffer.concat([length, typeBuffer, data, crc]);
36
+ }
37
+
38
+ // LVGL RGB565 小端:低字节 GGGBBBBB,高字节 RRRRRGGG;stride 为每行字节数(可能含行尾填充)。
39
+ export function rgb565ToPng(width, height, rgb565, stride = width * 2) {
40
+ if (!Number.isInteger(width) || width < 16 || width > MAX_WIDTH) throw new Error('宽度无效');
41
+ if (!Number.isInteger(height) || height < 16 || height > MAX_HEIGHT) throw new Error('高度无效');
42
+ if (!Buffer.isBuffer(rgb565)) throw new Error('像素数据无效');
43
+ if (!Number.isInteger(stride) || stride < width * 2 || stride > MAX_WIDTH * 4) throw new Error('行距无效');
44
+ const expected = stride * height;
45
+ if (rgb565.length !== expected) throw new Error(`数据长度不符:期望 ${expected} 字节,实得 ${rgb565.length}`);
46
+ const raw = Buffer.alloc(height * (1 + width * 3));
47
+ let out = 0;
48
+ for (let y = 0; y < height; y += 1) {
49
+ raw[out] = 0; // filter: none
50
+ out += 1;
51
+ const row = y * stride;
52
+ for (let x = 0; x < width; x += 1) {
53
+ const value = rgb565.readUInt16LE(row + x * 2);
54
+ const r5 = (value >> 11) & 0x1f;
55
+ const g6 = (value >> 5) & 0x3f;
56
+ const b5 = value & 0x1f;
57
+ raw[out] = (r5 << 3) | (r5 >> 2);
58
+ raw[out + 1] = (g6 << 2) | (g6 >> 4);
59
+ raw[out + 2] = (b5 << 3) | (b5 >> 2);
60
+ out += 3;
61
+ }
62
+ }
63
+ const ihdr = Buffer.alloc(13);
64
+ ihdr.writeUInt32BE(width, 0);
65
+ ihdr.writeUInt32BE(height, 4);
66
+ ihdr[8] = 8; // bit depth
67
+ ihdr[9] = 2; // color type: truecolor RGB
68
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
69
+ return Buffer.concat([
70
+ signature,
71
+ chunk('IHDR', ihdr),
72
+ chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
73
+ chunk('IEND', Buffer.alloc(0)),
74
+ ]);
75
+ }
76
+
77
+ export function validateScreenshotParams(query) {
78
+ const width = Number(query.get('w'));
79
+ const height = Number(query.get('h'));
80
+ const stride = query.has('stride') ? Number(query.get('stride')) : width * 2;
81
+ if (!Number.isInteger(width) || width < 16 || width > MAX_WIDTH) throw new Error('宽度无效');
82
+ if (!Number.isInteger(height) || height < 16 || height > MAX_HEIGHT) throw new Error('高度无效');
83
+ if (!Number.isInteger(stride) || stride < width * 2 || stride > MAX_WIDTH * 4) throw new Error('行距无效');
84
+ if ((query.get('fmt') || 'rgb565') !== 'rgb565') throw new Error('仅支持 rgb565 像素格式');
85
+ return { width, height, stride };
86
+ }
87
+
88
+ export function createScreenshotStore(dir) {
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ const sanitize = (deviceId) => String(deviceId).replace(/[^0-9a-f-]/gi, '').slice(0, 40) || 'unknown';
91
+ const prune = () => {
92
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.png')).sort();
93
+ for (const stale of files.slice(0, Math.max(0, files.length - KEEP_FILES))) {
94
+ try { fs.unlinkSync(path.join(dir, stale)); } catch { /* 并发清理可忽略 */ }
95
+ }
96
+ };
97
+ return {
98
+ save(deviceId, png) {
99
+ const stamp = Date.now();
100
+ const dev = sanitize(deviceId);
101
+ // 同毫秒多次保存不得互相覆盖(连拍/快速重试会撞毫秒,曾有测试因
102
+ // 27 次保存挤在几个毫秒里而假失败,实机连拍也会丢图)。
103
+ let name = `${stamp}-${dev}.png`;
104
+ for (let n = 1; fs.existsSync(path.join(dir, name)); n += 1) {
105
+ name = `${stamp}-${dev}-${n}.png`;
106
+ }
107
+ const file = path.join(dir, name);
108
+ fs.writeFileSync(file, png);
109
+ prune();
110
+ return file;
111
+ },
112
+ latest(afterMs) {
113
+ let best = null;
114
+ let bestTime = 0;
115
+ for (const name of fs.readdirSync(dir)) {
116
+ if (!name.endsWith('.png')) continue;
117
+ const stamp = Number(name.split('-')[0]);
118
+ if (!Number.isFinite(stamp) || stamp < afterMs) continue;
119
+ const file = path.join(dir, name);
120
+ let mtime = 0;
121
+ try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
122
+ if (mtime > bestTime) {
123
+ best = file;
124
+ bestTime = mtime;
125
+ }
126
+ }
127
+ return best;
128
+ },
129
+ };
130
+ }
131
+
132
+ export { MAX_UPLOAD };
package/src/config.mjs CHANGED
@@ -133,7 +133,9 @@ export function loadConfig(env = process.env) {
133
133
  }
134
134
  const port = Number(raw.port || 8787);
135
135
  const devicePort = Number(raw.devicePort || 8788);
136
- if (![port, devicePort].every((p) => Number.isInteger(p) && p > 0 && p < 65536)) {
136
+ // UDP 发现端口:默认与官方端口一致;跑第二个实例(开发/测试)时必须错开。
137
+ const discoveryPort = Number(env.VIBE_DISCOVERY_PORT || raw.discoveryPort || 8789);
138
+ if (![port, devicePort, discoveryPort].every((p) => Number.isInteger(p) && p > 0 && p < 65536)) {
137
139
  throw new Error('端口号必须在 1 到 65535 之间');
138
140
  }
139
141
  // 同时执行的会话数。同一会话恒为串行(编程工具按会话线程加写锁,并行必然冲突),
@@ -142,6 +144,16 @@ export function loadConfig(env = process.env) {
142
144
  if (!Number.isInteger(maxParallelJobs) || maxParallelJobs < 1 || maxParallelJobs > 4) {
143
145
  throw new Error('并行任务数必须是 1 到 4 之间的整数');
144
146
  }
147
+ // 卡死自愈:任务连续 N 分钟没有任何输出(进度/流式/stderr 都算)视为卡死,
148
+ // 桥接器杀掉编程工具进程并自动重跑,最多 maxAutoRetries 次。
149
+ const stuckIdleMinutes = Number(env.VIBE_STUCK_IDLE_MINUTES ?? raw.stuckIdleMinutes ?? 5);
150
+ if (!Number.isInteger(stuckIdleMinutes) || stuckIdleMinutes < 1 || stuckIdleMinutes > 60) {
151
+ throw new Error('卡死判定时长必须是 1 到 60 之间的整数(分钟)');
152
+ }
153
+ const maxAutoRetries = Number(env.VIBE_MAX_AUTO_RETRIES ?? raw.maxAutoRetries ?? 1);
154
+ if (!Number.isInteger(maxAutoRetries) || maxAutoRetries < 0 || maxAutoRetries > 3) {
155
+ throw new Error('卡死自动重跑次数必须是 0 到 3 之间的整数');
156
+ }
145
157
  const stateDir = path.resolve(env.VIBE_STATE_DIR
146
158
  || (hasUserConfig ? path.join(path.dirname(configPath), 'state') : dataHome(env)));
147
159
  const usbPort = validateUsbPort(env.VIBE_USB_PORT || '');
@@ -172,7 +184,10 @@ export function loadConfig(env = process.env) {
172
184
  defaultProvider,
173
185
  port,
174
186
  devicePort,
187
+ discoveryPort,
175
188
  maxParallelJobs,
189
+ stuckIdleMinutes,
190
+ maxAutoRetries,
176
191
  codexModel: models.codex,
177
192
  models,
178
193
  asrMode,
package/src/jobs.mjs CHANGED
@@ -11,7 +11,7 @@ function now() {
11
11
  }
12
12
 
13
13
  export class JobStore {
14
- constructor({ file, projects, defaultProject, defaultProvider, workspaceRoot, maxParallel, run, providerAvailable, pluginsRuntime, hooks }) {
14
+ constructor({ file, projects, defaultProject, defaultProvider, workspaceRoot, maxParallel, maxAutoRetries, run, providerAvailable, pluginsRuntime, hooks }) {
15
15
  this.agent = pluginsRuntime ? (id) => pluginsRuntime.agent(id) : run.agent || builtinAgent;
16
16
  this.hooks = hooks || null;
17
17
  this.file = file;
@@ -24,6 +24,9 @@ export class JobStore {
24
24
  // 同时执行的会话数。缺省 1 = 完全串行(保持既有语义,便于测试与降级)。
25
25
  const lanes = Number(maxParallel);
26
26
  this.maxParallel = Number.isFinite(lanes) ? Math.min(Math.max(Math.trunc(lanes), 1), 4) : 1;
27
+ // 卡死自愈:看门狗杀掉无输出的编程工具进程后,同车道自动重跑的次数上限。
28
+ const retries = Number(maxAutoRetries);
29
+ this.maxAutoRetries = Number.isFinite(retries) ? Math.min(Math.max(Math.trunc(retries), 0), 3) : 1;
27
30
  // 正在占用车道的会话 ID。一个会话同时只会有一条指令在跑,所以按会话占位即可。
28
31
  this.lanes = new Set();
29
32
  this.sessions = new SessionStore({
@@ -344,6 +347,20 @@ export class JobStore {
344
347
  }
345
348
  } catch (error) {
346
349
  if (job.status === 'running') {
350
+ // 卡死自愈:看门狗杀掉无输出的编程工具进程后,同车道自动重跑,
351
+ // 最多 maxAutoRetries 次;重跑的是同一条指令(native 会话会 resume 同一 CLI 线程)。
352
+ const attempts = job.attempts || 0;
353
+ if (error?.autoRetry && attempts < this.maxAutoRetries) {
354
+ job.attempts = attempts + 1;
355
+ job.status = 'queued';
356
+ job.progress = '任务疑似卡死,已终止编程工具进程并自动重跑';
357
+ job.progressAt = now();
358
+ job.error = '';
359
+ job.updatedAt = now();
360
+ this.save();
361
+ this.hooks?.notify('retrying', job);
362
+ continue;
363
+ }
347
364
  const retryableConflict = error?.retryable === true;
348
365
  job.status = retryableConflict ? 'waiting_confirmation' : 'failed';
349
366
  job.retryPending = retryableConflict;
@@ -6,6 +6,7 @@ import { isPackageEntry, packageDirectories, readPackageSettings } from '../plug
6
6
  import { MODEL_ID } from '../model-settings.mjs';
7
7
  import { builtinAgents } from '../agents/index.mjs';
8
8
  import { normalizeAgentIcon } from '../agent-icons.mjs';
9
+ import { readSprite } from '../agent-sprites.mjs';
9
10
  import { builtinSpeechRecognizers } from '../speech/index.mjs';
10
11
 
11
12
  export const PLUGIN_API_VERSION = 1;
@@ -95,8 +96,16 @@ export async function loadConfiguredPlugins(config) {
95
96
  const exported = (await import(pathToFileURL(filename).href)).default;
96
97
  const bundle = Array.isArray(exported) ? exported : [exported];
97
98
  const pack = isPackageEntry(filename) ? readPackageSettings(path.dirname(filename)) : null;
99
+ const packageDir = isPackageEntry(filename) ? path.dirname(filename) : null;
98
100
  for (const p of bundle) {
99
101
  validatePlugin(p);
102
+ // 素材挂包目录:sprite/ 校验失败只降级(该助手用固件兜底形象),不阻断插件加载。
103
+ if (packageDir && p.kind === 'coding-agent') {
104
+ try { p.sprite = readSprite(packageDir) || undefined; } catch (error) {
105
+ console.warn(`插件 ${p.id}:${error.message},改用内置兜底形象`);
106
+ p.sprite = undefined;
107
+ }
108
+ }
100
109
  if (p.kind !== 'coding-agent' && p.kind !== 'board-plugin') throw new Error(`插件 ${p.id}:外部插件仅支持 coding-agent;语音识别请配置兼容接口的 URL、API Key 和模型名`);
101
110
  if (pack) {
102
111
  const mismatch = pack.id && pack.id !== p.id;
package/src/providers.mjs CHANGED
@@ -39,6 +39,8 @@ export function providerStates(config, locale = config.locale) {
39
39
  },
40
40
  model: plugin.capabilities.model && plugin.model
41
41
  ? { default: plugin.model.default, required: plugin.model.required } : null,
42
+ // 形象素材清单:码得按 version 决定是否分片重拉(docs/SPRITES.md)。
43
+ sprite: plugin.sprite ? { version: plugin.sprite.version, files: plugin.sprite.files } : null,
42
44
  canCancelRunning: plugin.capabilities.cancel,
43
45
  };
44
46
  });
package/src/server.mjs CHANGED
@@ -25,6 +25,8 @@ import { createPluginRegistry, loadConfiguredPlugins } from './plugins/registry.
25
25
  import { createEnhancement } from './plugins/enhance.mjs';
26
26
  import { listInstalled, listThemes, removePlugin, removeTheme, savePluginSettings, setPluginEnabled, themeExists } from './installed.mjs';
27
27
  import { createBoardLink } from './board-link.mjs';
28
+ import { spriteChunk } from './agent-sprites.mjs';
29
+ import { createScreenshotStore, rgb565ToPng, validateScreenshotParams, MAX_UPLOAD } from './board-screenshot.mjs';
28
30
  import { initializeSpeechSettings, saveSpeechSettings } from './speech-settings.mjs';
29
31
  import { builtinSpeechRecognizers } from './speech/index.mjs';
30
32
  const PAIRED_DISCOVERY_REQUEST = /^VIBE_DISCOVER_PAIRED_V2 ([0-9a-f]{12}) ([0-9a-f]{32})$/;
@@ -253,6 +255,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
253
255
  const usbForwardSecret = randomUUID();
254
256
  const pairing = new PairingStore(config.stateDir);
255
257
  const boardLink = createBoardLink();
258
+ const screenshots = createScreenshotStore(path.join(config.stateDir, 'screenshots'));
256
259
  // 增强运行时:回合管道与生命周期通知给 JobStore 用,bridge 句柄给插件上下文用。
257
260
  const enhance = createEnhancement(config);
258
261
  const tunnel = providedTunnel || new TunnelManager({
@@ -306,6 +309,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
306
309
  defaultProject: config.defaultProject,
307
310
  defaultProvider: config.defaultProvider,
308
311
  maxParallel: config.maxParallelJobs,
312
+ maxAutoRetries: config.maxAutoRetries,
309
313
  pluginsRuntime: config.pluginsRuntime,
310
314
  run: run || createRunner(config),
311
315
  hooks: enhance,
@@ -545,6 +549,41 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
545
549
  send(res, 200, { command: boardLink.send(named, { name: payload.name, fields: payload.fields }) });
546
550
  return;
547
551
  }
552
+ // 板端截屏触发:向在线的码得下发 debug.screenshot 命令,等待固件回传并返回 PNG。
553
+ if (url.pathname === '/api/screenshot') {
554
+ const online = boardLink.deviceIds();
555
+ if (!online.length) throw new Error('码得未连接(板侧能力清单未上报)');
556
+ const before = Date.now() - 1500;
557
+ let queued = 0;
558
+ let lastError = '';
559
+ for (const deviceId of online) {
560
+ try {
561
+ boardLink.send(deviceId, { name: 'debug.screenshot' });
562
+ queued += 1;
563
+ } catch (error) {
564
+ lastError = error.message;
565
+ }
566
+ }
567
+ if (!queued) throw new Error(lastError || '命令未能下发');
568
+ const deadline = Date.now() + 15000;
569
+ while (Date.now() < deadline) {
570
+ const file = screenshots.latest(before);
571
+ if (file) {
572
+ const png = fs.readFileSync(file);
573
+ res.writeHead(200, {
574
+ 'Content-Type': 'image/png',
575
+ 'Content-Length': png.length,
576
+ 'X-Screenshot-File': path.basename(file),
577
+ 'Cache-Control': 'no-store',
578
+ 'X-Content-Type-Options': 'nosniff',
579
+ });
580
+ res.end(png);
581
+ return;
582
+ }
583
+ await new Promise((resolve) => setTimeout(resolve, 250));
584
+ }
585
+ throw new Error('码得未在 15 秒内回传截图(固件需要更新到支持 debug.screenshot 的版本)');
586
+ }
548
587
  const pairAction = url.pathname.match(/^\/api\/pair\/([0-9a-f]{12})\/(confirm|reject)$/);
549
588
  if (pairAction) {
550
589
  const { nonce } = await body(req);
@@ -696,6 +735,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
696
735
  return;
697
736
  }
698
737
  const deviceId = pairing.deviceIdForToken(token);
738
+ if (process.env.VIBE_UI_DIAGNOSTICS === '1') console.info('[device-req]', req.method, url.pathname);
699
739
  if (req.method === 'POST' && url.pathname === '/device/capabilities') {
700
740
  boardLink.setCatalog(deviceId, await body(req));
701
741
  boardLink.connect(deviceId, config.pluginsRuntime, config, enhance.bridge());
@@ -795,16 +835,62 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
795
835
  // picker lives on the desktop. Keep room for twelve bounded bitmaps.
796
836
  const payload = {
797
837
  locale: config.locale,
798
- providers: providerStates(config, deviceLocale).map(({ id, label, available, reason, icon, capabilities }) => ({ id, label, available, reason: devicePreview(reason, 120), icon, capabilities })),
838
+ providers: providerStates(config, deviceLocale).map(({ id, label, available, reason, icon, capabilities, sprite }) => ({ id, label, available, reason: devicePreview(reason, 120), icon, capabilities, sprite })),
799
839
  projects: config.projects.filter(({ id }) => id === config.defaultProject).map(({ id, label }) => ({ id, label: devicePreview(label, 64) })),
800
840
  defaultProject: config.defaultProject,
801
841
  voice: { mode: voice.mode, available: voice.available },
802
842
  board: { events: boardLink.subscription(pairing.deviceIdForToken(token), config.pluginsRuntime, config) },
843
+ // 桥接器重启会丢掉板侧命令清单:让板子看到 announce 后重新上报。
844
+ announce: !boardLink.hasCatalog(pairing.deviceIdForToken(token)),
803
845
  };
804
846
  if (Buffer.byteLength(JSON.stringify(payload)) > 32 * 1024) throw new Error('码得配置超过 32 KB,请检查助手元数据');
847
+ if (process.env.VIBE_UI_DIAGNOSTICS === '1') console.info('[device-config]', deviceId, 'announce=', payload.announce,
848
+ 'providers=', payload.providers.map((p, i) => `${i}:${p.id}${p.sprite ? '@' + p.sprite.version : ''}`).join(' ') || 'none');
805
849
  deviceSend(res, 200, payload);
806
850
  return;
807
851
  }
852
+ // 形象素材分片拉取:单次 ≤192KB,兼容 USB 帧转发 256KB 上限(docs/SPRITES.md)。
853
+ const spriteMatch = req.method === 'GET' &&
854
+ url.pathname.match(/^\/device\/sprite\/([a-z0-9][a-z0-9_-]{0,39})\/([A-Za-z0-9_-]+\.png|manifest\.json)$/);
855
+ if (spriteMatch) {
856
+ const plugin = config.pluginsRuntime.agent(spriteMatch[1]);
857
+ if (spriteMatch[2] === 'manifest.json') {
858
+ if (!plugin?.sprite) {
859
+ deviceSend(res, 404, { error: '素材不存在' });
860
+ return;
861
+ }
862
+ deviceSend(res, 200, {
863
+ version: plugin.sprite.version, canvas: plugin.sprite.canvas,
864
+ layers: plugin.sprite.layers, ...(plugin.sprite.motion ? { motion: plugin.sprite.motion } : {}),
865
+ });
866
+ return;
867
+ }
868
+ // 单次应答上限:固件按 offset 循环续拉整文件,这里故意切小。USB 链路
869
+ // (尤其叠着固件控制台输出时)对十几 KB 以上的帧偶发丢失;4KB 应答在
870
+ // 实测中始终送达,代价只是多几个来回。固件侧对下载失败已有 15s×5 次
871
+ // 重试(59f4043),此处小分片是为了把单次失败概率压到最低。
872
+ const SPRITE_DEVICE_CHUNK = 4 * 1024;
873
+ const requested = url.searchParams.has('len') ? Number(url.searchParams.get('len')) : undefined;
874
+ const chunk = plugin?.sprite ? spriteChunk(plugin.sprite, spriteMatch[2],
875
+ Number(url.searchParams.get('offset')) || 0,
876
+ requested === undefined ? SPRITE_DEVICE_CHUNK : Math.min(requested, SPRITE_DEVICE_CHUNK)) : null;
877
+ if (process.env.VIBE_UI_DIAGNOSTICS) {
878
+ console.log(`[device-sprite] ${spriteMatch[2]} offset=${url.searchParams.get('offset') ?? 0} len=${url.searchParams.get('len') ?? '-'} -> ${chunk ? chunk.length : -1}B`);
879
+ }
880
+ if (!chunk) {
881
+ deviceSend(res, 404, { error: '素材不存在' });
882
+ return;
883
+ }
884
+ res.writeHead(200, {
885
+ 'Content-Type': 'application/octet-stream',
886
+ 'Content-Length': chunk.length,
887
+ 'X-Sprite-Total': plugin.sprite.files.find((file) => file.file === spriteMatch[2])?.bytes ?? 0,
888
+ 'Cache-Control': 'no-store',
889
+ 'X-Content-Type-Options': 'nosniff',
890
+ });
891
+ res.end(chunk);
892
+ return;
893
+ }
808
894
  if (req.method === 'POST' && url.pathname === '/device/voice') {
809
895
  if (voiceBusy) {
810
896
  deviceSend(res, 429, { error: '另一段录音正在识别,请稍后再试' });
@@ -854,6 +940,15 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
854
940
  deviceSend(res, 200, publicJob(match[2] === 'confirm' ? store.confirm(match[1]) : store.cancel(match[1]), deviceLocale));
855
941
  return;
856
942
  }
943
+ // 板端截屏回传:固件把当前画面以 RGB565 裸帧 POST 上来(tools/shot.mjs 触发)。
944
+ if (req.method === 'POST' && url.pathname === '/device/screenshot') {
945
+ const { width, height, stride } = validateScreenshotParams(url.searchParams);
946
+ const raw = await binaryBody(req, MAX_UPLOAD);
947
+ const png = rgb565ToPng(width, height, raw, stride);
948
+ const saved = screenshots.save(deviceId, png);
949
+ deviceSend(res, 201, { saved: path.basename(saved) }, false);
950
+ return;
951
+ }
857
952
  deviceSend(res, 404, { error: '未找到接口' });
858
953
  } catch (error) {
859
954
  deviceSend(res, error.message === '电脑配对窗口未开启' ? 403 :
@@ -15,6 +15,9 @@ const MAX_CHUNK = 1024;
15
15
  // 8×1KB 的 base64 行大约 11KB,小于接收器 16KB 接收环。窗口内不等确认,
16
16
  // 避免 224KB 提示音被逐块往返拖过接收器 30 秒转发上限。
17
17
  const ACK_WINDOW = 8;
18
+ // USB 直连(码得)走 ESP 的 USB Serial/JTAG,固件 RX 环只有 4KB;连发 8 块
19
+ // 会灌爆环、丢字节,表现为大响应偶发超时。直连模式收紧到 2 块(约 2.8KB)。
20
+ const ACK_WINDOW_DIRECT = 2;
18
21
  const MAX_REQUEST = 1_100_000;
19
22
  const MAX_RESPONSE = 262_144;
20
23
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -169,6 +172,8 @@ export class UsbReceiverProtocol {
169
172
  this.locale = locale;
170
173
  this.line = Buffer.alloc(0);
171
174
  this.discardLine = false;
175
+ this.discardIsFrame = false;
176
+ this.ackWindow = ACK_WINDOW;
172
177
  this.helloReceived = false;
173
178
  this.peer = null;
174
179
  this.connectionId = randomBytes(16).toString('hex');
@@ -191,9 +196,14 @@ export class UsbReceiverProtocol {
191
196
  const segment = chunk.subarray(start, index);
192
197
  start = index + 1;
193
198
  if (this.discardLine || this.line.length + segment.length + 1 > MAX_LINE) {
199
+ // 超长行同样要区分日志与坏帧:固件的长日志行不该掐掉在途请求。
200
+ const looksLikeFrame = this.discardLine
201
+ ? this.discardIsFrame
202
+ : (this.line.length ? this.line[0] : segment[0]) === 0x7b;
203
+ if (looksLikeFrame) this.rejectCurrent(400, 'USB 帧过长');
194
204
  this.line = Buffer.alloc(0);
195
205
  this.discardLine = false;
196
- this.rejectCurrent(400, 'USB 帧过长');
206
+ this.discardIsFrame = false;
197
207
  continue;
198
208
  }
199
209
  const line = Buffer.concat([this.line, segment]).toString('utf8').replace(/\r$/, '');
@@ -203,6 +213,7 @@ export class UsbReceiverProtocol {
203
213
  const remainder = chunk.subarray(start);
204
214
  if (this.discardLine) return;
205
215
  if (this.line.length + remainder.length + 1 > MAX_LINE) {
216
+ this.discardIsFrame = (this.line.length ? this.line[0] : remainder[0]) === 0x7b;
206
217
  this.line = Buffer.alloc(0);
207
218
  this.discardLine = true;
208
219
  return;
@@ -213,6 +224,13 @@ export class UsbReceiverProtocol {
213
224
  handleLine(line) {
214
225
  let frame;
215
226
  try { frame = JSON.parse(line); } catch {
227
+ // 固件控制台日志和协议帧共用这条 USB 流(USB Serial/JTAG),日志行随时
228
+ // 会插进来。只有「看着像帧」的坏行才算协议错误;日志必须原样放行,
229
+ // 否则板子一打日志就会掐掉正在传输的响应(素材分片下载失败即此因)。
230
+ if (!line.startsWith('{')) {
231
+ console.error(`[usb-link] log: ${line.slice(0, 240)}`);
232
+ return;
233
+ }
216
234
  console.error(`[usb-link] unparseable: ${line.slice(0, 240)}`);
217
235
  this.rejectCurrent(400, 'USB 帧格式无效');
218
236
  return;
@@ -236,6 +254,7 @@ export class UsbReceiverProtocol {
236
254
  if (this.onHello(peer) === false) return;
237
255
  console.error(`[usb-link] hello: mode=${peer.mode} ${new Date().toISOString()}`);
238
256
  this.peer = peer;
257
+ this.ackWindow = peer.mode === 'direct' ? ACK_WINDOW_DIRECT : ACK_WINDOW;
239
258
  this.helloReceived = true;
240
259
  this.onActivity();
241
260
  // The receiver waits for this acknowledgement before forwarding HTTP.
@@ -399,11 +418,21 @@ export class UsbReceiverProtocol {
399
418
  length: bytes.length }));
400
419
  let sentChunks = 0;
401
420
  for (let offset = 0; offset < bytes.length; offset += MAX_CHUNK) {
402
- while (sentChunks - (this.ackCredits.get(id) || 0) >= ACK_WINDOW) {
403
- await this.waitForCredits(id, sentChunks - ACK_WINDOW + 1);
421
+ while (sentChunks - (this.ackCredits.get(id) || 0) >= this.ackWindow) {
422
+ await this.waitForCredits(id, sentChunks - this.ackWindow + 1);
423
+ }
424
+ const piece = bytes.subarray(offset, offset + MAX_CHUNK);
425
+ // 逐帧指纹:这是【所有】USB 响应分片(sessions/commands 轮询也会打),
426
+ // 不是素材专属日志。与固件 [sprite-rx] 对齐比对仅在素材下载窗口内有效,
427
+ // 能定位到具体哪一帧被改——别的请求别拿这里的指纹对账(2026-10-14 排障
428
+ // 曾因把 15B/42B 轮询响应当素材分片绕了远路)。
429
+ if (process.env.VIBE_UI_DIAGNOSTICS === '1') {
430
+ let fnv = 2166136261 >>> 0;
431
+ for (const byte of piece) fnv = Math.imul(fnv ^ byte, 16777619) >>> 0;
432
+ console.log(`[usb-tx] id=${id} off=${offset} len=${piece.length} fnv1a=${fnv.toString(16).padStart(8, '0')}`);
404
433
  }
405
434
  await this.sendLine(JSON.stringify({ type: 'data', id,
406
- chunk: bytes.subarray(offset, offset + MAX_CHUNK).toString('base64') }));
435
+ chunk: piece.toString('base64') }));
407
436
  sentChunks += 1;
408
437
  }
409
438
  if (sentChunks > (this.ackCredits.get(id) || 0)) await this.waitForCredits(id, sentChunks);