mingdao-harness 0.6.38 → 0.6.40

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.
@@ -209,30 +209,45 @@ node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe -
209
209
  下载 → 取到 `.sig` → 用旧公钥验 → 失败 → **永久拒绝更新**。
210
210
  这比"没有签名"严重得多——用户会被卡死在旧版本,只能手动重装。
211
211
 
212
- ### 6.2 两版
212
+ ### 6.2 三个阶段
213
213
 
214
214
  | 版本 | 代码 | 发布侧 |
215
215
  | --- | --- | --- |
216
- | **v0.6.38(本版)** | 密钥环/签名脚本/文案均沿用 v0.6.37(本版只修 macOS CI 测试判据) | **仍然不发布 `.sig`**(CI 开关关闭) |
217
- | **v0.6.39(下一版)** | 不需再改代码(密钥环已能认新钥) | 用新私钥签 `.sig` → 发布到 GitHub Release **与** 站点 `/updates/<文件名>.sig`;CI 开关打开 |
216
+ | **v0.6.38** | 密钥环/签名脚本/文案均沿用 v0.6.37(本版只修 macOS CI 测试判据) | **仍然不发布 `.sig`**(CI 开关关闭) |
217
+ | **v0.6.39(阶段一,已上线)** | 无需改代码(密钥环已能认新钥) | 用新私钥签 `.sig`,**只上传 GitHub Release**;站点 `/updates/` **绝不放 `.sig`** |
218
+ | **阶段二(0.6.40+,未上线)** | 无需改代码 | 把 Release 里的 `.sig` **复制到站点 `/updates/`**(客户端真正读取的位置) |
219
+
220
+ > **阶段一为什么不放站点**:存量客户端(≤0.6.35)只内置旧公钥,拿到用新私钥签的 `.sig` 会
221
+ > "有签名但验不过"→ `decideUpdatePolicy` 判 **reject** → **永久拒绝更新**,只能手动重装。
222
+ > 阶段一先把 `.sig` 关在 Release 里(可审计、客户端不读),等 0.6.36+(含密钥环)覆盖绝大多数
223
+ > 活跃用户后再进入阶段二。
224
+ >
225
+ > **阶段二切换条件与回滚**:0.6.36+ 覆盖绝大多数活跃用户后,把 `.sig` 复制到 `/updates/` 即可;
226
+ > 切换前必须实测 0.6.35 / 0.6.37 / 0.6.38 客户端能从新版正常更新。若旧版拒绝,**立即删除
227
+ > `/updates/*.sig`**(回滚只需删文件,不动代码)。
218
228
 
219
229
  ### 6.3 开关与密钥
220
230
 
221
231
  - 私钥**不入库**:`~/.mingdao/update-signing-key.pem`(0600),CI 里放 `secrets.UPDATE_SIGNING_KEY`(PEM 全文);
222
- - CI 开关:仓库变量 `UPDATE_SIGNING_ENABLED=true` 才执行签名与上传(默认关闭);
232
+ - CI 开关:**`secrets.UPDATE_SIGNING_KEY` 存在**即执行签名与上传(v0.6.39 起;此前文档写的是仓库变量
233
+ `UPDATE_SIGNING_ENABLED`,实际未接线)。`secrets` **不能**直接用在 step 级 `if`(整份 workflow 会判无效),
234
+ 所以在 job 级 `env` 折算成 `HAS_UPDATE_SIGNING_KEY` 再判;
223
235
  - 生成/轮换:`node desktop/update-verify.js --keygen`(沿用既有实现,私钥不打印、拒绝入库),
224
236
  然后把新公钥追加到 `UPDATE_PUBLIC_KEYS`(**旧钥必须保留至少一个大版本**,否则又会断掉存量客户端)。
225
237
 
226
238
  ### 6.4 发布时 `.sig` 放哪里
227
239
 
228
240
  客户端按 **`https://harness.mingdao.ai/updates/<安装包文件名>.sig`** 取(`collectUpdateSignature` 的第三来源)。
229
- 所以发版脚本要把 `scripts/sign-release-assets.mjs --out <sig 目录>` 的产物同时:
230
- 1. 传到 GitHub Release(便于审计与镜像);
231
- 2. **复制到站点 `updates/` 目录**(客户端真正读取的位置)。
241
+ `scripts/sign-release-assets.mjs artifacts` 会**递归**扫描(CI 的产物在 `artifacts/<name>/` 子目录)
242
+ 并**保留相对目录**产出 `.sig`(不同平台的 `latest.yml` 不会互相覆盖)。发布侧:
243
+
244
+ 1. **阶段一(v0.6.39)**:只传到 GitHub Release(便于审计与镜像);**站点 `updates/` 不放**。
245
+ 2. **阶段二(未上线)**:再把同样的 `.sig` **复制到站点 `updates/` 目录**(客户端真正读取的位置)。
232
246
 
233
247
  ### 6.5 验收
234
248
 
235
- - `node test/update-signing.js`(10 项):密钥环多钥、篡改必拒、签名脚本产物可验、缺钥 CLI 非零退出、文案分层;
236
- - `batch77`(8 条变异)必须全中;
237
- - 启用后的首个版本,必须实测"旧版客户端(0.6.35 或 0.6.37/0.6.38)能从 0.6.39 正常更新"——
238
- 若旧版拒绝,说明 `.sig` 已发布但旧版不在密钥环内,**立即撤下 `/updates/*.sig`**(回滚只要删文件,不动代码)。
249
+ - `node test/update-signing.js`(**11 项**):密钥环多钥、篡改必拒、签名脚本产物可验(含子目录递归/相对路径/
250
+ 扁平化重名告警)、缺钥 CLI 非零退出、文案分层;
251
+ - `batch77`(**9 条变异**)必须全中;
252
+ - **阶段二启用后**的首个版本,必须实测"旧版客户端(0.6.35 或 0.6.37/0.6.38)能从新版正常更新"——
253
+ 若旧版拒绝,说明 `.sig` 已发布但旧版不在密钥环内,**立即撤下 `/updates/*.sig`**。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.38",
3
+ "version": "0.6.40",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -46,6 +46,8 @@ import { appendEvent, eventFileFor, eventLogEnabled, missingToolCallIds, setEven
46
46
  // ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断);
47
47
  // ③ 工具参数怎么解析(v0.6.17 第四刀:JSON + 容错 + 归一化 + 错误文案)。
48
48
  import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage, parseToolArgs } from './tools-flow.js';
49
+ // v0.6.40:插话"有→继续、无→收尾"的判据与前端共用同一份纯函数(viewmodel 不读 DOM/全局)。
50
+ import { shouldContinueAfterModel } from './web/viewmodel.js';
49
51
 
50
52
  const MAX_STEPS = 24;
51
53
  // 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
@@ -138,14 +140,55 @@ function resolveAgentEventFile(sessionRef, cfg) {
138
140
  return null;
139
141
  }
140
142
 
143
+ /**
144
+ * v0.6.40:把插话缓冲里的**全部**条目注入当前回合的消息数组。
145
+ *
146
+ * 车道语义(与 next-turn 排队严格区分):
147
+ * · 每个 step 边界调用一次,`drain()` 必须**全量取走**(不是一条一步);
148
+ * · 每条插话以 `{role:'user', content:text}` 追加在末尾——模型下一步请求就能看到;
149
+ * · 通过既有 io 事件通道推 `{type:'steer', text}`(Web 端渲染"↳ 已插入本轮")。
150
+ *
151
+ * 纯判据("有插话→继续")在 web/viewmodel.js 的 shouldContinueAfterModel;这里只做副作用。
152
+ * drain 抛异常的边界:视为本轮没有插话(插话是旁路,绝不能让一个坏 buffer 打断回合)。
153
+ *
154
+ * @param {{ drain?: () => any } | undefined} steering
155
+ * @param {any[]} messages
156
+ * @param {any} io
157
+ * @returns {any[]} 本次实际注入的条目(供收尾分支判断是否继续)
158
+ */
159
+ function drainSteeringInto(steering, messages, io) {
160
+ /** @type {any} */
161
+ let raw;
162
+ try {
163
+ raw = steering?.drain?.() || [];
164
+ } catch {
165
+ return [];
166
+ }
167
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.taken) ? raw.taken : [];
168
+ /** @type {any[]} */
169
+ const injected = [];
170
+ for (const it of list) {
171
+ const text = String(it && typeof it === 'object' ? it.text ?? '' : it ?? '');
172
+ if (!text.trim()) continue;
173
+ messages.push({ role: 'user', content: text });
174
+ injected.push(it);
175
+ try {
176
+ if (typeof io?.steer === 'function') io.steer(text);
177
+ else if (typeof io?.push === 'function') io.push({ type: 'steer', text });
178
+ } catch {}
179
+ }
180
+ return injected;
181
+ }
182
+
141
183
  /**
142
184
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
143
185
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
144
186
  * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
145
187
  * onUsage?: (modelName: string, usage: any) => void,
146
- * constraints?: any[] }} params
188
+ * constraints?: any[],
189
+ * steering?: { drain?: () => any } }} params
147
190
  */
148
- export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage, constraints: rawConstraints }) {
191
+ export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage, constraints: rawConstraints, steering }) {
149
192
  const preset = modelPreset(modelName) || {};
150
193
  // v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
151
194
  // 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
@@ -1039,6 +1082,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1039
1082
  durationMs: Date.now() - startedAt, perf: perf(),
1040
1083
  };
1041
1084
  }
1085
+ // 接线点①(v0.6.40):每个 step 边界全量取走插话;放在中止检查之后 = 停止优先。
1086
+ drainSteeringInto(steering, messages, io);
1042
1087
  // 本轮的 AbortController 提到压缩之前创建(审计 BUG-037):压缩是一次**大开销的真实请求**
1043
1088
  // (30K 输入 × executor 模型),此前它在 `ac` 创建之前发生、拿不到任何 signal,
1044
1089
  // 于是压缩期间 Ctrl+C 只能等它返回(远程 600s / 本地 1800s,且还有一次重试)。
@@ -2063,6 +2108,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2063
2108
  });
2064
2109
  continue;
2065
2110
  }
2111
+ // 接线点②(v0.6.40):没有工具调用、本要收尾——刚插话就继续下一步(本轮不结束)。
2112
+ const steeredNow = drainSteeringInto(steering, messages, io);
2113
+ if (shouldContinueAfterModel({ steer: steeredNow, aborted, stalled: noProgressFired })) continue;
2066
2114
  markOutcome(finish === 'length' ? 'capped' : 'done', { capHit: false, truncated: finish === 'length' });
2067
2115
  return {
2068
2116
  text: finalText,
@@ -42,7 +42,27 @@ export const EVENT_TYPES = Object.freeze([
42
42
  'checkpoint',
43
43
  ]);
44
44
 
45
- const EVENT_TYPE_SET = new Set(EVENT_TYPES);
45
+ /**
46
+ * 审批事件(v0.6.40 悬空闭合切片):`approval.asked` / `approval.decided` 由审批轨道写入。
47
+ *
48
+ * 为什么不并进 `EVENT_TYPES`:那份清单是**对外公开**的 12 类事件,条数被既有回归套件
49
+ * (`test/session-events.js` ①)逐字钉死;本切片不允许改那套断言。但读取与追加又必须认它们——
50
+ * 否则修补器看不见「永远在等审批」的悬空 ask,也没法把合成的 decided 追加进日志。
51
+ * 因此:`EVENT_TYPES` 仍是 12 类;内部可读写集合 = 12 类 + 审批 2 类。
52
+ */
53
+ export const APPROVAL_EVENT_TYPES = Object.freeze(['approval.asked', 'approval.decided']);
54
+
55
+ /** 内部接受的事件类型:公共 12 类 + 审批 2 类(变量名保持 `EVENT_TYPE_SET`,既有变异锚点不变)。 */
56
+ const EVENT_TYPE_SET = new Set([...EVENT_TYPES, ...APPROVAL_EVENT_TYPES]);
57
+
58
+ /** 审批/工具的关联键字段(按优先级;没有键时按「最近一条」兜底配对)。 */
59
+ const APPROVAL_ID_FIELDS = Object.freeze(['requestId', 'approvalId', 'approvalRequestId', 'id', 'callId']);
60
+ const TOOL_ID_FIELDS = Object.freeze(['callId', 'toolCallId', 'id']);
61
+
62
+ /** 合成闭合事件的固定文案:工具结果未知(不是失败,是进程中断后无从得知)。 */
63
+ export const SYNTHETIC_TOOL_ERROR = '进程中断,工具结果未知';
64
+ /** 合成闭合事件的默认原因(`repairEventLog` 调用方也可覆盖)。 */
65
+ export const SYNTHETIC_REASON = '进程中断(启动时合成闭合事件)';
46
66
 
47
67
  /** 回滚开关:`MINGDAO_EVENT_LOG=0`(或 false/off/no)时全局关闭双写。 */
48
68
  export const EVENT_LOG_ENV = 'MINGDAO_EVENT_LOG';
@@ -383,6 +403,166 @@ export function readEvents(eventFile) {
383
403
  }
384
404
  }
385
405
 
406
+ /**
407
+ * v0.6.40:中断 / 崩溃 / 等审批时关应用会留下**没有配对**的事件。这里把它们找出来。
408
+ *
409
+ * 纯函数:不读写文件、不改入参。配对规则(全部按 `seq` 升序处理):
410
+ * · `approval.asked` ↔ `approval.decided`:按 requestId/approvalId/id/callId 配对;
411
+ * 没有 id 时 decided 关闭最近一条无 id 的 ask(审批不嵌套);
412
+ * · `tool.start` ↔ `tool.end`:按 callId/toolCallId/id 配对;没有 id 时同样关闭最近一条;
413
+ * · `turn.start` ↔ `turn.end`:turn 没有 id,用栈配对(end 关闭最近一次 start);
414
+ * · **同 id 多次出现以最后一次为准**:后一次 start/asked 覆盖前一次,不会把已覆盖的旧事件报成悬空。
415
+ *
416
+ * @param {any[]} events
417
+ * @returns {{asks: SessionEvent[], tools: SessionEvent[], turns: SessionEvent[]}}
418
+ */
419
+ export function findDangling(events) {
420
+ /** @type {Map<string, any>} */
421
+ const asks = new Map();
422
+ /** @type {Map<string, any>} */
423
+ const tools = new Map();
424
+ /** @type {any[]} */
425
+ const turns = [];
426
+ /** @param {any} ev @param {readonly string[]} fields @returns {string} */
427
+ const idOf = (ev, fields) => {
428
+ for (const f of fields) {
429
+ const v = ev?.data?.[f];
430
+ if (v !== null && v !== undefined && String(v) !== '') return String(v);
431
+ }
432
+ return '';
433
+ };
434
+ /**
435
+ * 关闭 map 里最近一条(无 id 的 end/decided 兜底)。
436
+ * @param {Map<string, any>} map
437
+ */
438
+ const closeLatest = (map) => {
439
+ const keys = [...map.keys()];
440
+ const last = keys.length ? keys[keys.length - 1] : '';
441
+ if (last) map.delete(last);
442
+ };
443
+ for (const ev of ordered(events)) {
444
+ if (ev.type === 'approval.asked') {
445
+ const key = idOf(ev, APPROVAL_ID_FIELDS) || `#${ev.seq}`;
446
+ asks.delete(key); // 先删再插:Map 的插入顺序也要反映「最后一次出现」
447
+ asks.set(key, ev);
448
+ } else if (ev.type === 'approval.decided') {
449
+ const key = idOf(ev, APPROVAL_ID_FIELDS);
450
+ if (key) asks.delete(key);
451
+ else closeLatest(asks);
452
+ } else if (ev.type === 'tool.start') {
453
+ const key = idOf(ev, TOOL_ID_FIELDS) || `#${ev.seq}`;
454
+ tools.delete(key); // 同上:覆盖时刷新插入顺序
455
+ tools.set(key, ev);
456
+ } else if (ev.type === 'tool.end') {
457
+ const key = idOf(ev, TOOL_ID_FIELDS);
458
+ if (key) tools.delete(key);
459
+ else closeLatest(tools);
460
+ } else if (ev.type === 'turn.start') {
461
+ turns.push(ev);
462
+ } else if (ev.type === 'turn.end') {
463
+ turns.pop();
464
+ }
465
+ }
466
+ /** @param {any} a @param {any} b @returns {number} */
467
+ const bySeq = (a, b) => (Number(a?.seq) || 0) - (Number(b?.seq) || 0);
468
+ return {
469
+ asks: [...asks.values()].sort(bySeq).map(cloneSessionEvent),
470
+ tools: [...tools.values()].sort(bySeq).map(cloneSessionEvent),
471
+ turns: turns.sort(bySeq).map(cloneSessionEvent),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * v0.6.40:为悬空事件合成**要追加**的闭合事件(纯函数,不写文件)。
477
+ *
478
+ * 顺序是嵌套的自然顺序:先 `approval.decided`(内层)→ 再 `tool.end` → 最后 `turn.end`。
479
+ * 每条都带 `data.synthetic=true` 与 `data.reason`——重放/审计能一眼看出这是修补出来的事实,
480
+ * 而不是真实发生过的结束。合成事件不带 `seq`:由 `appendEvent` 在锁内分配,保证接在尾部不重号。
481
+ *
482
+ * @param {{asks?: any[], tools?: any[], turns?: any[]}} dangling
483
+ * @param {{at?: number|Date|null, reason?: string}} [opts]
484
+ * @returns {{type: string, time: number, data: any}[]}
485
+ */
486
+ export function synthClosers(dangling, { at = null, reason = '' } = {}) {
487
+ const rawTime = Number(at instanceof Date ? at.getTime() : at);
488
+ const time = Number.isFinite(rawTime) && rawTime > 0 ? rawTime : Date.now();
489
+ const why = String(reason || SYNTHETIC_REASON);
490
+ /** @type {{type: string, time: number, data: any}[]} */
491
+ const closers = [];
492
+ const asks = Array.isArray(dangling?.asks) ? dangling.asks : [];
493
+ const tools = Array.isArray(dangling?.tools) ? dangling.tools : [];
494
+ const turns = Array.isArray(dangling?.turns) ? dangling.turns : [];
495
+ /** @param {any} ev @param {readonly string[]} fields @returns {{field: string, value: any}|null} */
496
+ const keyOf = (ev, fields) => {
497
+ for (const f of fields) {
498
+ const v = ev?.data?.[f];
499
+ if (v !== null && v !== undefined && String(v) !== '') return { field: f, value: v };
500
+ }
501
+ return null;
502
+ };
503
+ for (const ask of asks) {
504
+ /** @type {any} */
505
+ const data = { decision: 'aborted', synthetic: true, reason: why };
506
+ const key = keyOf(ask, APPROVAL_ID_FIELDS);
507
+ if (key) data[key.field] = key.value; // 带上原 ask 的关联键,否则第二次 findDangling 仍配不上
508
+ closers.push({ type: 'approval.decided', time, data });
509
+ }
510
+ for (const tool of tools) {
511
+ /** @type {any} */
512
+ const data = { ok: false, error: SYNTHETIC_TOOL_ERROR, synthetic: true, reason: why };
513
+ if (tool?.data?.name !== undefined) data.name = String(tool.data.name);
514
+ const key = keyOf(tool, TOOL_ID_FIELDS);
515
+ if (key) data[key.field] = key.value;
516
+ closers.push({ type: 'tool.end', time, data });
517
+ }
518
+ for (let i = 0; i < turns.length; i += 1) {
519
+ // 每条悬空 turn 各补一条;事件形状不需要 turn 的任何字段。
520
+ closers.push({ type: 'turn.end', time, data: { status: 'aborted', synthetic: true, reason: why } });
521
+ }
522
+ return closers;
523
+ }
524
+
525
+ /**
526
+ * v0.6.40:闭合一个事件日志里的悬空事件。**只追加,不删除/不改写既有行**。
527
+ *
528
+ * 流程:读全量事件 → `findDangling` → 有悬空才 `synthClosers` → 逐条走 `appendEvent`
529
+ * (从而拿到锁内分配的连续 seq)。没有悬空时**连文件都不碰**(返回 repaired:0)。
530
+ *
531
+ * 损坏日志:`readEvents` 会把半截 JSON / 未知 type / 缺 seq 的行计入 `skipped`。只要
532
+ * `skipped > 0` 就视为日志损坏,返回 `{ok:false}` 且**一个字节都不写**——修补器的前提是
533
+ * 「日志是完整的、只是缺收尾」,在坏日志上追加只会把损坏固化进事实源。
534
+ *
535
+ * @param {string} eventFile 事件文件(`.events.ndjson`,不是会话 `.jsonl`)
536
+ * @param {{reason?: string}} [opts]
537
+ * @returns {{ok: boolean, error: string|null, repaired: number, dangling: {asks:any[],tools:any[],turns:any[]}, disabled?: boolean}}
538
+ */
539
+ export function repairEventLog(eventFile, { reason = '' } = {}) {
540
+ /** @type {{asks:any[],tools:any[],turns:any[]}} */
541
+ const empty = { asks: [], tools: [], turns: [] };
542
+ try {
543
+ const file = path.resolve(String(eventFile || ''));
544
+ if (!String(eventFile || '')) return { ok: false, error: '事件文件路径为空', repaired: 0, dangling: empty };
545
+ if (!eventLogEnabled()) return { ok: true, error: null, repaired: 0, dangling: empty, disabled: true };
546
+ const read = readEvents(file);
547
+ if (!read.ok) return { ok: false, error: read.error ?? '读取事件日志失败', repaired: 0, dangling: empty };
548
+ if (read.skipped > 0) {
549
+ return { ok: false, error: `事件日志损坏(跳过 ${read.skipped} 行),拒绝修补`, repaired: 0, dangling: empty };
550
+ }
551
+ const dangling = findDangling(read.events);
552
+ const closers = synthClosers(dangling, { reason });
553
+ if (!closers.length) return { ok: true, error: null, repaired: 0, dangling };
554
+ let repaired = 0;
555
+ for (const closer of closers) {
556
+ const r = appendEvent(file, { type: closer.type, data: closer.data, at: closer.time });
557
+ if (!r.ok) return { ok: false, error: r.error ?? '追加闭合事件失败', repaired, dangling };
558
+ repaired += 1;
559
+ }
560
+ return { ok: true, error: null, repaired, dangling };
561
+ } catch (err) {
562
+ return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err), repaired: 0, dangling: empty };
563
+ }
564
+ }
565
+
386
566
  /** @param {any} value @returns {any} 深拷贝(事件里只会有 JSON 值)。 */
387
567
  function cloneJson(value) {
388
568
  try {
@@ -392,6 +572,21 @@ function cloneJson(value) {
392
572
  }
393
573
  }
394
574
 
575
+ /**
576
+ * 悬空事件的对外快照:只保留 `seq/time/type/data`,data 深拷贝。
577
+ * 找悬空是纯函数,调用方拿到的对象不能反过来改到入参上。
578
+ * @param {any} ev
579
+ * @returns {SessionEvent}
580
+ */
581
+ function cloneSessionEvent(ev) {
582
+ return {
583
+ seq: Number.isInteger(Number(ev?.seq)) ? Number(ev.seq) : 0,
584
+ time: Number.isFinite(Number(ev?.time)) ? Number(ev.time) : 0,
585
+ type: String(ev?.type || ''),
586
+ data: cloneJson(ev?.data && typeof ev.data === 'object' ? ev.data : {}),
587
+ };
588
+ }
589
+
395
590
  /**
396
591
  * 事件按 seq 升序(seq 是唯一关联键);缺 seq 的保持原序在后。
397
592
  * @param {any[]} events
package/src/session.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, warnEventFailure } from './session-events.js';
6
+ import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, repairEventLog, warnEventFailure } from './session-events.js';
7
7
 
8
8
  // —— 冲突备份名(单一事实来源,v0.4.6)——
9
9
  // 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
@@ -168,8 +168,48 @@ export function rewriteSession(file, messages) {
168
168
  atomicWriteFileSync(file, lines + (lines ? '\n' : ''), { mode: 0o600 }); // 质检 H4:会话文件原子写
169
169
  }
170
170
 
171
- /** @param {any} file */
172
- export function loadSession(file) {
171
+ /** 每个会话文件每进程只尝试修补一次;失败也不重试(避免每次读取都整份读事件日志)。 */
172
+ const repairAttempted = new Set();
173
+
174
+ /**
175
+ * v0.6.40:首次读取会话时闭合「悬空事件」——`approval.asked` 没有 decided、`tool.start`
176
+ * 没有 tool.end、`turn.start` 没有 turn.end(进程被 kill / 崩溃 / 用户等审批时关应用)。
177
+ *
178
+ * 纪律:
179
+ * · **只追加**闭合事件,不改写/删除既有行;修补失败只 warn,绝不影响会话读取;
180
+ * · 同一会话文件在进程内只跑一次(`repairAttempted`),后续读取直接跳过;
181
+ * · `cfg.eventLog === false`(或环境变量关闭)时完全不碰事件文件。
182
+ * @param {any} file 会话文件(不是事件文件;内部换算成 `<session>.events.ndjson`)
183
+ * @param {any} [cfg] 传入时尊重 `cfg.eventLog === false`
184
+ * @returns {{ok:boolean,error:string|null,repaired:number,dangling:{asks:any[],tools:any[],turns:any[]},skipped?:boolean,deduped?:boolean}}
185
+ */
186
+ export function repairSessionEvents(file, cfg = null) {
187
+ /** @type {{asks:any[],tools:any[],turns:any[]}} */
188
+ const empty = { asks: [], tools: [], turns: [] };
189
+ try {
190
+ if (cfg && cfg.eventLog === false) return { ok: true, error: null, repaired: 0, dangling: empty, skipped: true };
191
+ if (!eventLogEnabled(cfg)) return { ok: true, error: null, repaired: 0, dangling: empty, skipped: true };
192
+ const sessionFile = String(file || '');
193
+ if (!sessionFile) return { ok: false, error: '会话文件路径为空', repaired: 0, dangling: empty };
194
+ const key = path.resolve(sessionFile);
195
+ if (repairAttempted.has(key)) return { ok: true, error: null, repaired: 0, dangling: empty, deduped: true };
196
+ // 先登记再修补:即使修补失败也不再重试(同一会话每进程只跑一次是硬约束)。
197
+ repairAttempted.add(key);
198
+ const eventFile = eventFileFor(key);
199
+ const result = repairEventLog(eventFile, { reason: '进程中断:启动/首次读取会话时合成闭合事件' });
200
+ if (!result.ok) warnEventFailure(eventFile, result.error ?? null);
201
+ return result;
202
+ } catch (err) {
203
+ const error = String(/** @type {any} */ (err)?.message ?? err);
204
+ try {
205
+ warnEventFailure(eventFileFor(String(file || '')), error);
206
+ } catch {}
207
+ return { ok: false, error, repaired: 0, dangling: empty };
208
+ }
209
+ }
210
+
211
+ /** @param {any} file @param {any} [cfg] */
212
+ export function loadSession(file, cfg = null) {
173
213
  const raw = fs.readFileSync(file, 'utf8');
174
214
  const messages = [];
175
215
  for (const line of raw.split('\n')) {
@@ -181,6 +221,9 @@ export function loadSession(file) {
181
221
  }
182
222
  } catch {}
183
223
  }
224
+ // v0.6.40:消息读完后顺带闭合悬空事件。失败只 warn(repairSessionEvents 内部吞掉),
225
+ // 返回值与消息数组一字不动——事件侧车坏了/修不了,会话照样可读。
226
+ repairSessionEvents(file, cfg);
184
227
  return { file, messages };
185
228
  }
186
229
 
package/src/web/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, textBody, fmtDur, fmtTok, fmtT } from './util.js';
2
2
  import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
3
3
  import { sideNavBreakpoint, sideNavLayout, errCardParts, runStripView, permissionSourceLabel, toolStepView, toolTimelineSummary, toolTimelineVisibility, traceRoundView } from './viewmodel.js';
4
+ import { enqueueMessage, dequeueMessage, shouldDrainQueue, queueView, QUEUE_MAX, enqueueSteer, steerView, STEER_MAX } from './viewmodel.js';
4
5
 
5
6
  // 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
6
7
  // 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
@@ -718,16 +719,161 @@ function renderAttachments(){
718
719
  }
719
720
  }
720
721
 
722
+ // —— 输入队列(v0.6.39)——
723
+ // 语义:生成中按 Enter 不再"被吞掉/只弹一句提示",而是**排队**;本轮结束后自动依次发出。
724
+ // 用户中止(■ 停止)时**不排空**——中止的意思是"停下",此时接着发下一条会违背用户意图。
725
+ // 队列本身只有一份(内存),页面刷新即丢:它是"这一刻的输入意图",不是持久任务。
726
+ let queued=[]; // [{id,text}]
727
+ let queueSeq=0;
728
+
729
+ function renderQueue(){
730
+ const strip=$('#queueStrip'); if(!strip) return;
731
+ const items=queueView(queued);
732
+ if(!items.length){ strip.style.display='none'; strip.innerHTML=''; return; }
733
+ strip.style.display='flex';
734
+ strip.innerHTML='';
735
+ const cap=document.createElement('span'); cap.className='qcap';
736
+ cap.textContent='排队中 '+items.length+' 条(本轮结束后自动发送)';
737
+ strip.appendChild(cap);
738
+ for(const it of items){
739
+ const chip=document.createElement('span'); chip.className='qchip';
740
+ const idx=document.createElement('span'); idx.className='qi'; idx.textContent=it.index+'.';
741
+ const txt=document.createElement('span'); txt.className='qt'; txt.textContent=it.label; txt.title=it.text;
742
+ // 点文本 → 放回输入框编辑(从队列移除,避免"编辑完发两次")
743
+ txt.onclick=()=>{ const r=dequeueMessage(queued,it.id); queued=r.queue; renderQueue(); input.value=r.removed?r.removed.text:input.value; input.focus(); autosize(); };
744
+ const x=document.createElement('span'); x.className='qx'; x.textContent='✕'; x.title='从队列移除';
745
+ x.onclick=()=>{ queued=dequeueMessage(queued,it.id).queue; renderQueue(); };
746
+ chip.appendChild(idx); chip.appendChild(txt); chip.appendChild(x);
747
+ strip.appendChild(chip);
748
+ }
749
+ }
750
+
751
+ /** 生成中把输入放进队列;返回是否入队成功(空文本/满员时给可读提示)。 */
752
+ function enqueueInput(text){
753
+ queueSeq+=1;
754
+ const r=enqueueMessage(queued,text,'q'+queueSeq);
755
+ queued=r.queue;
756
+ if(r.ok){ renderQueue(); return true; }
757
+ if(r.reason==='full'){
758
+ const d=document.createElement('div'); d.className='msg-sys';
759
+ d.innerHTML='⚠ 队列已满('+QUEUE_MAX+' 条),先发送或删掉几条再添加。';
760
+ chatEl.appendChild(d); scroll();
761
+ }
762
+ return false;
763
+ }
764
+
765
+ /** 本轮结束后排空队列(用 override 文本直接发下一条)。 */
766
+ function drainQueue(){
767
+ if(!shouldDrainQueue({queue:queued,generating,aborted:runState.aborted})) return;
768
+ const next=queued[0];
769
+ queued=queued.slice(1);
770
+ renderQueue();
771
+ input.value=next.text; autosize();
772
+ // 让 finally 里的收尾(按钮/状态栏/渲染)先跑完,再开下一轮
773
+ setTimeout(()=>{ try{ send(); }catch(e){ console.error('[MingDao] 队列续发失败',e); } },120);
774
+ }
775
+
776
+ // —— 插话队列(v0.6.40,next-step 车道)——
777
+ // 语义:生成中 **Cmd/Ctrl+Enter** → POST /api/steer,服务端在每个 step 边界全量取走,
778
+ // 作为新的 user 消息插进**当前轮**——当前任务不会因此结束;与 Enter 的"排队到下一轮"是两条车道。
779
+ // 失败(网络/404 已结束/429 满/413 超长)一律回落到排队并明确提示,输入不丢。
780
+ let steering=[]; // [{id,text,at}]
781
+ let steerSeq=0;
782
+
783
+ /** 插话 chips:橙色 + ⚡,与蓝色排队 chips 视觉区分。 */
784
+ function renderSteer(){
785
+ const strip=$('#steerStrip'); if(!strip) return;
786
+ const items=steerView(steering);
787
+ if(!items.length){ strip.style.display='none'; strip.innerHTML=''; return; }
788
+ strip.style.display='flex';
789
+ strip.innerHTML='';
790
+ const cap=document.createElement('span'); cap.className='scap';
791
+ cap.textContent='⚡ 插话中 '+items.length+' 条(下一步注入本轮,不结束当前任务;上限 '+STEER_MAX+' 条)';
792
+ strip.appendChild(cap);
793
+ for(const it of items){
794
+ const chip=document.createElement('span'); chip.className='schip';
795
+ const ico=document.createElement('span'); ico.className='si'; ico.textContent=it.icon;
796
+ const txt=document.createElement('span'); txt.className='st'; txt.textContent=it.label; txt.title=it.text;
797
+ // 点文本 → 放回输入框编辑(从插话队列移除,避免"编辑完插两次")
798
+ txt.onclick=()=>{ const r=dequeueMessage(steering,it.id); steering=r.queue; renderSteer(); input.value=r.removed?r.removed.text:input.value; input.focus(); input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; };
799
+ const x=document.createElement('span'); x.className='sx'; x.textContent='✕'; x.title='撤销这条插话';
800
+ x.onclick=()=>{ steering=dequeueMessage(steering,it.id).queue; renderSteer(); };
801
+ chip.appendChild(ico); chip.appendChild(txt); chip.appendChild(x);
802
+ strip.appendChild(chip);
803
+ }
804
+ }
805
+
806
+ /** 注入成功后按文本移除本地 chip(服务端 id 与前端 id 不同源,按文本配对最稳)。 */
807
+ function dropSteerByText(text){
808
+ const t=String(text||'');
809
+ const idx=steering.findIndex(s=>s.text===t);
810
+ if(idx<0) return false;
811
+ steering=[...steering.slice(0,idx),...steering.slice(idx+1)];
812
+ renderSteer();
813
+ return true;
814
+ }
815
+
816
+ /** 插话失败 → 回落到 next-turn 队列(绝不静默吞掉用户输入)。 */
817
+ function fallbackSteer(text,why){
818
+ if(!enqueueInput(text)) return;
819
+ input.value=''; input.style.height='44px';
820
+ const d=document.createElement('div'); d.className='msg-sys';
821
+ d.textContent='⚠ 插话失败('+why+'),已改为排队:本轮结束后自动发送。';
822
+ chatEl.appendChild(d); scroll();
823
+ }
824
+
825
+ /** 生成中 Cmd/Ctrl+Enter:插话(POST /api/steer);失败回落排队。 */
826
+ async function steerInput(){
827
+ const text=input.value.trim();
828
+ if(!text) return;
829
+ if(!generating||!curTaskId){ fallbackSteer(text,'当前回合尚未建立任务'); return; }
830
+ try{
831
+ const r=await fetch('/api/steer',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({taskId:curTaskId,message:text})});
832
+ const j=await r.json().catch(()=>({}));
833
+ if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
834
+ steerSeq+=1;
835
+ const en=enqueueSteer(steering,text,'s'+steerSeq,Date.now());
836
+ steering=en.steer;
837
+ if(en.ok) renderSteer();
838
+ input.value=''; input.style.height='44px';
839
+ const ht=$('#hintText');
840
+ if(ht) ht.textContent='⚡ 插话已入队(共 '+(j.queued||steering.length)+' 条):模型下一步会收到,当前任务不会因此结束 · 普通 Enter 仍为「排队到下一轮」';
841
+ }catch(err){
842
+ fallbackSteer(text,(err&&err.message)||'网络错误');
843
+ }
844
+ }
845
+
846
+ /** 本轮收尾:服务端没来得及注入的插话转回排队(不能因为回合结束就丢掉用户已经写下的内容)。 */
847
+ function settleSteering(){
848
+ if(!steering.length) return;
849
+ const left=steering; steering=[]; renderSteer();
850
+ let moved=0;
851
+ for(const s of left){
852
+ const r=enqueueMessage(queued,s.text,'q'+(queueSeq+=1));
853
+ if(!r.ok) break;
854
+ queued=r.queue; moved+=1;
855
+ }
856
+ const dropped=left.length-moved;
857
+ if(moved) renderQueue();
858
+ const d=document.createElement('div'); d.className='msg-sys';
859
+ d.textContent=moved
860
+ ? '⚠ 本轮结束前插话未及注入,已转为排队:'+moved+' 条'+(dropped?'(另有 '+dropped+' 条因排队已满未保留)':'')
861
+ : '⚠ 插话未能注入,且排队已满:'+dropped+' 条未保留。';
862
+ chatEl.appendChild(d); scroll();
863
+ }
864
+
721
865
  async function send(){
722
866
  const text=input.value.trim();
723
867
  console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
724
868
  if(generating){
725
- // 审计(长任务静默硬伤):任务执行中追问「是否完成」→ 界面立即如实应答当前进度,
726
- // 不再静默吞掉输入;停止职责由「■ 停止」按钮承担(输入框内容保留不丢)。
727
- const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
728
- const d=document.createElement('div'); d.className='msg-sys';
729
- d.innerHTML='⏳ 上一个任务仍在执行(第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒),<b>尚未完成</b>。完成后总结与交付物会自动出现在本轮消息底部;如需立即中断请点「■ 停止」。';
730
- chatEl.appendChild(d); scroll();
869
+ // v0.6.39:生成中继续输入 → **入队**(本轮结束后自动发送),不再只弹一句"仍在执行"。
870
+ // 队列条在输入框上方:点文本回填编辑、点 ✕ 删除;用户中止时不排空(见 drainQueue/aborted)。
871
+ if(enqueueInput(text)){
872
+ input.value=''; input.style.height='44px';
873
+ const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
874
+ const ht=$('#hintText');
875
+ if(ht) ht.textContent='已加入队列(共 '+queued.length+' 条),本轮结束后自动发送 · 当前第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · 要立即中断请点「■ 停止」';
876
+ }
731
877
  return;
732
878
  }
733
879
  if(!text && !attachments.length) return;
@@ -859,6 +1005,13 @@ async function send(){
859
1005
  else if(ev.type==='tool'){ onActivity(); update(); runningTools.delete(ev.seq); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; } renderToolEvent(ev); }
860
1006
  else if(ev.type==='toolDenied'){ onActivity(); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.denied=ev.reason||'未授权'; } renderToolDeniedEvent(ev); const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
861
1007
  else if(ev.type==='banner'){ renderBanner(ev); }
1008
+ else if(ev.type==='steer'){
1009
+ // v0.6.40:queued 是 POST 即时应答(同页 steerInput 已反馈,不重复插行);
1010
+ // injected 才是 agent 真正把它作为 user 消息喂进当前回合——在对话流里留痕。
1011
+ if(ev.queued!==true){ const st=String(ev.text||''); dropSteerByText(st);
1012
+ const d=document.createElement('div'); d.className='msg-sys'; d.textContent='↳ 已插入本轮:'+st.slice(0,60);
1013
+ chatEl.appendChild(d); scroll(); }
1014
+ }
862
1015
  else if(ev.type==='ask'){ runState.rawPhase='等待权限确认'; runState.phase='执行工具'; update(); renderWorkStatus(); handleAskEvent(ev); }
863
1016
  else if(ev.type==='usage'){ onActivity(); runState.cost=String(ev.cost||'');
864
1017
  // P1-4:本**轮**实际用到的模型(服务端 usage.modelName)——不是"当前选择"的猜测。
@@ -905,7 +1058,7 @@ async function send(){
905
1058
  // 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
906
1059
  d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
907
1060
  msg.appendChild(d); scroll(); }
908
- finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); }
1061
+ finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); settleSteering(); drainQueue(); }
909
1062
  }
910
1063
 
911
1064
  let curTaskId=null; // 本轮 SSE 任务 id(质检 L1:主停止按钮只断本任务,不再误伤其他 tab)
@@ -2985,7 +3138,14 @@ async function loadSession(file){ if(currentSession && currentSession!==file) fi
2985
3138
 
2986
3139
  // §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
2987
3140
  sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
2988
- input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
3141
+ input.addEventListener('keydown',e=>{
3142
+ if(e.isComposing) return;
3143
+ if(e.key!=='Enter'||e.shiftKey) return;
3144
+ e.preventDefault();
3145
+ // v0.6.40:生成中 Cmd/Ctrl+Enter = 插话(注入当前轮);Enter = 排队(本轮结束后发送)。
3146
+ if((e.metaKey||e.ctrlKey)&&generating){ steerInput(); return; }
3147
+ send();
3148
+ });
2989
3149
  input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
2990
3150
  $('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); refreshSessions(); };
2991
3151
  $('#sessMgrBtn').onclick=openSessModal;
@@ -218,6 +218,21 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
218
218
  #composer{display:flex;gap:8px;align-items:flex-end}
219
219
  #attachBtn{padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:15px}
220
220
  #attachList{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
221
+ #queueStrip{max-width:860px;margin:0 auto 6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
222
+ #queueStrip .qcap{font-size:12px;color:var(--dim)}
223
+ #queueStrip .qchip{display:inline-flex;align-items:center;gap:6px;max-width:360px;background:rgba(90,160,255,.12);border:1px solid rgba(90,160,255,.38);border-radius:14px;padding:3px 10px;font-size:12.5px;color:var(--fg)}
224
+ #queueStrip .qchip .qi{color:var(--dim)}
225
+ #queueStrip .qchip .qt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text}
226
+ #queueStrip .qchip .qx{cursor:pointer;color:var(--dim)}
227
+ #queueStrip .qchip .qx:hover{color:#ff6b6b}
228
+ /* v0.6.40 插话(next-step)chips:橙色 + ⚡,与蓝色排队 chips 区分(两条车道不混淆) */
229
+ #steerStrip{max-width:860px;margin:0 auto 6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
230
+ #steerStrip .scap{font-size:12px;color:var(--warn)}
231
+ #steerStrip .schip{display:inline-flex;align-items:center;gap:6px;max-width:360px;background:rgba(245,158,11,.14);border:1px solid rgba(245,158,11,.48);border-radius:14px;padding:3px 10px;font-size:12.5px;color:var(--fg)}
232
+ #steerStrip .schip .si{color:var(--warn)}
233
+ #steerStrip .schip .st{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text}
234
+ #steerStrip .schip .sx{cursor:pointer;color:var(--dim)}
235
+ #steerStrip .schip .sx:hover{color:#ff6b6b}
221
236
  .deliver{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;background:var(--bg2);padding:8px 12px;margin:4px 0;font-size:12.5px}
222
237
  .deliver .t{color:var(--accent2);font-weight:600;margin-bottom:2px}
223
238
  .deliver .i{color:var(--dim);padding:1px 0;font-family:ui-monospace,Consolas,monospace}
@@ -861,9 +876,13 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
861
876
  </div>
862
877
  <input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,.txt,.md,.json,.js,.py,.log,.csv,.html,.css" multiple style="display:none">
863
878
  <div id="attachList" style="display:none"></div>
879
+ <!-- v0.6.40 插话(next-step):生成中 Cmd/Ctrl+Enter 会插进当前轮,不结束当前任务 -->
880
+ <div id="steerStrip" style="display:none"></div>
881
+ <!-- v0.6.39 输入队列(next-turn):生成中 Enter 排队,本轮结束后自动依次发出(不打断当前任务) -->
882
+ <div id="queueStrip" style="display:none"></div>
864
883
  <div id="composer">
865
884
  <button id="attachBtn" title="上传图片或文本文件(图片需支持视觉的模型)">📎</button>
866
- <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
885
+ <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送;生成中 Enter=排队、⌘/Ctrl+Enter=插话)"></textarea>
867
886
  <!-- P1-5 会话设置弹层(v0.6.19 §3.70):模型 / 预设 / 权限三件套收进同一个 popover,
868
887
  按钮上直接印当前生效的模型与权限(用户一眼知道"东西没丢")。原三个 select 保留在弹层内
869
888
  (send()/init() 仍按既有 id 读取),不再占输入区。 -->
@@ -1047,9 +1066,9 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
1047
1066
  <div class="shortcut-row" data-shortcut="⌘K"><span class="kbd">⌘K / Ctrl+K</span><span class="grow">命令面板(搜索 / 切换 / 执行)</span></div>
1048
1067
  <div class="shortcut-row" data-shortcut="⌘/"><span class="kbd">⌘/ / Ctrl+/</span><span class="grow">打开本表</span></div>
1049
1068
  <div class="shortcut-row" data-shortcut="Esc"><span class="kbd">Esc</span><span class="grow">分层关闭:浮层 › 确认卡 › 停止本轮</span></div>
1050
- <div class="shortcut-row" data-shortcut="Enter"><span class="kbd">Enter</span><span class="grow">发送</span></div>
1069
+ <div class="shortcut-row" data-shortcut="Enter"><span class="kbd">Enter</span><span class="grow">发送;生成中 = 排队到本轮结束后发送</span></div>
1051
1070
  <div class="shortcut-row" data-shortcut="Shift+Enter"><span class="kbd">Shift+Enter</span><span class="grow">换行</span></div>
1052
- <div class="shortcut-row" data-shortcut="⌘Enter"><span class="kbd">⌘Enter / Ctrl+Enter</span><span class="grow">发送(与 Enter 等价)</span></div>
1071
+ <div class="shortcut-row" data-shortcut="⌘Enter"><span class="kbd">⌘Enter / Ctrl+Enter</span><span class="grow">发送;生成中 = ⚡ 插话(立即注入当前轮,不结束任务)</span></div>
1053
1072
  <div class="shortcut-row" data-shortcut="y/n"><span class="kbd">y / n</span><span class="grow">权限确认:本次放行 / 拒绝(输入框内不触发)</span></div>
1054
1073
  <div class="shortcut-note">输入法组合(isComposing)期间不触发上述快捷键;⌘ 在 Windows/Linux 上为 Ctrl。</div>
1055
1074
  </div>
@@ -109,6 +109,12 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
109
109
  return true;
110
110
  }
111
111
 
112
+ // v0.6.40:插话(next-step 车道)。鉴权/CSRF 已由 routes/api.js 统一完成,这里只做域分发;
113
+ // 缓冲/上限/SSE 应答的唯一实现在 server.js 的 createSteering(路由层不另写一套判定)。
114
+ if (method === 'POST' && p === '/api/steer') {
115
+ return deps.handleSteer(req, res);
116
+ }
117
+
112
118
  if (method === 'POST' && p === '/api/abort') {
113
119
  const body = await readBody(req, MAX_API_BODY);
114
120
  /**
package/src/web/server.js CHANGED
@@ -9,7 +9,8 @@
9
9
  // POST /api/chat {message, file?} → 运行一轮,SSE 事件流
10
10
  // POST /api/permission {id, answer} → 应答权限确认
11
11
  // POST /api/abort 中断当前生成
12
- // SSE 事件:banner/text/reasoning/code/tool/toolDenied/todo/usage/ask/error/done
12
+ // POST /api/steer {taskId, message} → 插话(下一步边界注入当前轮)
13
+ // SSE 事件:banner/text/reasoning/code/tool/toolDenied/todo/usage/ask/steer/error/done
13
14
 
14
15
  import http from 'node:http';
15
16
  import { DEFAULT_MODEL } from '../models.js';
@@ -41,6 +42,8 @@ import { buildSystemPrompt } from '../prompts.js';
41
42
  import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
42
43
  import { saveTaskStateMergeAsync, clearTaskState, loadTaskState, resumePrompt, checkpointHint } from '../task-state.js';
43
44
  import { createWebIO } from './web-io.js';
45
+ // v0.6.40:插话的纯语义(上限/字节口径/全量取走)与前端共用同一份实现,避免服务端另写一套。
46
+ import { enqueueSteer, drainSteer, STEER_MAX, STEER_TEXT_MAX } from './viewmodel.js';
44
47
  import { installPipeGuards } from '../proc.js';
45
48
  import { startMcpServers } from '../mcp.js';
46
49
  import {
@@ -298,6 +301,80 @@ function readBody(req, limit = 40 * 1024 * 1024) {
298
301
  });
299
302
  }
300
303
 
304
+ // ---------------------------------------------------------------------------
305
+ // 插话(v0.6.40):生成中 Cmd/Ctrl+Enter → 插入**当前轮**(不是下一轮)。
306
+ //
307
+ // 每个 task 一份缓冲;agent 在**每个 step 边界**全量 drain 一次,所以此刻 POST 的三条
308
+ // 会一起进入下一次请求,而不是一条一步。上限/字节口径复用 viewmodel.js 的纯函数
309
+ // (前端 chip 与服务端拒绝同一套判据),本工厂只负责"按 taskId 存取 + HTTP 应答 + SSE 通知"。
310
+ // ---------------------------------------------------------------------------
311
+ /** 普通 JSON 接口的请求体上限(与 routes/api.js 的 MAX_API_BODY 同值)。 */
312
+ const STEER_BODY_LIMIT = 1024 * 1024;
313
+
314
+ /**
315
+ * 创建插话缓冲与其唯一消费者(放在 runWebServer 内,多个 server 实例互不串味)。
316
+ *
317
+ * @param {{ tasks: Map<string, any>, srvlog?: (line: string) => void }} deps
318
+ * @returns {{
319
+ * state: {buffers: Map<string, Array<{id:string,text:string,at?:number}>>, seq: number},
320
+ * handleSteer: (req: any, res: any) => Promise<boolean>,
321
+ * drain: (taskId: string) => Array<{id:string,text:string,at?:number}>,
322
+ * clear: (taskId: string) => void,
323
+ * }}
324
+ */
325
+ export function createSteering({ tasks, srvlog }) {
326
+ const state = { buffers: new Map(), seq: 0 };
327
+ /** @param {string} taskId */
328
+ const clear = (taskId) => {
329
+ state.buffers.delete(taskId);
330
+ };
331
+ /** @param {string} taskId @returns {Array<{id:string,text:string,at?:number}>} */
332
+ const drain = (taskId) => {
333
+ const list = state.buffers.get(taskId) || [];
334
+ if (!list.length) return [];
335
+ const r = drainSteer(list);
336
+ state.buffers.set(taskId, r.steer);
337
+ return r.taken;
338
+ };
339
+ /** POST /api/steer 的唯一实现(鉴权/CSRF 由 routes/api.js 在域分发之前统一完成)。 */
340
+ const handleSteer = async (/** @type {any} */ req, /** @type {any} */ res) => {
341
+ let body;
342
+ try {
343
+ body = await readBody(req, STEER_BODY_LIMIT);
344
+ } catch (/** @type {any} */ e) {
345
+ return json(res, e?.status || 400, { error: e?.message || '请求体读取失败' });
346
+ }
347
+ const taskId = String(body?.taskId || '');
348
+ const entry = tasks.get(taskId);
349
+ // 只接受**运行中**的任务:已完成的任务保留在 tasks 里 10 分钟只是任务面板历史,
350
+ // 往它的缓冲里写东西永远没人消费(泄漏),而且对用户是"假成功"。
351
+ if (!entry || entry.status !== 'running') {
352
+ return json(res, 404, { error: '任务不存在或已结束(插话只能发给运行中的任务)' });
353
+ }
354
+ state.seq += 1;
355
+ const list = state.buffers.get(taskId) || [];
356
+ const r = enqueueSteer(list, String(body?.message == null ? '' : body.message), 's' + state.seq, Date.now());
357
+ if (!r.ok) {
358
+ if (r.reason === 'too-long') {
359
+ return json(res, 413, { error: `插话过长(上限 ${Math.round(STEER_TEXT_MAX / 1024)}KB,UTF-8 字节)——请拆短或改用排队(Enter)分次发送。` });
360
+ }
361
+ if (r.reason === 'full') {
362
+ return json(res, 429, { error: `插话队列已满(上限 ${STEER_MAX} 条)——等下一步注入后再插,或按 Enter 排队。` });
363
+ }
364
+ return json(res, 400, { error: '插话不能为空。' });
365
+ }
366
+ state.buffers.set(taskId, r.steer);
367
+ const text = r.steer[r.steer.length - 1].text;
368
+ // 入队成功 → 即时应答(queued:true);真正注入时 agent 还会再推 injected:true。
369
+ try {
370
+ entry.send?.({ type: 'steer', text, queued: true, queuedCount: r.steer.length });
371
+ } catch {}
372
+ srvlog?.(`chat 插话入队 ${taskId} 第 ${r.steer.length} 条(${String(text).length} 字)`);
373
+ return json(res, 200, { ok: true, queued: r.steer.length });
374
+ };
375
+ return { state, handleSteer, drain, clear };
376
+ }
377
+
301
378
  /** @param {{ host?: string, port?: number, authToken?: string|null, onBusy?: (busy: boolean) => void, [key: string]: any }} [opts] */
302
379
  export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken, onBusy } = {}) {
303
380
  // v0.6.2:常驻服务**不能**因日志管道断开而自杀——重新安装为「静默吞掉 EPIPE」策略。
@@ -391,6 +468,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
391
468
  // 任务注册表:支持多会话并行——每个任务独立的 SSE 流、权限确认、中断控制
392
469
  let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
393
470
  const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
471
+ const steering = createSteering({ tasks, srvlog }); // v0.6.40:每任务一份插话缓冲(结束后 clear)
394
472
  // v0.4.7(T10):正在跑回合的会话文件集合——同一会话同一时刻只允许一个回合。
395
473
  // 此前 /api/chat 各自 loadSession → 各自推理 → 按完成顺序追加:会话记录会交错
396
474
  // (A/B 互相看不到对方的 user 消息、回答归属错乱);若其中一路触发自动压缩,
@@ -568,8 +646,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
568
646
  try {
569
647
  res.end();
570
648
  } catch {}
571
- // ⑦ prune + 释放闭包
649
+ // ⑦ prune + 释放闭包(插话缓冲随任务结束清掉,避免 Map 随会话数无限增长)
572
650
  entry.send = null;
651
+ steering.clear(taskId);
573
652
  try {
574
653
  pruneTasks();
575
654
  } catch {}
@@ -1010,7 +1089,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
1010
1089
  const agent = createAgent({
1011
1090
  provider: providerNow,
1012
1091
  permission,
1013
- io,
1092
+ io, steering: { drain: () => steering.drain(taskId) }, // v0.6.40:每步边界全量取走本任务插话
1014
1093
  modelName: runModel,
1015
1094
  workingDir: taskDir,
1016
1095
  cfg: chatCfg, // v0.4.0:预设覆盖后的配置(工具白名单 presetTools/参数/权限)
@@ -1192,7 +1271,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
1192
1271
  const dispatch = createApiDispatch({
1193
1272
  json, srvlog, readBody, cfg, home,
1194
1273
  providerCache, invalidateProviderCache, getProviderFor, tasks,
1195
- MAX_CONCURRENT, pruneTasks, handleChat,
1274
+ MAX_CONCURRENT, pruneTasks, handleChat, handleSteer: steering.handleSteer,
1196
1275
  mcpFacade, provider, validateRemoteUrl, isPrivateHost,
1197
1276
  authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd, sessionMemoryCache,
1198
1277
  state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
@@ -445,3 +445,164 @@ export function traceRoundView(round) {
445
445
  summary: parts.join(' · '),
446
446
  };
447
447
  }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // 输入队列(v0.6.39):生成中继续输入 → 排队,本轮结束后自动发出
451
+ //
452
+ // 为什么放在这里而不是直接写在 app.js:队列的**语义**(去重、上限、截断、何时排空)是纯逻辑,
453
+ // 必须能被单测钉住;DOM 渲染只是把它画出来。DSH 等 harness 的做法也是"输入先入队、在回合边界消费"。
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /** 队列上限:防止用户连点把内存撑爆(也避免"一次发 50 条"把模型打爆)。 */
457
+ export const QUEUE_MAX = 20;
458
+ /** chip 上显示的最大字符数。 */
459
+ export const QUEUE_LABEL_MAX = 42;
460
+
461
+ /**
462
+ * 把一条输入加入队列。
463
+ * 空文本直接拒绝(不要让空 chip 出现在界面上);满员拒绝并给出可读原因。
464
+ * @param {Array<{id:string,text:string}>} queue
465
+ * @param {string} text
466
+ * @param {string} id
467
+ * @returns {{ok:boolean, queue:Array<{id:string,text:string}>, reason?:string}}
468
+ */
469
+ export function enqueueMessage(queue, text, id) {
470
+ const list = Array.isArray(queue) ? queue : [];
471
+ const t = String(text == null ? '' : text);
472
+ if (!t.trim()) return { ok: false, queue: list, reason: 'empty' };
473
+ if (list.length >= QUEUE_MAX) return { ok: false, queue: list, reason: 'full' };
474
+ return { ok: true, queue: [...list, { id: String(id || ''), text: t }] };
475
+ }
476
+
477
+ /**
478
+ * 从队列里移除指定 id。
479
+ * @param {Array<{id:string,text:string}>} queue
480
+ * @param {string} id
481
+ * @returns {{queue:Array<{id:string,text:string}>, removed:({id:string,text:string}|null)}}
482
+ */
483
+ export function dequeueMessage(queue, id) {
484
+ const list = Array.isArray(queue) ? queue : [];
485
+ const idx = list.findIndex((q) => String(q?.id) === String(id));
486
+ if (idx < 0) return { queue: list, removed: null };
487
+ return { queue: [...list.slice(0, idx), ...list.slice(idx + 1)], removed: list[idx] };
488
+ }
489
+
490
+ /**
491
+ * chip 上的单行显示文本(换行压成空格,超长截断加省略号)。
492
+ * @param {{text?:string}} item
493
+ * @returns {string}
494
+ */
495
+ export function queueLabel(item) {
496
+ const t = String(item?.text ?? '').replace(/\s+/g, ' ').trim();
497
+ if (!t) return '';
498
+ return t.length > QUEUE_LABEL_MAX ? t.slice(0, QUEUE_LABEL_MAX - 1) + '…' : t;
499
+ }
500
+
501
+ /**
502
+ * 本轮是否可以排空队列。
503
+ * 关键:**用户主动中止(aborted)时不排空**——中止的语义是"停下",此时接着发下一条会违背用户意图;
504
+ * 而"任务完成""出错"都应当继续把用户已经写好的下一条发出去(用户已经表达了意图)。
505
+ * @param {{queue?:Array<any>, generating?:boolean, aborted?:boolean}} st
506
+ * @returns {boolean}
507
+ */
508
+ export function shouldDrainQueue(st) {
509
+ const q = Array.isArray(st?.queue) ? st.queue : [];
510
+ return q.length > 0 && !st?.generating && !st?.aborted;
511
+ }
512
+
513
+ /**
514
+ * 队列的渲染模型(含序号、截断文本)。
515
+ * @param {Array<{id:string,text:string}>} queue
516
+ * @returns {Array<{id:string,index:number,label:string,text:string}>}
517
+ */
518
+ export function queueView(queue) {
519
+ const list = Array.isArray(queue) ? queue : [];
520
+ return list.map((q, i) => ({ id: String(q?.id || ''), index: i + 1, label: queueLabel(q), text: String(q?.text ?? '') }));
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // 插话(v0.6.40):生成中 Cmd/Ctrl+Enter → **插入当前轮**,不是排到下一轮
525
+ //
526
+ // 与 next-turn 队列(上面的 enqueueMessage 一族)是**两条车道**:
527
+ // · next-turn:本轮全部结束后,才按每轮 1 条依次发出(drainQueue);
528
+ // · next-step:每个 step 边界**全量取走**(drainSteer),作为新的 user 消息插进当前请求;
529
+ // 模型本次没有工具调用、本来要收尾时,只要 next-step 非空就继续下一步(shouldContinueAfterModel)。
530
+ // 纯逻辑仍放在这里:上限、字节口径、全量取走、是否继续,都必须被单测钉住。
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /** 插话队列上限(Octop 的 STEER_DEPTH_CAP 同值):一次最多积 8 条,多则拒绝。 */
534
+ export const STEER_MAX = 8;
535
+ /** 单条插话文本上限:200KB(UTF-8 字节,不是字符数——中文一字 3 字节)。 */
536
+ export const STEER_TEXT_MAX = 200 * 1024;
537
+
538
+ /**
539
+ * 文本的 UTF-8 字节数(浏览器与 Node 同一口径;TextEncoder 不可用时退回字符数)。
540
+ * @param {unknown} text
541
+ * @returns {number}
542
+ */
543
+ export function steerBytes(text) {
544
+ const t = String(text == null ? '' : text);
545
+ try {
546
+ return new TextEncoder().encode(t).length;
547
+ } catch {
548
+ return t.length;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * 把一条输入加入**插话**队列(下一步注入本轮)。
554
+ * 空文本 / 超长(>200KB)/ 满员(≥8 条)都拒绝并给出可读 reason;超长先于满员判定,
555
+ * 这样"又能插但太长"始终得到 413 而不是 429。
556
+ * @param {Array<{id:string,text:string,at?:number}>} steer
557
+ * @param {string} text
558
+ * @param {string} id
559
+ * @param {number} [at] 入队时刻(由调用方注入,保持本函数纯净;缺省 0)
560
+ * @returns {{ok:boolean, steer:Array<{id:string,text:string,at?:number}>, reason?:string}}
561
+ */
562
+ export function enqueueSteer(steer, text, id, at) {
563
+ const list = Array.isArray(steer) ? steer : [];
564
+ const t = String(text == null ? '' : text);
565
+ if (!t.trim()) return { ok: false, steer: list, reason: 'empty' };
566
+ if (steerBytes(t) > STEER_TEXT_MAX) return { ok: false, steer: list, reason: 'too-long' };
567
+ if (list.length >= STEER_MAX) return { ok: false, steer: list, reason: 'full' };
568
+ return { ok: true, steer: [...list, { id: String(id || ''), text: t, at: Number(at) || 0 }] };
569
+ }
570
+
571
+ /**
572
+ * **全量取走**插话队列(每个 step 边界消费一次,不是每条一步)。
573
+ * 取走后原队列变空;对空队列再调用返回空 taken(幂等,不抛)。
574
+ * @param {Array<{id:string,text:string,at?:number}>} steer
575
+ * @returns {{steer:Array<{id:string,text:string,at?:number}>, taken:Array<{id:string,text:string,at?:number}>}}
576
+ */
577
+ export function drainSteer(steer) {
578
+ const list = Array.isArray(steer) ? steer : [];
579
+ return { steer: [], taken: [...list] };
580
+ }
581
+
582
+ /**
583
+ * 插话 chip 的渲染模型(与 queueView 同形,另带 kind/icon 供 UI 用不同配色区分)。
584
+ * @param {Array<{id:string,text:string,at?:number}>} steer
585
+ * @returns {Array<{id:string,index:number,label:string,text:string,kind:'steer',icon:'⚡'}>}
586
+ */
587
+ export function steerView(steer) {
588
+ const list = Array.isArray(steer) ? steer : [];
589
+ return list.map((s, i) => ({
590
+ id: String(s?.id || ''),
591
+ index: i + 1,
592
+ label: queueLabel(s),
593
+ text: String(s?.text ?? ''),
594
+ kind: /** @type {'steer'} */ ('steer'),
595
+ icon: /** @type {'⚡'} */ ('⚡'),
596
+ }));
597
+ }
598
+
599
+ /**
600
+ * 模型本次没有工具调用、本来要收尾时,是否应当**继续下一步**(而不是结束本轮)。
601
+ * 有插话就继续;用户中止 / 无进展看门狗触发时不继续("停下"优先于插话)。
602
+ * @param {{steer?:Array<any>, aborted?:boolean, stalled?:boolean}} st
603
+ * @returns {boolean}
604
+ */
605
+ export function shouldContinueAfterModel(st) {
606
+ const list = Array.isArray(st?.steer) ? st.steer : [];
607
+ return list.length > 0 && !st?.aborted && !st?.stalled;
608
+ }
package/src/web/web-io.js CHANGED
@@ -115,6 +115,14 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
115
115
  renderTodo(todos) {
116
116
  send({ type: 'todo', todos });
117
117
  },
118
+ /**
119
+ * v0.6.40:插话已真正注入当前回合——`injected:true` 与 POST /api/steer 的即时应答
120
+ * (`queued:true`)区分开,前端只对后者之外的事件在对话流里写「↳ 已插入本轮」。
121
+ * @param {any} text
122
+ */
123
+ steer(text) {
124
+ send({ type: 'steer', text: String(text ?? ''), injected: true });
125
+ },
118
126
  /** @param {{ modelName: any, usage: any, durationMs: any }} param0 */
119
127
  printUsageLine({ modelName, usage, durationMs }) {
120
128
  send({