made-bridge 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "made-bridge",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "码得(made)独立语音编程设备的电脑侧桥接器:工作台 API、设备接口、任务调度与插件运行时。一条命令启动,配合 ESP32 固件使用,源码在 github.com/jobop/made。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -297,6 +297,28 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
297
297
  showToast(error.message || t('app.065'), true);
298
298
  } finally { state.busy.delete('rename-session'); render(); }
299
299
  }
300
+ /** 工具调用轨迹(仅桥接器网页端展示,不下发设备):可折叠列表。 */
301
+ function toolTrace(job) {
302
+ const calls = Array.isArray(job.toolCalls) ? job.toolCalls.filter((call) => call && safeString(call.name)) : [];
303
+ if (!calls.length) return null;
304
+ const details = document.createElement('details');
305
+ details.className = 'tool-trace';
306
+ const summary = document.createElement('summary');
307
+ summary.textContent = t('app.231', { v0: calls.length });
308
+ details.append(summary);
309
+ const list = text('div', 'tool-trace-list');
310
+ for (const call of [...calls].reverse()) {
311
+ const line = text('div', `tool-trace-line ${call.state === 'completed' ? 'done' : 'active'}`);
312
+ line.append(
313
+ text('span', 'tool-trace-mark', call.state === 'completed' ? '✓' : '…'),
314
+ text('span', 'tool-trace-name', safeString(call.name)),
315
+ );
316
+ if (safeString(call.detail)) line.append(text('code', 'tool-trace-detail', safeString(call.detail)));
317
+ list.append(line);
318
+ }
319
+ details.append(list);
320
+ return details;
321
+ }
300
322
  function renderJobs() {
301
323
  if (!$('jobList')) return;
302
324
  const list = $('jobList'); list.replaceChildren();
@@ -319,6 +341,7 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
319
341
  card.append(top, text('div', 'job-instruction', t('app.073', { v0: (job.instruction || t('app.072')) })));
320
342
  const answer = turnAnswer(job);
321
343
  card.append(text('p', `job-summary turn-answer ${job.error && !job.retryPending ? 'error' : ''}`, t('app.077', { v0: (providerName(job.provider)), v1: (safeString(answer)) })));
344
+ const trace = toolTrace(job); if (trace) card.append(trace);
322
345
  if (job.provider === 'codex' && job.retryPending) {
323
346
  card.append(text('p', 'job-retry-notice', t('app.078')));
324
347
  }
@@ -329,7 +352,7 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
329
352
  if (actions.children.length) { const bottom = text('div', 'job-bottom'); bottom.append(actions); card.append(bottom); }
330
353
  list.append(card);
331
354
  }
332
- /** 历史会话抽屉:当前任务的全部指令与回复,新的在上。数据来自 /api/state 的 jobs。 */
355
+ /** 历史会话抽屉:当前任务的全部指令与回复,从第一轮按时间往下排。数据来自 /api/state 的 jobs。 */
333
356
  function renderHistory() {
334
357
  const button = $('historyDrawerButton');
335
358
  const overlay = $('historyDrawerOverlay');
@@ -346,18 +369,20 @@ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations,
346
369
  list.append(text('p', 'history-empty', t('app.229')));
347
370
  return;
348
371
  }
372
+ // /api/state 的 jobs 是新→旧,反转成第一轮在最上面,编号也按时间顺序。
349
373
  [...jobs].reverse().forEach((job, index) => {
350
374
  const info = jobStatus(job);
351
375
  const turn = text('article', 'history-turn');
352
376
  const top = text('div', 'history-turn-top');
353
377
  top.append(
354
- text('span', 'turn-number', t('app.228', { v0: jobs.length - index })),
378
+ text('span', 'turn-number', t('app.228', { v0: index + 1 })),
355
379
  text('span', 'history-turn-time', formatDateTime(job.createdAt)),
356
380
  text('span', `job-status ${info.css}`, info.label),
357
381
  );
358
382
  turn.append(top, text('div', 'history-turn-instruction', job.instruction || t('app.072')));
359
383
  const answer = turnAnswer(job);
360
384
  if (answer) turn.append(text('p', `history-turn-answer ${job.error && !job.retryPending ? 'error' : ''}`, answer));
385
+ const trace = toolTrace(job); if (trace) turn.append(trace);
361
386
  list.append(turn);
362
387
  });
363
388
  }
@@ -905,8 +905,8 @@ export const messages = {
905
905
  "en": "History"
906
906
  },
907
907
  "app.227": {
908
- "zh-CN": "这个任务的全部指令与回复,新的在上面。",
909
- "en": "Every instruction and reply of this task, newest first."
908
+ "zh-CN": "这个任务的全部指令与回复,第一轮在最上面。",
909
+ "en": "Every instruction and reply of this task, oldest first."
910
910
  },
911
911
  "app.228": {
912
912
  "zh-CN": "第 {v0} 轮",
@@ -920,6 +920,14 @@ export const messages = {
920
920
  "zh-CN": "关闭",
921
921
  "en": "Close"
922
922
  },
923
+ "app.231": {
924
+ "zh-CN": "工具调用({v0})",
925
+ "en": "Tool calls ({v0})"
926
+ },
927
+ "app.232": {
928
+ "zh-CN": "进行中",
929
+ "en": "running"
930
+ },
923
931
  "tunnel.official.title": {
924
932
  "zh-CN": "官方入口",
925
933
  "en": "Official links"
package/public/styles.css CHANGED
@@ -461,6 +461,26 @@
461
461
  .history-turn-instruction { margin: 9px 0 0; font-size: var(--font-body); font-weight: 600; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
462
462
  .history-turn-answer { margin: 7px 0 0; color: var(--muted); font-size: var(--font-body); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
463
463
  .history-turn-answer.error { color: var(--red); }
464
+
465
+ /* 工具调用轨迹:仅桥接器网页端展示,可折叠;设备端不携带该数据 */
466
+ .tool-trace { margin: 2px 0 12px; border: 1px dashed var(--line); border-radius: 12px; background: var(--panel); }
467
+ .tool-trace summary { padding: 9px 13px; color: var(--muted); font-size: var(--font-small); font-weight: 700; cursor: pointer; user-select: none; list-style: none; display: flex; align-items: center; gap: 7px; }
468
+ .tool-trace summary::-webkit-details-marker { display: none; }
469
+ .tool-trace summary::after { content: '+'; margin-left: auto; color: var(--dim); font-size: 16px; }
470
+ .tool-trace[open] summary::after { content: '–'; }
471
+ .tool-trace summary:hover { color: var(--green-deep); }
472
+ .tool-trace-list { display: grid; gap: 6px; max-height: 320px; overflow-y: auto; padding: 4px 13px 12px; }
473
+ .tool-trace-line { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: var(--font-small); line-height: 1.5; }
474
+ .tool-trace-mark { flex: 0 0 auto; color: var(--green); font-weight: 700; }
475
+ .tool-trace-line.active .tool-trace-mark { color: var(--blue); animation: tool-trace-pulse 1.2s ease-in-out infinite; }
476
+ @keyframes tool-trace-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
477
+ .tool-trace-name { flex: 0 0 auto; color: var(--text); font-weight: 600; }
478
+ .tool-trace-detail { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px 8px; font-size: 13px; }
479
+ @media (max-width: 650px) {
480
+ .tool-trace-line { flex-wrap: wrap; }
481
+ .tool-trace-detail { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; }
482
+ }
483
+
464
484
  @media (max-width: 650px) {
465
485
  .history-head { padding: 18px 16px 13px; }
466
486
  .history-list { padding: 14px 16px 24px; }
@@ -52,24 +52,81 @@ function readManifestFile(dir) {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * 整帧状态动画表(可选)。素材明说「每个状态播哪几帧、多快、眨眼怎么插」,
57
+ * 固件照表播放,不推断素材结构。有这张表时 layers 可以省略。
58
+ * 返回归一化的 { [state]: { frames: [...], ms } } 或 null(没声明)。
59
+ */
60
+ function validateFramesTable(raw) {
61
+ if (raw.frames === undefined) return null;
62
+ const table = raw.frames;
63
+ if (!table || typeof table !== 'object' || Array.isArray(table)) {
64
+ throw new SpriteError('manifest.frames 必须是对象');
65
+ }
66
+ const allowed = new Set([...MOOD_NAMES, 'blink']);
67
+ const out = {};
68
+ let total = 0;
69
+ for (const [state, entry] of Object.entries(table)) {
70
+ if (!allowed.has(state)) throw new SpriteError(`frames 含未知状态:${state}`);
71
+ let files = null;
72
+ const extra = {};
73
+ if (Array.isArray(entry)) {
74
+ files = entry;
75
+ } else if (entry && typeof entry === 'object') {
76
+ const keys = Object.keys(entry);
77
+ if (keys.some((key) => !['frames', 'ms', 'period_ms', 'hold_ms'].includes(key))) {
78
+ throw new SpriteError(`frames.${state} 含不支持的字段`);
79
+ }
80
+ if (!Array.isArray(entry.frames)) throw new SpriteError(`frames.${state}.frames 必须是数组`);
81
+ files = entry.frames;
82
+ if (entry.ms !== undefined && !isInt(entry.ms, 30, 2000)) throw new SpriteError(`frames.${state}.ms 范围 30–2000`);
83
+ if (entry.ms !== undefined) extra.ms = entry.ms;
84
+ if (entry.period_ms !== undefined && !isInt(entry.period_ms, 500, 60000)) throw new SpriteError(`frames.${state}.period_ms 范围 500–60000`);
85
+ if (entry.period_ms !== undefined) extra.period_ms = entry.period_ms;
86
+ if (entry.hold_ms !== undefined && !isInt(entry.hold_ms, 30, 2000)) throw new SpriteError(`frames.${state}.hold_ms 范围 30–2000`);
87
+ if (entry.hold_ms !== undefined) extra.hold_ms = entry.hold_ms;
88
+ } else {
89
+ throw new SpriteError(`frames.${state} 必须是数组或对象`);
90
+ }
91
+ if (files.length < 1 || files.length > MAX_FRAMES ||
92
+ files.some((file) => typeof file !== 'string' || !FILE_NAME.test(file))) {
93
+ throw new SpriteError(`frames.${state} 需要 1–${MAX_FRAMES} 个 PNG 文件名`);
94
+ }
95
+ total += files.length;
96
+ if (total > 12) throw new SpriteError('frames 总帧数超过 12(设备内存上限)');
97
+ out[state] = { frames: [...files], ...extra };
98
+ }
99
+ if (Object.keys(out).length < 1 || !out.idle) throw new SpriteError('frames 至少要声明 idle 轨道');
100
+ if (raw.frame_ms !== undefined && !isInt(raw.frame_ms, 30, 2000)) {
101
+ throw new SpriteError('manifest.frame_ms 范围 30–2000');
102
+ }
103
+ return out;
104
+ }
105
+
55
106
  function validateManifest(raw) {
56
107
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new SpriteError('manifest 必须是对象');
57
- if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'motion', 'emotions', 'actions', 'commands'].includes(key))) {
108
+ if (Object.keys(raw).some((key) => !['version', 'canvas', 'layers', 'frames', 'frame_ms', 'motion', 'emotions', 'actions', 'commands'].includes(key))) {
58
109
  throw new SpriteError('manifest 包含不支持的字段');
59
110
  }
60
111
  if (!isInt(raw.version, 1, 2147483647)) throw new SpriteError('manifest.version 必须是正整数');
112
+ // 整帧状态动画(声明式):有 frames 表就不再需要 layers。固件只照表播放,
113
+ // 不去推断素材结构;老素材(layers 分层叠加)行为不变。
114
+ const frameTracks = validateFramesTable(raw);
61
115
  const canvas = raw.canvas;
62
116
  if (!canvas || typeof canvas !== 'object' || Array.isArray(canvas) ||
63
117
  !isInt(canvas.w, 1, 512) || !isInt(canvas.h, 1, 512) ||
64
118
  Object.keys(canvas).some((key) => !['w', 'h'].includes(key))) {
65
119
  throw new SpriteError('manifest.canvas 必须是 {w, h}(各 1–512)');
66
120
  }
67
- if (!Array.isArray(raw.layers) || raw.layers.length < 1 || raw.layers.length > MAX_LAYERS) {
68
- throw new SpriteError(`manifest.layers 需要 1–${MAX_LAYERS} 层`);
121
+ // 整帧模式(frames 表)不需要 layers;分层模式 layers 必填。
122
+ if (!frameTracks) {
123
+ if (!Array.isArray(raw.layers) || raw.layers.length < 1 || raw.layers.length > MAX_LAYERS) {
124
+ throw new SpriteError(`manifest.layers 需要 1–${MAX_LAYERS} 层`);
125
+ }
69
126
  }
70
127
  const seen = new Set();
71
128
  const referenced = new Set();
72
- for (const layer of raw.layers) {
129
+ if (Array.isArray(raw.layers)) for (const layer of raw.layers) {
73
130
  if (!layer || typeof layer !== 'object' || Array.isArray(layer)) throw new SpriteError('layers 条目必须是对象');
74
131
  const keys = Object.keys(layer);
75
132
  if (keys.some((key) => !['id', 'file', 'frames', 'x', 'y', 'z', 'blink', 'talk', 'alert'].includes(key))) {
@@ -225,6 +282,7 @@ function validateManifest(raw) {
225
282
  return {
226
283
  version: raw.version, canvas: canvas, layers: raw.layers, motion: raw.motion ?? null,
227
284
  emotions: raw.emotions ?? null, actions: raw.actions ?? null, commands: raw.commands ?? null,
285
+ frames: frameTracks ?? null, frame_ms: raw.frame_ms ?? null,
228
286
  };
229
287
  }
230
288
 
@@ -269,11 +327,19 @@ export function readSprite(packageDir) {
269
327
  if (action && typeof action.gif === 'string') gifReferencedHint.add(action.gif);
270
328
  }
271
329
  }
330
+ const declaredNames = new Set();
331
+ if (Array.isArray(manifest.layers)) {
332
+ for (const layer of manifest.layers) {
333
+ for (const file of layer.frames !== undefined ? layer.frames : [layer.file]) declaredNames.add(file);
334
+ }
335
+ }
336
+ if (manifest.frames) {
337
+ for (const track of Object.values(manifest.frames)) for (const file of track.frames) declaredNames.add(file);
338
+ }
272
339
  for (const name of fs.readdirSync(spriteDir)) {
273
340
  const lower = name.toLowerCase();
274
341
  if (lower.endsWith('.png')) {
275
- const referenced = manifest.layers.some((layer) =>
276
- (layer.frames !== undefined ? layer.frames : [layer.file]).includes(name));
342
+ const referenced = declaredNames.has(name);
277
343
  if (!referenced) {
278
344
  console.error(`[plugins] sprite 提示:${path.basename(spriteDir)}/${name} 未被 manifest 引用,不会被下发(建议删除旧素材省空间)`);
279
345
  }
@@ -283,8 +349,7 @@ export function readSprite(packageDir) {
283
349
  }
284
350
  let total = 0;
285
351
  const files = [];
286
- for (const layer of manifest.layers) {
287
- for (const file of layer.frames !== undefined ? layer.frames : [layer.file]) {
352
+ for (const file of declaredNames) {
288
353
  const target = path.join(spriteDir, file);
289
354
  let bytes;
290
355
  try {
@@ -297,7 +362,6 @@ export function readSprite(packageDir) {
297
362
  if (total > SPRITE_MAX_TOTAL_BYTES) throw new SpriteError(`sprite/ 整包超过 512 KB`);
298
363
  const data = fs.readFileSync(target);
299
364
  files.push({ file, bytes, sha256: createHash('sha256').update(data).digest('hex') });
300
- }
301
365
  }
302
366
  // GIF 动作引用的动画文件(多个动作共用同一文件只下发一份)
303
367
  if (manifest.actions) {
@@ -321,6 +385,7 @@ export function readSprite(packageDir) {
321
385
  const value = Object.freeze({
322
386
  dir: spriteDir, version: manifest.version, canvas: manifest.canvas,
323
387
  layers: manifest.layers, motion: manifest.motion, emotions: manifest.emotions,
388
+ frames: manifest.frames, frame_ms: manifest.frame_ms,
324
389
  actions: manifest.actions, commands: manifest.commands,
325
390
  files: Object.freeze(files.map((file) => Object.freeze(file))),
326
391
  });
@@ -36,19 +36,108 @@ export function streamActivity(event) {
36
36
  return '';
37
37
  }
38
38
 
39
+ // 工具调用捕获:从 CLI 原始 JSONL 事件里抽 {name, detail, state},只进桥接器
40
+ // 任务记录(网页端折叠展示),绝不混入设备进度文本。codex 的 item.* 事件与
41
+ // Claude 风格的 tool_call/assistant 事件都覆盖;认不出的形状安静返回 null。
42
+ const TOOL_DETAIL_LIMIT = 600;
43
+
44
+ function truncate(value, limit = TOOL_DETAIL_LIMIT) {
45
+ const text = String(value ?? '').trim();
46
+ return text.length <= limit ? text : `${text.slice(0, limit)}…(截断)`;
47
+ }
48
+
49
+ function toolDetail(parts) {
50
+ return truncate(parts.filter((part) => String(part || '').trim()).join(' '));
51
+ }
52
+
53
+ export function toolCallFromEvent(event) {
54
+ if (event.type === 'tool_call' && event.tool_call && typeof event.tool_call === 'object') {
55
+ const name = Object.keys(event.tool_call)[0] || '';
56
+ const args = event.tool_call[name];
57
+ return {
58
+ name: truncate(name, 120),
59
+ detail: toolDetail([name, typeof args === 'string' ? args : JSON.stringify(args)]),
60
+ state: event.subtype === 'completed' ? 'completed' : 'started',
61
+ key: '',
62
+ };
63
+ }
64
+ if (event.type === 'stream_event' && event.event?.type === 'content_block_start' &&
65
+ event.event.content_block?.type === 'tool_use') {
66
+ return {
67
+ name: truncate(event.event.content_block.name, 120),
68
+ detail: toolDetail([event.event.content_block.name,
69
+ JSON.stringify(event.event.content_block.input ?? '')]),
70
+ state: 'started', key: String(event.event.index ?? ''),
71
+ };
72
+ }
73
+ if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
74
+ const block = event.message.content.find((part) => part?.type === 'tool_use');
75
+ if (!block) return null;
76
+ return {
77
+ name: truncate(block.name, 120),
78
+ detail: toolDetail([block.name, JSON.stringify(block.input ?? '')]),
79
+ state: 'started', key: String(block.id ?? ''),
80
+ };
81
+ }
82
+ if (event.type !== 'item.started' && event.type !== 'item.completed') return null;
83
+ const item = event.item;
84
+ if (!item || typeof item !== 'object') return null;
85
+ const completed = event.type === 'item.completed';
86
+ switch (item.type) {
87
+ case 'command_execution':
88
+ return { name: '命令执行', detail: toolDetail([item.command, item.cwd]), state: completed ? 'completed' : 'started', key: String(item.id ?? '') };
89
+ case 'file_change':
90
+ return {
91
+ name: '文件修改',
92
+ detail: toolDetail(Array.isArray(item.changes)
93
+ ? item.changes.map((change) => `${change?.path || ''}${change?.kind ? ` (${change.kind})` : ''}`)
94
+ : []),
95
+ state: completed ? 'completed' : 'started', key: String(item.id ?? ''),
96
+ };
97
+ case 'mcp_tool_call': case 'collab_tool_call':
98
+ return { name: truncate(item.tool || '工具调用', 120), detail: toolDetail([item.tool, JSON.stringify(item.arguments ?? '')]), state: completed ? 'completed' : 'started', key: String(item.id ?? '') };
99
+ case 'web_search':
100
+ return { name: '网页搜索', detail: toolDetail([item.query]), state: completed ? 'completed' : 'started', key: String(item.id ?? '') };
101
+ case 'reasoning': case 'agent_message': case 'todo_list': case 'error':
102
+ return null;
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+
39
108
  // Only categorical activity is emitted by builtin adapters; raw tool arguments
40
- // and reasoning never become the device progress text.
109
+ // and reasoning never become the device progress text. Tool calls are drained
110
+ // separately (takeToolCalls) for the bridge UI only.
41
111
  export function jsonLineEvents(interpret, onProgress = () => {}, onSession = () => {}) {
42
112
  let pending = '';
43
113
  let rawTail = '';
44
114
  let discardingOversized = false;
45
115
  let sawEvent = false;
46
116
  const state = { lastMessage: '', assistantDeltas: '', finalResult: '', terminalError: '' };
117
+ // 半开工具调用:key → 在 calls 里的引用。completed 事件来时把对应条目置为
118
+ // 完成;对不上号(CLI 没给 id 或乱序)就当作独立条目追加,宁可多一行。
119
+ const open = new Map();
120
+ const calls = [];
121
+ const record = (call) => {
122
+ if (!call || !call.name) return;
123
+ if (call.state === 'completed' && call.key && open.has(call.key)) {
124
+ const entry = open.get(call.key);
125
+ entry.state = 'completed';
126
+ if (call.detail && call.detail.length > entry.detail.length) entry.detail = call.detail;
127
+ open.delete(call.key);
128
+ return;
129
+ }
130
+ const entry = { name: call.name, detail: call.detail || '', state: call.state, at: new Date().toISOString() };
131
+ calls.push(entry);
132
+ if (calls.length > 80) calls.splice(0, calls.length - 80);
133
+ if (entry.state === 'started' && call.key) open.set(call.key, entry);
134
+ };
47
135
  const consume = (line) => {
48
136
  let event;
49
137
  try { event = JSON.parse(line); } catch { return; }
50
138
  if (!event || typeof event !== 'object' || typeof event.type !== 'string') return;
51
139
  sawEvent = true;
140
+ record(toolCallFromEvent(event));
52
141
  const update = interpret(event, state) || {};
53
142
  if (typeof update.sessionId === 'string') onSession(update.sessionId);
54
143
  if (update.activity) onProgress(update.activity);
@@ -69,12 +158,17 @@ export function jsonLineEvents(interpret, onProgress = () => {}, onSession = ()
69
158
  },
70
159
  finish() {
71
160
  if (!discardingOversized && pending.trim()) consume(pending);
161
+ for (const entry of open.values()) entry.state = 'completed';
162
+ open.clear();
72
163
  return {
73
164
  result: (state.finalResult || state.lastMessage || (sawEvent ? '任务已执行,未返回文字摘要。' : rawTail.trim())).slice(-8000),
74
165
  hasAssistantText: Boolean(state.finalResult || state.lastMessage || !sawEvent),
75
166
  error: state.terminalError, rawTail,
76
167
  };
77
168
  },
169
+ takeToolCalls() {
170
+ return calls.splice(0, calls.length);
171
+ },
78
172
  };
79
173
  }
80
174
 
@@ -109,7 +109,7 @@ function changeFooter(state, message) {
109
109
  return state.changed ? `${message('改动文件:')}\n${state.changed}` : message('没有检测到代码改动。');
110
110
  }
111
111
 
112
- export function runCli({ job, project, signal, onProgress, onSession, config, model }, adapter) {
112
+ export function runCli({ job, project, signal, onProgress, onSession, onTool, config, model }, adapter) {
113
113
  const startingGitSnapshot = repositorySnapshot(project.path);
114
114
  const message = (value) => translateKnownMessage(value, config.locale);
115
115
  // 每轮都拼上权限边界:用户原文一字不改,边界文案按界面语言翻译。
@@ -139,8 +139,10 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
139
139
  let timer;
140
140
  // 卡死看门狗:编程工具进程连续 idleMs 没有任何输出(进度、流式、stderr 都算)
141
141
  // 视为卡死,杀掉进程树并把错误标记为可自动重跑,由任务车道决定是否重试。
142
+ // 默认跟 config.mjs 对齐到 15 分钟:CLI 工具的 stdout/stderr 都算活动,但素材
143
+ // 管线里外部生图是异步轮询,单张上限 300s 且期间不吐字,5 分钟会误杀。
142
144
  const idleMs = Number(config?.stuckIdleMs)
143
- || Math.max(1, Number(config?.stuckIdleMinutes) || 5) * 60 * 1000;
145
+ || Math.max(1, Number(config?.stuckIdleMinutes) || 15) * 60 * 1000;
144
146
  let idleTimer;
145
147
  const bumpIdle = () => {
146
148
  clearTimeout(idleTimer);
@@ -189,6 +191,9 @@ export function runCli({ job, project, signal, onProgress, onSession, config, mo
189
191
  child.stdout.on('data', (chunk) => {
190
192
  bumpIdle();
191
193
  events.write(chunk);
194
+ // 工具调用只走 onTool(桥接器任务记录,网页端折叠展示),不进设备进度文本。
195
+ const forwarded = events.takeToolCalls?.() || [];
196
+ for (const call of forwarded) onTool?.(call);
192
197
  });
193
198
  child.stderr.on('data', (chunk) => {
194
199
  bumpIdle();
package/src/config.mjs CHANGED
@@ -128,9 +128,11 @@ export function loadConfig(env = process.env) {
128
128
  };
129
129
  });
130
130
  const defaultProject = raw.defaultProject || projects[0].id;
131
- const defaultProvider = raw.defaultProvider || 'codex';
132
- if (!seen.has(defaultProject) || !PLUGIN_ID.test(defaultProvider)) {
133
- throw new Error('默认项目或默认编程工具不在配置中');
131
+ // 默认编程助手只是可选偏好(未指定 provider 时兜底),不再是必须存在的"主力":
132
+ // 可以留空——没有助手享有特权,入口处没指定就明确报错让用户选。
133
+ const defaultProvider = typeof raw.defaultProvider === 'string' && PLUGIN_ID.test(raw.defaultProvider) ? raw.defaultProvider : '';
134
+ if (!seen.has(defaultProject)) {
135
+ throw new Error('默认项目不在配置中');
134
136
  }
135
137
  const port = Number(raw.port || 8787);
136
138
  const devicePort = Number(raw.devicePort || 8788);
@@ -147,7 +149,11 @@ export function loadConfig(env = process.env) {
147
149
  }
148
150
  // 卡死自愈:任务连续 N 分钟没有任何输出(进度/流式/stderr 都算)视为卡死,
149
151
  // 桥接器杀掉编程工具进程并自动重跑,最多 maxAutoRetries 次。
150
- const stuckIdleMinutes = Number(env.VIBE_STUCK_IDLE_MINUTES ?? raw.stuckIdleMinutes ?? 5);
152
+ // 默认 15 分钟而不是 5 分钟:素材管线里一次外部生图是「提交异步任务 + 每 6s
153
+ // 轮询」,单张最长 300s(body 240s + sheet 300s ≈ 9 分钟),且轮询期间工具
154
+ // 不吐字。5 分钟会把一条正在正常跑的长任务当成卡死杀掉(2026-10-24 雪球
155
+ // jojo 任务被自己的看门狗反复打断实锤)。
156
+ const stuckIdleMinutes = Number(env.VIBE_STUCK_IDLE_MINUTES ?? raw.stuckIdleMinutes ?? 15);
151
157
  if (!Number.isInteger(stuckIdleMinutes) || stuckIdleMinutes < 1 || stuckIdleMinutes > 60) {
152
158
  throw new Error('卡死判定时长必须是 1 到 60 之间的整数(分钟)');
153
159
  }
package/src/i18n.mjs CHANGED
@@ -59,8 +59,9 @@ const messages = new Map([
59
59
  ['插件状态检查必须同步返回', 'The plugin status check must return synchronously'],
60
60
  ['任务已取消', 'Task cancelled'],
61
61
  ['默认编程助手未安装或已禁用', 'The default assistant is not installed or is disabled'],
62
- ['这是默认编程助手,不能停用;请先在设置里把默认编程助手切换为其他助手', 'This is the default assistant and cannot be disabled; switch the default assistant to another one in settings first'],
63
- ['这是默认编程助手,不能删除;请先在设置里把默认编程助手切换为其他助手', 'This is the default assistant and cannot be removed; switch the default assistant to another one in settings first'],
62
+ ['请先选择编程助手', 'Choose an assistant first'],
63
+ ['请先在电脑桥接器里选择编程助手', 'Choose an assistant in the desktop bridge first'],
64
+ ['请指定编程助手(createTask 需要显式 agentId)', 'Specify an assistant (createTask requires an explicit agentId)'],
64
65
  ['配对请求格式无效', 'Invalid pairing request'],
65
66
  ['设备名称无效', 'Invalid device name'],
66
67
  ['电脑配对窗口未开启', 'Pairing is not open on the computer'],
package/src/installed.mjs CHANGED
@@ -189,11 +189,12 @@ export async function setPluginEnabled(config, id, enabled) {
189
189
  if (!PLUGIN_ID.test(id || '')) throw fail('插件不存在');
190
190
  const installed = (await listInstalled(config)).plugins;
191
191
  if (!installed.some((plugin) => plugin.id === id)) throw fail('插件不存在');
192
- // 默认编程助手不能停用:loadConfiguredPlugins 会守卫 defaultProvider,
193
- // 但那里的报错不含操作指引,用户只会看到一句没头没尾的「未安装或已禁用」。
194
- // 在动作入口给出可操作的前置报错。
195
- if (!enabled && id === config.defaultProvider) {
196
- throw fail('这是默认编程助手,不能停用;请先在设置里把默认编程助手切换为其他助手');
192
+ // 默认助手只是可选偏好,不再是"主力":停用它时直接清掉默认指向,不拦人。
193
+ const previousDefault = config.defaultProvider || '';
194
+ let defaultCleared = false;
195
+ if (!enabled && id === previousDefault) {
196
+ config.defaultProvider = '';
197
+ defaultCleared = true;
197
198
  }
198
199
  const previous = [...(config.disabledPlugins || [])];
199
200
  const next = new Set(previous);
@@ -204,12 +205,17 @@ export async function setPluginEnabled(config, id, enabled) {
204
205
  await reload(config);
205
206
  } catch (error) {
206
207
  config.disabledPlugins = previous;
208
+ if (defaultCleared) config.defaultProvider = previousDefault;
207
209
  throw error;
208
210
  }
209
211
  try {
210
- writeConfig(config, (raw) => { raw.disabledPlugins = config.disabledPlugins; });
212
+ writeConfig(config, (raw) => {
213
+ raw.disabledPlugins = config.disabledPlugins;
214
+ if (defaultCleared) raw.defaultProvider = '';
215
+ });
211
216
  } catch (error) {
212
217
  config.disabledPlugins = previous;
218
+ if (defaultCleared) config.defaultProvider = previousDefault;
213
219
  await reload(config);
214
220
  throw error;
215
221
  }
@@ -252,11 +258,10 @@ export async function removePlugin(config, { id = '', file = '' } = {}) {
252
258
  target = removalTarget(config, target);
253
259
  const described = await describeModule(target, config);
254
260
  const ids = described.map((plugin) => plugin.id).filter((item) => PLUGIN_ID.test(item));
255
- // 同 setPluginEnabled:删默认助手会被 defaultProvider 守卫拦下但报错没有指引,
256
- // 在动作入口就说清楚(2026-10-18 实锤:删除 codex 后桥接器重启都起不来)。
257
- if (ids.includes(config.defaultProvider)) {
258
- throw fail('这是默认编程助手,不能删除;请先在设置里把默认编程助手切换为其他助手');
259
- }
261
+ // 默认助手只是可选偏好,不再是"主力":删的就是默认助手时清掉指向,不拦人。
262
+ const previousDefault = config.defaultProvider || '';
263
+ const removesDefault = ids.includes(previousDefault);
264
+ if (removesDefault) config.defaultProvider = '';
260
265
  const previousDisabled = [...(config.disabledPlugins || [])];
261
266
  const previousPlugins = [...(config.plugins || [])];
262
267
  config.disabledPlugins = [...new Set([...previousDisabled, ...ids])];
@@ -266,10 +271,12 @@ export async function removePlugin(config, { id = '', file = '' } = {}) {
266
271
  writeConfig(config, (raw) => {
267
272
  raw.disabledPlugins = config.disabledPlugins;
268
273
  if (Array.isArray(raw.plugins)) raw.plugins = config.plugins;
274
+ if (removesDefault) raw.defaultProvider = '';
269
275
  });
270
276
  } catch (error) {
271
277
  config.disabledPlugins = previousDisabled;
272
278
  config.plugins = previousPlugins;
279
+ if (removesDefault) config.defaultProvider = previousDefault;
273
280
  await reload(config).catch(() => {});
274
281
  throw error;
275
282
  }
package/src/jobs.mjs CHANGED
@@ -383,6 +383,34 @@ export class JobStore {
383
383
  job.updatedAt = now();
384
384
  this.save();
385
385
  this.hooks?.notify('started', job);
386
+ // —— 插件型助手的卡死看门狗 ——
387
+ // CLI 型插件由 agents/shared.mjs 的子进程 idle 看门狗覆盖;插件型(自带
388
+ // run() 与工具环的助手)此前完全没有超时:模型请求/工具调用不返回就永久
389
+ // 挂着,任务永远停在 running(2026-10-24 雪球 jojo 任务挂 15 分钟零输出
390
+ // 零产物实锤)。这里按「多久没有任何进展」判定,中止后交给车道自动重跑。
391
+ // 阈值必须大于素材管线的合法最长静默时长:外部生图是异步任务轮询,单张
392
+ // 上限 300s(body 240s + sheet 300s ≈ 9 分钟),期间完全没有文本进度。
393
+ // 默认 15 分钟(见 config.mjs)。
394
+ let lastActivity = Date.now();
395
+ const touchActivity = () => { lastActivity = Date.now(); };
396
+ const stallMs = Math.max(1, Number(this.stuckIdleMinutes) || 15) * 60_000;
397
+ let stalled = false;
398
+ const stallTimer = setInterval(() => {
399
+ if (job.status !== 'running') return;
400
+ const idle = Date.now() - lastActivity;
401
+ if (idle < stallMs) return;
402
+ stalled = true;
403
+ controller.abort(new Error(`任务 ${Math.round(idle / 60_000)} 分钟没有任何进展,疑似卡死`));
404
+ }, 15_000);
405
+ if (stallTimer.unref) stallTimer.unref();
406
+ // 单轮硬上限 30 分钟:素材管线一次从零生成(body + sheet)最坏约 10 分钟,
407
+ // 加上模型侧重试仍有余量。
408
+ const hardTimer = setTimeout(() => {
409
+ if (job.status !== 'running') return;
410
+ stalled = true;
411
+ controller.abort(new Error('任务单轮运行超过 30 分钟,已停止'));
412
+ }, 30 * 60_000);
413
+ if (hardTimer.unref) hardTimer.unref();
386
414
  try {
387
415
  this.ensureWorkspace(job.vibeSessionId);
388
416
  const project = this.projectFor(job);
@@ -395,6 +423,7 @@ export class JobStore {
395
423
  if (!progress || progress === job.progress) return;
396
424
  job.progress = progress;
397
425
  job.progressAt = now();
426
+ touchActivity();
398
427
  job.updatedAt = job.progressAt;
399
428
  const current = Date.now();
400
429
  if (current - (this.progressSavedAt.get(job.id) || 0) >= 750) {
@@ -413,6 +442,34 @@ export class JobStore {
413
442
  job.updatedAt = now();
414
443
  this.save();
415
444
  }
445
+ }, (tool) => {
446
+ // 工具调用轨迹:仅存任务记录给桥接器网页端折叠展示,设备端
447
+ // /device/tasks 是字段白名单,天然带不出去。
448
+ if (job.status !== 'running') return;
449
+ const name = String(tool?.name || '').trim();
450
+ if (!name) return;
451
+ const entry = {
452
+ name: name.slice(0, 120),
453
+ detail: String(tool?.detail || '').slice(0, 600),
454
+ state: tool?.state === 'completed' ? 'completed' : 'started',
455
+ at: now(),
456
+ };
457
+ const calls = Array.isArray(job.toolCalls) ? job.toolCalls : [];
458
+ // 同名同参数的 completed 覆盖前一条 started,避免每个工具占两行。
459
+ const previous = calls.length - 1 >= 0 ? calls[calls.length - 1] : null;
460
+ if (previous && previous.state === 'started' && previous.name === entry.name &&
461
+ previous.detail === entry.detail) {
462
+ previous.state = 'completed';
463
+ } else {
464
+ calls.push(entry);
465
+ }
466
+ job.toolCalls = calls.slice(-40);
467
+ touchActivity();
468
+ const current = Date.now();
469
+ if (current - (this.progressSavedAt.get(job.id) || 0) >= 750) {
470
+ this.progressSavedAt.set(job.id, current);
471
+ this.save();
472
+ }
416
473
  });
417
474
  if (job.status === 'running') {
418
475
  if (!outcome || !['completed', 'handed_off'].includes(outcome.status || 'completed')) {
@@ -434,6 +491,13 @@ export class JobStore {
434
491
  this.hooks?.notify(job.status, job);
435
492
  }
436
493
  } catch (error) {
494
+ if (stalled && job.status === 'running') {
495
+ // 看门狗中止(插件型助手):按 CLI 型同样的语义交给车道自动重跑。
496
+ error = Object.assign(
497
+ new Error(`任务 ${Math.round(stallMs / 60_000)} 分钟没有任何进展,疑似卡死:已终止`),
498
+ { retryable: true, autoRetry: true },
499
+ );
500
+ }
437
501
  if (job.status === 'running') {
438
502
  // 卡死自愈:看门狗杀掉无输出的编程工具进程后,同车道自动重跑,
439
503
  // 最多 maxAutoRetries 次;重跑的是同一条指令(native 会话会 resume 同一 CLI 线程)。
@@ -462,6 +526,8 @@ export class JobStore {
462
526
  this.hooks?.notify('failed', job);
463
527
  }
464
528
  } finally {
529
+ clearInterval(stallTimer);
530
+ clearTimeout(hardTimer);
465
531
  this.controllers.delete(job.id);
466
532
  this.progressSavedAt.delete(job.id);
467
533
  }
@@ -91,6 +91,7 @@ export function createEnhancement(config) {
91
91
  if (!wired.jobs) throw new Error('桥接器尚未就绪');
92
92
  createTaskGuard();
93
93
  const provider = agentId || config.defaultProvider;
94
+ if (!provider) throw new Error('请指定编程助手(createTask 需要显式 agentId)');
94
95
  const project = projectId || wired.jobs.defaultProject;
95
96
  const session = wired.jobs.createSession({ provider, projectId: project, title });
96
97
  const job = wired.jobs.submit({ instruction, sessionId: session.id, provider, projectId: project, source: 'plugin' });
@@ -92,6 +92,14 @@ export async function reloadConfiguredPlugins(config) {
92
92
  }
93
93
 
94
94
  const RELOAD_DELAY_MS = 300;
95
+ // 热加载单次超时:reloadConfiguredPlugins 一旦挂起(插件 import 死锁/网络
96
+ // 等待),reloading 会永远停在 true,之后所有变更只排队、不再加载也
97
+ // 不打日志——表现为「桥接器对素材改动完全无反应」(2026-10-24 实锤)。
98
+ // 超时后强制解锁并记账,下一轮变更还能再试。
99
+ const RELOAD_TIMEOUT_MS = 30000;
100
+ // fs.watch 在 macOS 长跑进程里会静默失效(事件流断掉,无错误、无回调)。
101
+ // 轮询兜底:按 mtime 快照比对,watch 哑了也能热加载。
102
+ const POLL_INTERVAL_MS = 3000;
95
103
 
96
104
  function relevantChange(filename) {
97
105
  if (!filename) return true;
@@ -137,6 +145,12 @@ export function watchPlugins(config, { onReload } = {}) {
137
145
  if (closed) return;
138
146
  if (reloading) { pending = true; return; }
139
147
  reloading = true;
148
+ let timedOut = false;
149
+ const guard = setTimeout(() => {
150
+ timedOut = true;
151
+ console.error(`[plugins] 热加载超过 ${RELOAD_TIMEOUT_MS}ms 未返回,强制解锁`
152
+ + '(保留上一份插件;下一轮变更会再试)');
153
+ }, RELOAD_TIMEOUT_MS);
140
154
  try {
141
155
  await reloadConfiguredPlugins(config);
142
156
  const ids = config.pluginsRuntime?.agents?.().map((plugin) => plugin.id) ?? [];
@@ -146,7 +160,11 @@ export function watchPlugins(config, { onReload } = {}) {
146
160
  console.error(`[plugins] 热加载失败,继续使用上一份:${error.message}`);
147
161
  onReload?.(error);
148
162
  } finally {
163
+ clearTimeout(guard);
149
164
  reloading = false;
165
+ // 无论成败都刷新快照:否则轮询兜底会把同一次改动再触发一遍
166
+ // (fs.watch 与轮询双通道各来一次)。
167
+ snapshotMtimes();
150
168
  if (pending && !closed) {
151
169
  pending = false;
152
170
  schedule();
@@ -173,13 +191,58 @@ export function watchPlugins(config, { onReload } = {}) {
173
191
  arm(filename, false);
174
192
  arm(dir, false);
175
193
  }
176
- if (watchers.length) console.error('[plugins] 正在监视插件变更');
194
+ // ---- 轮询兜底(fs.watch 静默失效时仍能热加载)----
195
+ const mtimes = new Map();
196
+
197
+ function collect(dir, depth, out) {
198
+ let entries;
199
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
200
+ for (const entry of entries) {
201
+ if (entry.isDirectory()) {
202
+ if (depth <= 0 || entry.name === 'node_modules') continue;
203
+ collect(path.join(dir, entry.name), depth - 1, out);
204
+ continue;
205
+ }
206
+ if (!relevantChange(entry.name)) continue;
207
+ const full = path.join(dir, entry.name);
208
+ try { out.set(full, fs.statSync(full).mtimeMs); } catch { /* 文件刚被删。 */ }
209
+ }
210
+ }
211
+
212
+ function scan() {
213
+ const out = new Map();
214
+ for (const root of pluginWatchRoots(config)) collect(root, 4, out);
215
+ return out;
216
+ }
217
+
218
+ function snapshotMtimes() {
219
+ mtimes.clear();
220
+ for (const [file, ms] of scan()) mtimes.set(file, ms);
221
+ }
222
+
223
+ function poll() {
224
+ if (closed || reloading) return;
225
+ const current = scan();
226
+ let changed = false;
227
+ for (const [file, ms] of current) {
228
+ if (mtimes.get(file) !== ms) { changed = true; break; }
229
+ }
230
+ if (!changed && current.size !== mtimes.size) changed = true;
231
+ mtimes.clear();
232
+ for (const [file, ms] of current) mtimes.set(file, ms);
233
+ if (changed && mtimes.size) schedule();
234
+ }
235
+
236
+ snapshotMtimes();
237
+ const pollTimer = setInterval(poll, POLL_INTERVAL_MS);
238
+ if (watchers.length) console.error('[plugins] 正在监视插件变更(含轮询兜底)');
177
239
 
178
240
  return {
179
241
  close() {
180
242
  closed = true;
181
243
  if (timer) clearTimeout(timer);
182
244
  timer = null;
245
+ clearInterval(pollTimer);
183
246
  for (const watcher of watchers) {
184
247
  try { watcher.close(); } catch { /* 已经关掉。 */ }
185
248
  }
@@ -156,6 +156,7 @@ export async function loadConfiguredPlugins(config) {
156
156
  await loadFile(filename, label);
157
157
  }
158
158
  const registry = createPluginRegistry(plugins);
159
- if (!registry.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
159
+ // 默认助手是可选偏好:配置了才校验存在性;留空表示没有兜底助手。
160
+ if (config.defaultProvider && !registry.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
160
161
  return registry;
161
162
  }
package/src/providers.mjs CHANGED
@@ -60,7 +60,7 @@ export function providerStates(config, locale = config.locale) {
60
60
  }
61
61
 
62
62
  export function createRunner(config) {
63
- const runner = async (job, project, signal, onProgress = () => {}, onSession = () => {}) => {
63
+ const runner = async (job, project, signal, onProgress = () => {}, onSession = () => {}, onTool = () => {}) => {
64
64
  const plugin = agentFor(config, job.provider);
65
65
  if (!plugin) throw new Error('编程工具插件未加载,请先安装或启用对应插件');
66
66
  if (signal.aborted) throw new Error('任务已取消');
@@ -71,6 +71,9 @@ export function createRunner(config) {
71
71
  job, project, signal, config, model,
72
72
  onProgress: plugin.capabilities.progress ? onProgress : () => {},
73
73
  onSession: plugin.capabilities.session === 'native' ? onSession : () => {},
74
+ // 工具调用轨迹(仅桥接器网页端展示):CLI 型插件的 runCli 会自动消费,
75
+ // 不认识该字段的旧插件安全忽略。
76
+ onTool,
74
77
  });
75
78
  };
76
79
  runner.agent = (id) => agentFor(config, id);
package/src/server.mjs CHANGED
@@ -251,7 +251,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
251
251
  const publicTunnel = (value = tunnel.summary()) => localizeStatus(value, config.locale);
252
252
  if (!config.pluginsRuntime && (config.plugins?.length || config.disabledPlugins?.length)) throw new Error('请先 await loadConfiguredPlugins(config) 并赋值 config.pluginsRuntime');
253
253
  config.pluginsRuntime ||= createPluginRegistry();
254
- if (!config.pluginsRuntime.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
254
+ if (config.defaultProvider && !config.pluginsRuntime.hasAgent(config.defaultProvider)) throw new Error('默认编程助手未安装或已禁用');
255
255
  if (!config.speech) initializeSpeechSettings(config);
256
256
  config.models = { ...Object.fromEntries(config.pluginsRuntime.agents().filter(p => p.capabilities.model).map(p => [p.id, p.model?.default || ''])), ...(config.codexModel ? {codex: config.codexModel} : {}), ...config.models };
257
257
  validateModelSettings(Object.fromEntries(Object.entries(publicModels(config)).filter(([, value]) => value !== null)), config.pluginsRuntime);
@@ -909,6 +909,7 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
909
909
  }
910
910
  if (req.method === 'POST' && url.pathname === '/device/sessions') {
911
911
  const provider = url.searchParams.get('provider') || config.defaultProvider;
912
+ if (!provider) throw new Error('请先在电脑桥接器里选择编程助手');
912
913
  const projectId = url.searchParams.get('projectId') || config.defaultProject;
913
914
  if (!config.pluginsRuntime.hasAgent(provider)) throw new Error('未知编程工具');
914
915
  const payload = await body(req);
@@ -966,6 +967,9 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
966
967
  deviceSend(res, 200, {
967
968
  version: plugin.sprite.version, canvas: plugin.sprite.canvas,
968
969
  layers: plugin.sprite.layers, ...(plugin.sprite.motion ? { motion: plugin.sprite.motion } : {}),
970
+ // 整帧状态动画(声明式,固件照表播);老素材没有这两项,照旧走分层叠加
971
+ ...(plugin.sprite.frames ? { frames: plugin.sprite.frames } : {}),
972
+ ...(plugin.sprite.frame_ms ? { frame_ms: plugin.sprite.frame_ms } : {}),
969
973
  ...(plugin.sprite.emotions ? { emotions: plugin.sprite.emotions } : {}),
970
974
  ...(plugin.sprite.actions ? { actions: plugin.sprite.actions } : {}),
971
975
  ...(plugin.sprite.commands ? { commands: plugin.sprite.commands } : {}),
@@ -1045,7 +1049,10 @@ export function createApp(config, { run, transcribe, tunnel: providedTunnel, ena
1045
1049
  }
1046
1050
  const match = url.pathname.match(/^\/device\/tasks\/([a-z0-9-]+)\/(confirm|cancel)$/);
1047
1051
  if (req.method === 'POST' && match) {
1048
- deviceSend(res, 200, publicJob(match[2] === 'confirm' ? store.confirm(match[1]) : store.cancel(match[1]), deviceLocale));
1052
+ const confirmed = publicJob(match[2] === 'confirm' ? store.confirm(match[1]) : store.cancel(match[1]), deviceLocale);
1053
+ // 工具调用轨迹只给桥接器网页端,任何设备回包都不携带。
1054
+ const { toolCalls, ...deviceJob } = confirmed;
1055
+ deviceSend(res, 200, deviceJob);
1049
1056
  return;
1050
1057
  }
1051
1058
  // 板端截屏回传:固件把当前画面以 RGB565 裸帧 POST 上来(tools/shot.mjs 触发)。
package/src/sessions.mjs CHANGED
@@ -65,6 +65,7 @@ export class SessionStore {
65
65
 
66
66
  create({ provider, projectId, title, legacy = false, cliSessionId = '' } = {}) {
67
67
  const selectedProvider = provider || this.defaultProvider;
68
+ if (!selectedProvider) throw new Error('请先选择编程助手');
68
69
  const selectedProject = projectId || this.defaultProject;
69
70
  const plugin = this.agent(selectedProvider);
70
71
  if (!plugin && !(legacy && PROVIDER_ID.test(selectedProvider))) throw new Error('不支持的编程工具');