mingdao-harness 0.6.22 → 0.6.24

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/docs/CONFIG.md CHANGED
@@ -406,9 +406,10 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
406
406
 
407
407
  `GET /api/session?file=` 的每条消息**只增** `ts`(ISO 字符串)。旧会话(v0.6.21 及以前写的
408
408
  JSONL 没有 `ts`)返回 `"ts": null`,其余字段语义不变;WebUI 对 `ts` 为 `null` 的消息不渲染时间,
409
- 不会出现 `Invalid Date` / `undefined`。新消息在**服务端落盘时**写入 `ts`(压缩重写保留已有值),
410
- 前端不自行推算历史消息时间。分组(今天/昨天/本周/更早)与时间显示都在 `src/web/app.js`;
411
- 分组只在会话列表刷新时计算一次,不引入轮询。
409
+ 不会出现 `Invalid Date` / `undefined`。`ts` 由 `session.appendMessages` **统一补齐**(v0.6.23,§3.87):
410
+ CLI / REPL / 调度 / Web 写入都走这一个入口,只补缺少 `ts` 的消息、已有值原样保留(重复写入与压缩
411
+ 重写不刷新时间);读取路径不回填、不改写旧文件,因此旧会话仍返回 `null`。前端不自行推算历史消息时间。
412
+ 分组(今天/昨天/本周/更早)与时间显示都在 `src/web/app.js`;分组只在会话列表刷新时计算一次,不引入轮询。
412
413
 
413
414
  ## 模型名称以 API 为准(v0.6.19,§3.67)
414
415
 
@@ -924,11 +925,29 @@ mingdao ledger verify <runId> --key <文件> # 用指定公钥验签(本
924
925
  顺手一次就会把签名密钥清掉、让全部历史账本变成"另一把密钥签发"。
925
926
  - **默认开启**:新账本在首次收尾时若本机还没有密钥会**自动生成**并签名(可选签名在合规上等于
926
927
  没有签名)。封条里带 `sig{alg,keyId,value}`,`run.end` 链内带 `sigKey`(公钥指纹)。
927
- - **`run.start.permissionSource`(v0.6.17 §3.64)**:记录本回合生效权限的**来源**——`explicit`
928
- (用户/调用方显式选择,如 WebUI 权限档)/ `preset`(预设声明生效)/ `config`(`config.json`
929
- 的 `permission`)/ `cli`(后台/调度任务由 CLI 参数指定)/ `default`(以上都没有,落到内置缺省
930
- `ask`;WebUI 的 `config` 分支在缺字段时也归 `config`)。`permission` 仍是生效值、`LEDGER_VERSION`
931
- 不变;旧账本没有该字段照样 `verify` 通过(`show` 只是少一行可选的「权限来源」)。
928
+ - **`run.start.permissionSource`(v0.6.17 §3.64,v0.6.24 §3.89 补全 worker/REPL)**:记录本回合
929
+ 生效权限的**来源**。`permission` 仍是生效值,两者分开记——单看生效值无法回答「是谁定的」。
930
+ **完整枚举**(`src/ledger.js` 的 `PERMISSION_SOURCES`;缺失/非法一律写 `null`,不猜):
931
+
932
+ | 值 | 含义 |
933
+ | --- | --- |
934
+ | `explicit` | 用户/调用方在**本回合交互**里显式选择(WebUI 权限档) |
935
+ | `preset` | 预设声明并被采纳的 `permission`(已过反提权判定) |
936
+ | `config` | `config.json` 的 `permission` |
937
+ | `cli` | 前台 CLI 显式参数(保留值;`mingdao run --permission` 属后台任务,按下面的 `job` 记) |
938
+ | `job` | **后台任务/调度**的任务规格显式指定 `permission`(`mingdao run --permission ...`、`schedule add --permission ...`)。v0.6.24 §3.89 新增;刻意不复用 `explicit`,避免与「用户跑的时候按了权限档」混淆 |
939
+ | `default` | 以上都没有,落到内置缺省 `ask` |
940
+
941
+ **各入口取值口径**:
942
+
943
+ | 入口 | 写入值 | 时机 |
944
+ | --- | --- | --- |
945
+ | WebUI | `explicit` / `preset` / `config` | 显式权限档 → `explicit`;预设声明 → `preset`;都不发 → `config`(`resolveTurnPermission` 的既有语义,配置缺字段也归 `config`) |
946
+ | CLI 单次问答 | `preset` / `config` / `default` | `--preset` 声明 permission → `preset`;`config.permission` 存在 → `config`;否则 → `default` |
947
+ | 后台 worker / 调度 | `job` / `config` / `default` | 任务规格(含 `mingdao run --permission`、schedule 的 `permission`)显式指定 → `job`;否则 `config.permission` → `config`;都没有 → `default`。worker 的 `ask → readonly` 降级只改生效值,来源仍按上述口径记 |
948
+ | REPL | `preset` / `config` / `default` | 启动时 `--preset` 已声明 permission → `preset`;否则 `config`(有 `permission`)/ `default`(无)。会话内 `/preset` 且预设 permission 被采纳 → `preset`;仅换提示词/工具白名单的预设不冒充来源,沿用切换前的值。当前没有 `/permission` 命令 |
949
+
950
+ `LEDGER_VERSION` 不变;旧账本没有该字段照样 `verify` 通过(`show` 只是少一行可选的「权限来源」)。
932
951
  - **`ledger verify` 的三种结论**(前两者退出码 0,第三者非 0):
933
952
 
934
953
  | 结论 | 含义 | 退出码 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.22",
3
+ "version": "0.6.24",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,12 +26,79 @@ import { AsyncLocalStorage } from 'node:async_hooks';
26
26
  import { setTimeout as sleepAsync } from 'node:timers/promises';
27
27
  import { procAlive } from './proc.js';
28
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // 瞬时拒绝(Windows)与有界重试(v0.6.23,§3.86)
31
+ //
32
+ // 证据:CI Windows 腿的 BUG-068(8 个子进程并发 saveTaskStateMerge)曾出现 7/8、6/8,
33
+ // rerun 换运气;macOS/Linux 万条×5 轮、64 进程×5 轮 0 丢。子进程把 saveTaskStateMerge 的
34
+ // {ok:false} 丢进 stdio:'ignore' 后照常 exit 0,所以父进程只看到一个没有归因的差数。
35
+ // 复查后确认:丢更新不是原因,**瞬时拒绝被当成永久失败**才是——NTFS 上刚 unlink 的
36
+ // `.lock` 仍处于 delete-pending 时 `open(…,'wx')` 会被拒 EACCES/EPERM;Defender/索引器
37
+ // 正在扫描刚写出的 `.tmp` 时 `rename` 会被拒 EACCES/EPERM/EBUSY。这两种都不是"权限配错",
38
+ // 也不是"别人长期持锁",而是**争用的一种**:等几十~几百毫秒就会自己消失。
39
+ //
40
+ // 所以把 EACCES/EPERM/EBUSY 与 EEXIST 一样纳入"等待 + 重试",但**单独给 2 秒预算**:
41
+ // · 初始 5ms:锁刚被 unlink 时 delete-pending 通常几个 tick 就清掉,早试能少等;
42
+ // · 每次 ×2、封顶 100ms:既不在前 100ms 里高频敲文件系统(杀软扫描时高频重试只会更糟),
43
+ // 又保证晚期的恢复延迟不超过 100ms;
44
+ // · 总预算 2000ms:Windows 杀软/索引器的单文件扫描窗口在数十~数百 ms 量级,
45
+ // 2s 有约 10~100 倍余量;同时**有界**——真正的 ACL/只读目录问题不会把进程永久冻住,
46
+ // 也不会把一次保存拖成整个 WebUI 的几十秒卡顿(同步锁仍走 Atomics.wait)。
47
+ // · EEXIST(确实有人在持锁)不适用这个 2s 预算:它继续走原来的 timeoutMs(5s)/staleMs(4s)。
48
+ // 预算耗尽时**原样抛出原始错误**(保留 errno/path),不折成"锁超时"——后者会把权限/杀软
49
+ // 问题伪装成死锁,用户按提示删锁也没用。
50
+ // ---------------------------------------------------------------------------
51
+ const TRANSIENT_DENY_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']);
52
+ const TRANSIENT_RETRY_INITIAL_MS = 5;
53
+ const TRANSIENT_RETRY_MAX_MS = 100;
54
+ const TRANSIENT_RETRY_BUDGET_MS = 2000;
55
+
56
+ /** 某个 errno 是否属于「等一会儿可能自己好」的瞬时拒绝。 @param {any} code */
57
+ export function isTransientDenyCode(code) {
58
+ return TRANSIENT_DENY_CODES.has(String(code || ''));
59
+ }
60
+
61
+ /** 退避参数(导出供诊断与测试断言「5–10ms 起 / 封顶 / 总预算有界」)。 */
62
+ export function retryDefaults() {
63
+ return {
64
+ initialMs: TRANSIENT_RETRY_INITIAL_MS,
65
+ maxDelayMs: TRANSIENT_RETRY_MAX_MS,
66
+ budgetMs: TRANSIENT_RETRY_BUDGET_MS,
67
+ codes: [...TRANSIENT_DENY_CODES],
68
+ };
69
+ }
70
+
71
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
72
+ const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
73
+
74
+ /**
75
+ * 在 deadline 之前对瞬时拒绝做指数退避重试;非瞬时错误与预算耗尽都**原样抛出**。
76
+ * deadline 由调用方传入:一次原子写的 write+rename 共享同一份预算,避免两段各等 2 秒。
77
+ * @template T @param {() => T} fn @param {number} deadline @returns {T}
78
+ */
79
+ function retryTransientSync(fn, deadline) {
80
+ let delay = TRANSIENT_RETRY_INITIAL_MS;
81
+ for (;;) {
82
+ try {
83
+ return fn();
84
+ } catch (err) {
85
+ if (!isTransientDenyCode(/** @type {any} */ (err)?.code)) throw err;
86
+ const remain = deadline - Date.now();
87
+ if (remain <= 0) throw err;
88
+ sleepMs(Math.min(delay, remain));
89
+ delay = Math.min(delay * 2, TRANSIENT_RETRY_MAX_MS);
90
+ }
91
+ }
92
+ }
93
+
29
94
  export function atomicWriteFileSync(/** @type {string} */ target, /** @type {string|Buffer} */ data, /** @type {any} */ options = {}) {
30
95
  const dir = path.dirname(target);
31
96
  const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
97
+ const deadline = Date.now() + TRANSIENT_RETRY_BUDGET_MS;
32
98
  try {
33
- fs.writeFileSync(tmp, data, options);
34
- fs.renameSync(tmp, target);
99
+ // Windows:杀软/索引器短暂持有新建的 .tmp(EACCES/EPERM/EBUSY)或拒绝覆盖目标,都等一等再试。
100
+ retryTransientSync(() => fs.writeFileSync(tmp, data, options), deadline);
101
+ retryTransientSync(() => fs.renameSync(tmp, target), deadline);
35
102
  } catch (err) {
36
103
  try {
37
104
  fs.unlinkSync(tmp);
@@ -81,8 +148,6 @@ export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any
81
148
 
82
149
  // 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步/异步);异常/完成释放。
83
150
  // 进程崩溃遗留的陈旧锁自动回收,避免永久卡死。
84
- const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
85
- const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
86
151
 
87
152
  // 可重入集合按**调用链**隔离(见文件头注释:异步临界区会 yield,进程级 Set 会让并发任务互相穿透)
88
153
  const lockScope = new AsyncLocalStorage();
@@ -95,16 +160,23 @@ function runWithHeld(fn) {
95
160
  }
96
161
 
97
162
  /**
98
- * 尝试独占创建锁文件。EEXIST 表示别人持有(返回 false,由调用方决定等待或重试);
99
- * 其它错误(权限/路径非法)**直接抛出**——那不是"有人在用",装成争用只会掩盖真问题。
163
+ * 尝试独占创建锁文件。**返回结构化结果而不是用异常表达"忙"**:
164
+ * · `{ok:true}` —— 拿到了锁;
165
+ * · `{ok:false, code:'EEXIST', error}` —— 锁确实被别人持有(按 timeoutMs 等待,可回收陈旧锁);
166
+ * · `{ok:false, code:'EACCES'|'EPERM'|'EBUSY', error}` —— Windows 的**瞬时拒绝**:NTFS
167
+ * delete-pending / 杀软/索引器扫描 / 共享冲突。它既不是"权限配错"也不是"长期持锁",
168
+ * 由调用方走独立的 2s 退避预算;预算耗尽时抛 `error`(保留 errno 原文)。
169
+ * 其余错误(路径非法等)仍**直接抛出**——那不是争用,装成争用只会掩盖真问题。
100
170
  * @param {string} lockPath
171
+ * @returns {{ok: true} | {ok: false, code: string, error: any}}
101
172
  */
102
173
  function tryAcquire(lockPath) {
103
174
  let fd;
104
175
  try {
105
176
  fd = fs.openSync(lockPath, 'wx');
106
177
  } catch (err) {
107
- if (/** @type {any} */ (err)?.code === 'EEXIST') return false;
178
+ const code = String(/** @type {any} */ (err)?.code || '');
179
+ if (code === 'EEXIST' || isTransientDenyCode(code)) return { ok: false, code, error: err };
108
180
  throw err;
109
181
  }
110
182
  try {
@@ -124,7 +196,7 @@ function tryAcquire(lockPath) {
124
196
  } finally {
125
197
  fs.closeSync(fd);
126
198
  }
127
- return true;
199
+ return { ok: true };
128
200
  }
129
201
 
130
202
  /** @param {string} lockPath */
@@ -217,39 +289,34 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
217
289
  if (held.has(lockPath)) return fn();
218
290
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
219
291
  const t0 = Date.now();
292
+ let delay = TRANSIENT_RETRY_INITIAL_MS;
293
+ // 第一次遇到瞬时拒绝的时刻(EEXIST 不参与这个预算,见文件头参数说明)
294
+ let transientSince = 0;
220
295
  for (;;) {
221
- // v0.4.7:区分「抢锁阶段」与「执行 fn 阶段」。此前 fn 自身抛出的 EEXIST 也会落进下方的
222
- // 锁重试分支,而 finally 已释放锁 → 重抢成功后再次执行 fn → 再次抛出 → **同步死循环**
223
- // (100% CPU,连超时分支都不可达)。acquiring 在 fn 开始执行前置 false。
224
- let acquiring = true;
225
- try {
226
- if (!tryAcquire(lockPath)) {
227
- const e = /** @type {any} */ (new Error('EEXIST'));
228
- e.code = 'EEXIST';
229
- throw e;
230
- }
296
+ const got = tryAcquire(lockPath);
297
+ if (got.ok) {
231
298
  held.add(lockPath);
232
- acquiring = false; // 锁已持有:此后 fn 的任何异常都不得进入锁重试分支
299
+ // v0.4.7:fn 在 try/finally 里直接执行,**不进入抢锁的 catch**——
300
+ // 否则 fn 自身抛出的 EEXIST 会被当成抢锁失败,重抢后再次执行 fn → 同步死循环。
233
301
  try {
234
302
  return fn();
235
303
  } finally {
236
304
  held.delete(lockPath);
237
305
  releaseLock(lockPath);
238
306
  }
239
- } catch (err) {
240
- if (!acquiring) throw err; // fn 自身的异常绝不进入锁重试
241
- if (/** @type {any} */ (err).code !== 'EEXIST') throw err;
242
- // 审计 BUG-047(实测复现):回收陈旧锁的分支原先直接 `continue`,**跳过了下面的超时判断**——
243
- // 当陈旧锁删不掉(只读目录 / EPERM / EIO;实测 chmod 555 即可复现)时 reclaimIfStale
244
- // 永远返回 true,于是无限空转:99% CPU、5 秒超时失效、进程不返回。
245
- // 「回收过」不构成「可以不计时地重试」,两条路径都必须过超时闸。
246
- const reclaimed = reclaimIfStale(lockPath, staleMs);
247
- if (Date.now() - t0 > timeoutMs) {
248
- throw lockTimeoutError(lockPath, timeoutMs);
249
- }
250
- sleepMs(25);
251
- if (reclaimed) continue; // 回收后立刻重试;否则同样按 25ms 节流再试
252
307
  }
308
+ if (got.code === 'EEXIST') {
309
+ // 审计 BUG-047:回收陈旧锁的分支不得绕过超时闸,否则陈旧锁删不掉(只读目录/EPERM)
310
+ // 时 reclaimIfStale 恒返回 true → 无限空转、超时失效。
311
+ reclaimIfStale(lockPath, staleMs);
312
+ if (Date.now() - t0 > timeoutMs) throw lockTimeoutError(lockPath, timeoutMs);
313
+ } else {
314
+ // EACCES/EPERM/EBUSY:Windows 的瞬时拒绝,走独立的 2s 预算;耗尽时抛原始 errno。
315
+ if (!transientSince) transientSince = Date.now();
316
+ if (Date.now() - transientSince >= TRANSIENT_RETRY_BUDGET_MS) throw got.error;
317
+ }
318
+ sleepMs(delay);
319
+ delay = Math.min(delay * 2, TRANSIENT_RETRY_MAX_MS);
253
320
  }
254
321
  });
255
322
  }
@@ -262,15 +329,21 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
262
329
  * 为什么值得单独一个函数而不是把同步版改成异步:调用链里不少地方本身就是同步的
263
330
  * (CLI 一次性命令、调度守护进程内部的读-改-写),强行异步化会把 async 传染到 24 个调用点,
264
331
  * 回归风险大于收益。做法是**逐个判断**:请求路径上的迁移,纯同步链保留同步版。
332
+ *
333
+ * 等待策略与同步版逐字一致:EEXIST 走 timeoutMs,EACCES/EPERM/EBUSY 走 2s 瞬时预算;
334
+ * `pollMs` 是**首次**等待时长,之后每次 ×2、封顶 100ms(旧版是固定 25ms 轮询)。
265
335
  * @param {string} lockPath @param {() => any} fn
266
336
  */
267
- export async function withFileLock(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = DEFAULT_TIMEOUT_MS, staleMs = DEFAULT_STALE_MS, pollMs = 25 } = {}) {
337
+ export async function withFileLock(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = DEFAULT_TIMEOUT_MS, staleMs = DEFAULT_STALE_MS, pollMs = TRANSIENT_RETRY_INITIAL_MS } = {}) {
268
338
  return runWithHeld(async (held) => {
269
339
  if (held.has(lockPath)) return fn();
270
340
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
271
341
  const t0 = Date.now();
342
+ let delay = Number(pollMs) > 0 ? Number(pollMs) : TRANSIENT_RETRY_INITIAL_MS;
343
+ let transientSince = 0;
272
344
  for (;;) {
273
- if (tryAcquire(lockPath)) {
345
+ const got = tryAcquire(lockPath);
346
+ if (got.ok) {
274
347
  held.add(lockPath);
275
348
  try {
276
349
  return await fn();
@@ -281,13 +354,16 @@ export async function withFileLock(/** @type {string} */ lockPath, /** @type {()
281
354
  releaseLock(lockPath);
282
355
  }
283
356
  }
284
- // 与同步版同一条修正(审计 BUG-047):回收分支不得绕过超时闸,否则陈旧锁删不掉时永远自旋
285
- const reclaimed = reclaimIfStale(lockPath, staleMs);
286
- if (Date.now() - t0 > timeoutMs) {
287
- throw lockTimeoutError(lockPath, timeoutMs);
357
+ if (got.code === 'EEXIST') {
358
+ // 与同步版同一条修正(审计 BUG-047):回收分支不得绕过超时闸,否则陈旧锁删不掉时永远自旋
359
+ reclaimIfStale(lockPath, staleMs);
360
+ if (Date.now() - t0 > timeoutMs) throw lockTimeoutError(lockPath, timeoutMs);
361
+ } else {
362
+ if (!transientSince) transientSince = Date.now();
363
+ if (Date.now() - transientSince >= TRANSIENT_RETRY_BUDGET_MS) throw got.error;
288
364
  }
289
- await sleepAsync(pollMs);
290
- if (reclaimed) continue;
365
+ await sleepAsync(delay);
366
+ delay = Math.min(delay * 2, TRANSIENT_RETRY_MAX_MS);
291
367
  }
292
368
  });
293
369
  }
@@ -96,8 +96,16 @@ export async function runRepl(ctx) {
96
96
  const { io, cfg, home, pc0, opts, permission, workingDir, sessionUndoStore, mcpFacade, sessionRef, preset, withJournal, tuiState } = ctx;
97
97
  let presetBlock = ctx.presetBlock || ''; // v0.4.0 Agent Preset 系统提示定制段(/preset 可切换)
98
98
  let permissionNow = permission; // /preset 可换权限模式
99
- // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化
100
- let agentCfg = ctx.agentCfg || cfg;
99
+ // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化。
100
+ // v0.6.24 §3.89:REPL 启动时的来源沿用入口(cli.js)算好的值:未切预设 → `config`(config.json
101
+ // 有 permission)或 `default`(内置缺省);`--preset` 已声明 permission → `preset`。这里只兜底
102
+ // 补齐、绝不写 null——"来源未知"与"来源就是配置"是两件事,REPL 属于后者。
103
+ // 这里**保持同一对象**(不拷贝):cli.js 装配的首回合 agent 持有的 cfg 就是这个 agentCfg,
104
+ // 原地补来源才能让首回合同样可见;拷贝会让下面的补齐对已有 agent 失效。
105
+ let agentCfg = ctx.agentCfg || { ...cfg };
106
+ if (!agentCfg.permissionSource) {
107
+ agentCfg.permissionSource = cfg.permission !== undefined && cfg.permission !== null ? 'config' : 'default';
108
+ }
101
109
  let mcpManager = ctx.mcpManager;
102
110
  let modelName = ctx.modelName;
103
111
  let provider = ctx.provider;
@@ -300,7 +308,18 @@ export async function runRepl(ctx) {
300
308
  } else if (p.permission !== undefined) {
301
309
  over.permission = permOv.permission;
302
310
  }
303
- agentCfg = { ...cfg, ...over, presetName: p.name };
311
+ // v0.6.24 §3.89:`/preset` 换了本会话的规则环境,来源必须跟着换。只有预设真的声明、
312
+ // 并被反提权判定采纳了 permission 才记 `preset`(与 cli.js 的 hasOwnProperty 同一口径);
313
+ // 仅换提示词/工具白名单/模型的预设不冒充来源,沿用切换前的来源。
314
+ const permissionFromPreset = Object.prototype.hasOwnProperty.call(over, 'permission');
315
+ agentCfg = {
316
+ ...cfg,
317
+ ...over,
318
+ presetName: p.name,
319
+ permissionSource: permissionFromPreset
320
+ ? 'preset'
321
+ : agentCfg.permissionSource ?? (cfg.permission !== undefined && cfg.permission !== null ? 'config' : 'default'),
322
+ };
304
323
  if (over.model) await switchToModel(over.model, { silent: true, persist: false });
305
324
  presetBlock = presetSystemBlock(p);
306
325
  permissionNow = createPermission(agentCfg.permission ?? 'ask', io);
package/src/ledger.js CHANGED
@@ -44,13 +44,23 @@ export const LEDGER_VERSION = 1;
44
44
  * 一次"来源不可见"的坑(界面选了自动、却被只读预设压着跑)迫使我们把来源写进账本:
45
45
  * 事后自查时,`permission:"auto"` 单看无法区分"用户显式选的"与"config 里本来就是 auto"。
46
46
  *
47
- * - `explicit`:用户/调用方在本回合显式选择(WebUI 的权限档、CLI 的显式参数);
47
+ * - `explicit`:用户/调用方在**本回合**显式选择(WebUI 的权限档;前台 CLI 暂无 `--permission` 入口);
48
48
  * - `preset`:由预设声明的 permission 生效(反提权判定已通过);
49
49
  * - `config`:来自 `config.json` 的 `permission`;
50
- * - `cli`:后台/调度任务由 CLI 参数指定(`mingdao run --permission ...`);
50
+ * - `cli`:保留给前台 CLI 的显式权限参数(当前没有前台 `--permission` 入口;
51
+ * 后台任务的 `--permission` 属于任务规格,按下面的 `job` 记);
52
+ * - `job`:**后台任务/调度**的任务规格显式指定(`mingdao run --permission ...` /
53
+ * `schedule add --permission ...` 落进 task/job 的 permission)——v0.6.24 §3.89 新增。
54
+ * 刻意不复用 `explicit`:那个值表示「用户在本回合的交互里选的」,而后台任务的权限
55
+ * 是**提交任务时写进规格**的,事后看到 `explicit` 会误以为有人在跑的时候按了权限档;
51
56
  * - `default`:以上都没有,落到内置缺省(`ask`)。
57
+ *
58
+ * 各入口的取值口径(完整表见 `docs/CONFIG.md`「账本来源签名」一节):
59
+ * WebUI → explicit / preset / config;CLI 前台 → preset / config / default(`cli` 为保留值,当前无写入路径);
60
+ * 后台 worker → job(任务规格显式) / config(否则);REPL → preset(本回合切了声明
61
+ * permission 的预设) / config / default(启动时未切预设)。
52
62
  */
53
- export const PERMISSION_SOURCES = Object.freeze(['explicit', 'preset', 'config', 'cli', 'default']);
63
+ export const PERMISSION_SOURCES = Object.freeze(['explicit', 'preset', 'config', 'cli', 'job', 'default']);
54
64
  /** 默认保留最近多少次运行(超出按 mtime 删最旧) */
55
65
  const DEFAULT_MAX_RUNS = 200;
56
66
  const GENESIS = '0'.repeat(16);
@@ -897,7 +907,7 @@ export function exportRun(/** @type {any} */ runId, { format = 'json' } = {}) {
897
907
  lines.push(`- 模型:${start?.model ?? '—'} · 会话:${start?.session ?? '—'} · 权限:${start?.permission ?? '—'}`);
898
908
  // v0.6.17 §3.64:来源是**可选**字段——旧账本没有它,这一行就整行不出现(输出契约不变);
899
909
  // 新账本才有,且只增行、不改上面那行「权限:…」的既有形状。
900
- if (start?.permissionSource) lines.push(`- 权限来源:${start.permissionSource}(explicit=显式选择 / preset=预设 / config=配置 / cli=命令行 / default=内置缺省)`);
910
+ if (start?.permissionSource) lines.push(`- 权限来源:${start.permissionSource}(explicit=显式选择 / preset=预设 / config=配置 / cli=命令行 / job=后台任务规格 / default=内置缺省)`);
901
911
  lines.push(`- 开始:${start?.at ? new Date(start.at).toISOString() : '—'}`);
902
912
  lines.push(`- 结束:${end ? `${end.status}(${end.ms}ms,${end.steps} 步)` : '未结束'}`);
903
913
  lines.push(`- 费用:${end?.priced ? `¥${Number(end.yuanTotal ?? 0).toFixed(4)}` : '**无法估算**(模型无价,不是 0 元)'}`);
package/src/session.js CHANGED
@@ -60,9 +60,30 @@ export function createSession(home) {
60
60
  return { file, name: path.basename(file) };
61
61
  }
62
62
 
63
+ /**
64
+ * v0.6.23(§3.87):会话消息 `ts` 的**唯一补齐点**。
65
+ *
66
+ * 为什么放在这一层:CLI / REPL / 调度 worker / Web 落盘最终都走 appendMessages;
67
+ * 此前只有 Web server 在落盘前自己 stamp,导致旧会话与非 Web 会话的消息在界面上没有时间。
68
+ * 规则:只给**缺少 ts** 的消息补一个 ISO 字符串,已有 ts 原样保留(重复写入不刷新时间);
69
+ * 就地写回同一批消息对象,这样自动压缩的整文件重写(rewriteSession)能带上刚写下的 ts。
70
+ * 读取路径不调用它:旧会话缺 ts 仍返回 null,绝不回填/改写历史文件。
71
+ * @param {any[]} messages
72
+ * @param {Date} [now]
73
+ */
74
+ export function stampMessageTs(messages, now = new Date()) {
75
+ const ts = now.toISOString();
76
+ for (const m of messages) {
77
+ if (!m || typeof m !== 'object' || m.ts) continue;
78
+ m.ts = ts;
79
+ }
80
+ return messages;
81
+ }
82
+
63
83
  /** @param {any} file @param {any} messages */
64
84
  export function appendMessages(file, messages) {
65
85
  if (!messages?.length) return;
86
+ stampMessageTs(messages); // 单一来源:所有写入路径在这里补 ts(已有 ts 不覆盖)
66
87
  const lines = messages.map((/** @type {any} */ m) => JSON.stringify(m)).join('\n') + '\n';
67
88
  appendFilePrivateSync(file, lines);
68
89
  }
@@ -49,6 +49,16 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
49
49
  perm = 'readonly';
50
50
  note = 'ask 权限下后台任务按只读执行';
51
51
  }
52
+ // v0.6.24 §3.89:把权限来源写进账本。此前 worker 路径恒写 null,后台任务/调度会话的
53
+ // `permission` 是 auto 还是 readonly 单看生效值无法区分「任务规格显式定的」与「配置来的」。
54
+ // · 任务规格/CLI 显式给了 permission → `job`(**不复用 explicit**:那个值表示
55
+ // "用户在本回合交互里选的",提交任务时写进规格是另一回事;spawn 时它经
56
+ // `--permission` 传入,语义仍是任务规格);
57
+ // · 否则权限口径来自 `config.json` → `config`;
58
+ // · 配置里也没有 → 内置缺省 `default`(不冒充 config)。
59
+ // 生效值必须用**降级后**的 perm(ask → readonly 已在上面完成),否则账本会记下实际没生效的 ask。
60
+ const permissionSource = permission ? 'job' : cfg.permission !== undefined && cfg.permission !== null ? 'config' : 'default';
61
+ const agentCfg = { ...cfg, permission: perm, permissionSource };
52
62
  // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–12:00、14:00–18:00)
53
63
  // 自动顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
54
64
  if (offpeak && isPeakHour(new Date())) {
@@ -93,7 +103,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
93
103
  io,
94
104
  modelName,
95
105
  workingDir,
96
- cfg,
106
+ cfg: agentCfg,
97
107
  mcp: mcpFacade || undefined,
98
108
  sessionRef,
99
109
  onCompact: (/** @type {any} */ msgs) => {
package/src/web/app.js CHANGED
@@ -298,6 +298,191 @@ function msgTimeText(ts, now) {
298
298
  let generating = false; // 生成中:发送按钮复用为停止按钮
299
299
  let currentSession = null, thinking = null;
300
300
 
301
+ // ==== v0.6.24 §3.88:左侧栏(会话列) ====
302
+ // 只做「展示 + 转发」:会话数据来自 refreshSessions() 已有的 sessionsGroupedCache(不新增请求、
303
+ // 不引入轮询);点会话写回 #sessions 并派发 change(复用既有切换逻辑);新建对话 / 工作空间
304
+ // 分别复用 #newChat 与 #wsSel 的 handler。折叠偏好记忆在 localStorage。
305
+ const SIDE_NAV_STORAGE_KEY = 'mingdao-sidenav-collapsed';
306
+ let sideNavUserCollapsed = false;
307
+
308
+ /** @param {any} storage */
309
+ function sideNavCollapsedPref(storage) {
310
+ try { return !!storage && storage.getItem(SIDE_NAV_STORAGE_KEY) === '1'; } catch { return false; }
311
+ }
312
+ /** @param {any} storage @param {boolean} collapsed */
313
+ function writeSideNavCollapsed(storage, collapsed) {
314
+ try { if (storage) storage.setItem(SIDE_NAV_STORAGE_KEY, collapsed ? '1' : '0'); } catch {}
315
+ return !!collapsed;
316
+ }
317
+ /** 三档断点:≥1180 三栏 / 900–1180 左栏自动折叠 / <900 抽屉。 */
318
+ function sideNavBreakpoint(width) {
319
+ const w = Number(width) || 0;
320
+ if (w >= 1180) return 'wide';
321
+ if (w >= 900) return 'mid';
322
+ return 'drawer';
323
+ }
324
+ function sideNavLayout(width, pref) {
325
+ const mode = sideNavBreakpoint(width);
326
+ const collapsed = mode === 'mid' || (mode === 'wide' && !!pref);
327
+ return { mode, collapsed, width: mode === 'drawer' ? 0 : (collapsed ? 48 : 240), drawer: mode === 'drawer' };
328
+ }
329
+ function sideNavClassNames(layout) {
330
+ const out = [];
331
+ if (layout.mode === 'wide') out.push('side-wide');
332
+ if (layout.mode === 'mid') out.push('side-mid');
333
+ if (layout.mode === 'drawer') out.push('side-drawer');
334
+ if (layout.mode === 'wide' && layout.collapsed) out.push('side-collapsed');
335
+ return out;
336
+ }
337
+ /**
338
+ * 渲染左栏会话列表:只读分组缓存,四个分组标题 + 条目;当前会话高亮。
339
+ * @param {any} container @param {any[]} groups @param {any} current @param {(s:any)=>void} [onPick]
340
+ * @returns {number} 渲染的会话条数
341
+ */
342
+ function renderSideNavSessions(container, groups, current, onPick) {
343
+ if (!container) return 0;
344
+ while (container.firstChild) container.removeChild(container.firstChild);
345
+ const list = Array.isArray(groups) ? groups : [];
346
+ if (!list.length) {
347
+ const empty = document.createElement('div');
348
+ empty.className = 'sn-empty';
349
+ empty.textContent = '暂无会话';
350
+ container.appendChild(empty);
351
+ return 0;
352
+ }
353
+ let n = 0;
354
+ for (const g of list) {
355
+ const head = document.createElement('div');
356
+ head.className = 'sn-group';
357
+ head.textContent = String(g.title || '');
358
+ container.appendChild(head);
359
+ for (const s of (Array.isArray(g.items) ? g.items : [])) {
360
+ const item = document.createElement('button');
361
+ item.type = 'button';
362
+ item.className = 'sn-item' + (s.file === current ? ' on' : '');
363
+ item.dataset.file = String(s.file || '');
364
+ item.title = String(s.label || s.file || '');
365
+ if (s.file === current) item.setAttribute('aria-current', 'true');
366
+ const ico = document.createElement('span');
367
+ ico.className = 'sn-item-ico';
368
+ ico.setAttribute('aria-hidden', 'true');
369
+ ico.textContent = '💬';
370
+ const label = document.createElement('span');
371
+ label.className = 'sn-item-label';
372
+ label.textContent = String(s.label || s.file || '');
373
+ item.appendChild(ico);
374
+ item.appendChild(label);
375
+ item.addEventListener('click', () => { if (typeof onPick === 'function') onPick(s); });
376
+ container.appendChild(item);
377
+ n += 1;
378
+ }
379
+ }
380
+ return n;
381
+ }
382
+ /** 点击左栏会话:写回顶栏 #sessions 并派发 change(复用既有 onchange 切换逻辑)。 */
383
+ function pickSessionFromSideNav(s) {
384
+ const sel = $('#sessions');
385
+ if (!sel || !s || !s.file) return;
386
+ sel.value = s.file;
387
+ sel.dispatchEvent(new Event('change'));
388
+ }
389
+ /** 工作空间镜像:选项与选中值从顶栏 #wsSel 同步,切换仍交给原控件 handler。 */
390
+ function syncSideNavWorkspace() {
391
+ const src = $('#wsSel'), dst = $('#sideNavWs');
392
+ if (!src || !dst) return;
393
+ while (dst.firstChild) dst.removeChild(dst.firstChild);
394
+ for (const o of src.options) {
395
+ const n = document.createElement('option');
396
+ n.value = o.value;
397
+ n.textContent = o.textContent;
398
+ dst.appendChild(n);
399
+ }
400
+ dst.value = src.value;
401
+ const icon = $('#sideNavWsIcon');
402
+ if (icon) {
403
+ const cur = src.options[src.selectedIndex];
404
+ icon.title = '工作空间' + (cur && cur.value ? ':' + String(cur.textContent || '').replace(/^●\s*/, '') : '(点此切换)');
405
+ }
406
+ }
407
+ /** 当前会话高亮(只切类,不重建列表,避免搜索/滚动位置抖动)。 */
408
+ function syncSideNavActive() {
409
+ const box = $('#sideNavSessions');
410
+ if (!box) return;
411
+ for (const el of box.querySelectorAll('.sn-item')) {
412
+ const on = !!(currentSession && el.dataset && el.dataset.file === currentSession);
413
+ el.classList.toggle('on', on);
414
+ if (on) el.setAttribute('aria-current', 'true'); else el.removeAttribute('aria-current');
415
+ }
416
+ }
417
+ function syncSideNavToggle() {
418
+ const btn = $('#sideNavCollapse');
419
+ const mode = sideNavBreakpoint(window.innerWidth || 0);
420
+ const drawerOpen = document.body.classList.contains('side-drawer-open');
421
+ if (btn) {
422
+ btn.textContent = mode === 'drawer' ? (drawerOpen ? '×' : '☰') : (sideNavUserCollapsed ? '»' : '«');
423
+ btn.setAttribute('aria-expanded', String(mode === 'drawer' ? drawerOpen : !sideNavUserCollapsed));
424
+ btn.title = mode === 'drawer' ? (drawerOpen ? '关闭会话侧栏' : '打开会话侧栏') : (sideNavUserCollapsed ? '展开侧栏' : '折叠侧栏');
425
+ }
426
+ const rail = $('#sideNavRailBtn');
427
+ if (rail) { rail.classList.toggle('on', drawerOpen); rail.title = drawerOpen ? '关闭会话侧栏' : '打开会话侧栏'; }
428
+ }
429
+ function applySideNavLayout(width) {
430
+ const w = width == null ? (Number(window.innerWidth) || 0) : (Number(width) || 0);
431
+ const layout = sideNavLayout(w, sideNavUserCollapsed);
432
+ const names = sideNavClassNames(layout);
433
+ for (const c of ['side-wide', 'side-mid', 'side-drawer', 'side-collapsed']) {
434
+ document.body.classList.toggle(c, names.includes(c));
435
+ }
436
+ if (layout.mode !== 'drawer') document.body.classList.remove('side-drawer-open');
437
+ const nav = $('#sideNav');
438
+ if (nav) nav.classList.toggle('collapsed', layout.collapsed);
439
+ syncSideNavToggle();
440
+ }
441
+ function toggleSideNav() {
442
+ const mode = sideNavBreakpoint(window.innerWidth || 0);
443
+ if (mode === 'drawer') {
444
+ document.body.classList.toggle('side-drawer-open');
445
+ syncSideNavToggle();
446
+ return;
447
+ }
448
+ if (mode === 'mid') return; // 900–1180:自动折叠,不提供手动展开(避免挤窄主区)
449
+ sideNavUserCollapsed = !sideNavUserCollapsed;
450
+ let storage = null;
451
+ try { storage = window.localStorage; } catch {}
452
+ writeSideNavCollapsed(storage, sideNavUserCollapsed);
453
+ applySideNavLayout(window.innerWidth || 0);
454
+ }
455
+ function initSideNav() {
456
+ let storage = null;
457
+ try { storage = window.localStorage; } catch {}
458
+ sideNavUserCollapsed = sideNavCollapsedPref(storage);
459
+ const newBtn = $('#sideNavNew');
460
+ if (newBtn) newBtn.onclick = () => { const b = $('#newChat'); if (b) b.click(); };
461
+ const collapse = $('#sideNavCollapse');
462
+ if (collapse) collapse.onclick = toggleSideNav;
463
+ const rail = $('#sideNavRailBtn');
464
+ if (rail) rail.onclick = toggleSideNav;
465
+ const ws = $('#sideNavWs');
466
+ if (ws) ws.addEventListener('change', () => {
467
+ const main = $('#wsSel');
468
+ if (!main) return;
469
+ main.value = ws.value;
470
+ main.dispatchEvent(new Event('change'));
471
+ });
472
+ const wsIcon = $('#sideNavWsIcon');
473
+ if (wsIcon) wsIcon.onclick = () => {
474
+ const main = $('#wsSel');
475
+ if (!main) return;
476
+ try { main.focus(); } catch {}
477
+ try { if (typeof main.showPicker === 'function') { main.showPicker(); return; } } catch {}
478
+ try { main.click(); } catch {}
479
+ };
480
+ syncSideNavWorkspace();
481
+ renderSideNavSessions($('#sideNavSessions'), sessionsGroupedCache, currentSession, pickSessionFromSideNav);
482
+ applySideNavLayout();
483
+ }
484
+ // ==== /v0.6.24 §3.88 ====
485
+
301
486
 
302
487
 
303
488
  function newAiMsg(ts){ const el=document.createElement('div'); el.className='msg-ai'; const t=msgTimeText(ts); if(t){ const m=document.createElement('div'); m.className='msg-time'; m.textContent=t; el.appendChild(m); } chatEl.appendChild(el); scrollBottom(); return el; }
@@ -1337,6 +1522,7 @@ function applyResponsiveLayout(width) {
1337
1522
  const cls = layoutClassesFor(w);
1338
1523
  document.body.classList.toggle('narrow-side', cls.narrow);
1339
1524
  document.body.classList.toggle('narrow-run', cls.narrowRun);
1525
+ applySideNavLayout(w); // §3.88:左栏三档断点与主区推开量
1340
1526
  syncRunPanel();
1341
1527
  }
1342
1528
  function initResponsive() {
@@ -2340,6 +2526,7 @@ function renderCrumb(){
2340
2526
  const w=$('#crumbWs'); if(w) w.textContent=currentWorkspaceName||'工作空间';
2341
2527
  const cur=sessionsCache.find((s)=>s&&s.file===currentSession);
2342
2528
  const c=$('#crumbSession'); if(c) c.textContent=cur?(cur.label||cur.file):(currentSession?String(currentSession).replace(/\.jsonl$/,''):'新对话');
2529
+ syncSideNavActive();
2343
2530
  }
2344
2531
  async function refreshSessions(q){
2345
2532
  const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions';
@@ -2354,6 +2541,8 @@ async function refreshSessions(q){
2354
2541
  for(const s of g.items){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; og.appendChild(o); }
2355
2542
  sel.appendChild(og);
2356
2543
  }
2544
+ // §3.88:左栏只读同一份分组缓存(不额外请求/不轮询)
2545
+ renderSideNavSessions($('#sideNavSessions'), sessionsGroupedCache, currentSession, pickSessionFromSideNav);
2357
2546
  if(currentSession&&!q) sel.value=currentSession;
2358
2547
  renderCrumb();
2359
2548
  }
@@ -2511,6 +2700,7 @@ async function refreshWsSel(){
2511
2700
  const mgr=document.createElement('option'); mgr.value='__manage__'; mgr.textContent='管理(重命名/删除/改目录)…';
2512
2701
  sel.appendChild(mgr);
2513
2702
  renderCrumb();
2703
+ syncSideNavWorkspace(); // §3.88:左栏工作空间镜像顶栏选项/选中值
2514
2704
  }
2515
2705
  /** 命令面板与顶栏下拉共用的工作空间切换(携带当前会话,语义与旧路径一致)。 */
2516
2706
  async function applyWorkspaceSwitch(name){
@@ -2957,4 +3147,6 @@ async function openSessModal(){
2957
3147
  }
2958
3148
  $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); } };
2959
3149
  if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
3150
+ // §3.88:左栏先接线(读折叠偏好 + 渲染一次);init() 里的 refreshSessions 会再次用最新数据刷新。
3151
+ initSideNav();
2960
3152
  init();
@@ -640,9 +640,71 @@ body.run-open #sessSheet{right:318px}
640
640
  #emptyState .es-lead,
641
641
  .empty-step .es-main span,
642
642
  .empty-step .es-state{color:var(--dim)}
643
+
644
+ /* ============================================================================
645
+ v0.6.24 §3.88 左侧栏(设计稿三栏的标志性部分)
646
+ ——只新增左栏自己的样式,复用既有 token;不改顶栏高度、#runPanel 宽度、
647
+ 既有断点与字号阶。布局:现有窄条 #trajRail(42) → 本栏(240/48) → 主区 → 右栏。
648
+ ============================================================================ */
649
+ #sideNav{position:fixed;left:42px;top:52px;bottom:0;width:240px;min-width:0;background:var(--bg2);border-right:1px solid var(--border);z-index:34;display:flex;flex-direction:column;overflow:hidden;transition:width .16s ease}
650
+ #sideNav .sn-head{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border)}
651
+ #sideNav .sn-title{flex:1;min-width:0;font-size:12px;font-weight:600;color:var(--dim);letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
652
+ #sideNavCollapse{flex:none;width:26px;height:26px;padding:0;border-radius:7px;background:var(--bg3);font-size:13px;line-height:1;display:inline-flex;align-items:center;justify-content:center}
653
+ #sideNav .sn-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 8px;overflow-y:auto;overscroll-behavior:contain}
654
+ #sideNavNew{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-width:0;padding:8px 10px;border-radius:9px;border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:13px;font-weight:600;white-space:nowrap}
655
+ #sideNavNew:hover{border-color:var(--accent2);color:var(--accent2)}
656
+ #sideNavNew .sn-label{overflow:hidden;text-overflow:ellipsis}
657
+ .sn-ws{display:flex;align-items:center;gap:6px;min-width:0;padding:0 2px}
658
+ .sn-ws .sn-ws-ico{flex:none;font-size:13px;line-height:1;color:var(--dim)}
659
+ #sideNavWs{flex:1;min-width:0;max-width:none;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:5px 6px;font-size:12px}
660
+ #sideNavWsIcon{display:none;align-items:center;justify-content:center;width:36px;height:30px;padding:0;border-radius:8px;background:var(--bg3);font-size:14px;line-height:1}
661
+ #sideNav .sn-sec-title{flex:none;font-size:10.5px;color:var(--dim);letter-spacing:.05em;padding:2px 6px;white-space:nowrap}
662
+ #sideNavSessions{display:flex;flex-direction:column;gap:1px;min-width:0}
663
+ #sideNavSessions .sn-group{font-size:10.5px;line-height:1.6;color:var(--dim);padding:7px 6px 2px;letter-spacing:.02em;white-space:nowrap}
664
+ #sideNavSessions .sn-item{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:transparent;color:var(--dim);text-align:left;font-size:12.5px;line-height:1.5;cursor:pointer}
665
+ #sideNavSessions .sn-item:hover{background:var(--bg3);color:var(--text)}
666
+ #sideNavSessions .sn-item.on{background:var(--bg3);color:var(--text);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
667
+ #sideNavSessions .sn-item-ico{flex:none;width:16px;text-align:center;font-size:13px}
668
+ #sideNavSessions .sn-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
669
+ #sideNavSessions .sn-empty{font-size:11.5px;color:var(--dim);padding:8px 6px;white-space:nowrap}
670
+ #trajRail #sideNavRailBtn{display:none}
671
+ /* —— 三档断点:≥1180 展开(240) / 900–1180 自动折叠(48) / <900 抽屉(默认收起) —— */
672
+ body.side-wide #sideNav{width:240px}
673
+ body.side-wide.side-collapsed #sideNav{width:48px}
674
+ body.side-mid #sideNav{width:48px}
675
+ body.side-drawer #sideNav{display:none;width:240px}
676
+ body.side-drawer.side-drawer-open #sideNav{display:flex;z-index:55;box-shadow:12px 0 32px rgba(0,0,0,.45)}
677
+ body.side-drawer #trajRail #sideNavRailBtn{display:flex}
678
+ /* 主区/输入区右移,左栏不遮正文;顶栏保持整条横贯(高度/入口不动) */
679
+ body.side-wide main,body.side-wide footer{margin-left:282px}
680
+ body.side-wide.side-collapsed main,body.side-wide.side-collapsed footer{margin-left:90px}
681
+ body.side-mid main,body.side-mid footer{margin-left:90px}
682
+ body.side-drawer main,body.side-drawer footer{margin-left:0}
683
+ /* 折叠态:只留图标 + tooltip(label/分组标题隐藏;折叠按钮本身是图标,保留用于展开) */
684
+ body.side-collapsed .sn-title,
685
+ body.side-mid .sn-title,
686
+ body.side-collapsed #sideNavNew .sn-label,
687
+ body.side-mid #sideNavNew .sn-label,
688
+ body.side-collapsed .sn-sec-title,
689
+ body.side-mid .sn-sec-title,
690
+ body.side-collapsed #sideNavSessions .sn-group,
691
+ body.side-mid #sideNavSessions .sn-group,
692
+ body.side-collapsed #sideNavSessions .sn-item-label,
693
+ body.side-mid #sideNavSessions .sn-item-label,
694
+ body.side-collapsed .sn-ws,
695
+ body.side-mid .sn-ws,
696
+ body.side-mid #sideNavCollapse{display:none}
697
+ body.side-mid .sn-head::before{content:'💬';font-size:14px;line-height:1;color:var(--dim)}
698
+ body.side-collapsed #sideNavWsIcon,
699
+ body.side-mid #sideNavWsIcon{display:inline-flex}
700
+ body.side-collapsed .sn-head,body.side-mid .sn-head{padding:10px 6px;justify-content:center}
701
+ body.side-collapsed #sideNav .sn-body,body.side-mid #sideNav .sn-body{padding:10px 6px;align-items:center}
702
+ body.side-collapsed #sideNavNew,body.side-mid #sideNavNew{width:36px;padding:8px 0;gap:0}
703
+ body.side-collapsed #sideNavSessions .sn-item,body.side-mid #sideNavSessions .sn-item{width:36px;justify-content:center;padding:6px 0;gap:0}
704
+ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions .sn-item.on{box-shadow:inset 0 0 0 1px var(--accent)}
643
705
  </style>
644
706
  </head>
645
- <body>
707
+ <body class="side-wide">
646
708
  <header>
647
709
  <div class="logo">MingDao <span>Harness</span></div>
648
710
  <!-- v0.6.21 小步重排(§3.81):顶栏只保留「我在哪」的当前位置;模型/预设/权限只在 #sessSheet 里改。 -->
@@ -666,7 +728,28 @@ body.run-open #sessSheet{right:318px}
666
728
  <div id="trajRail">
667
729
  <button class="rail-btn" id="trajRailBtn" title="轨迹(点击查看详情)">🧭</button>
668
730
  <button class="rail-btn" id="subRailBtn" title="子 Agent">🤖<span class="rail-badge" id="subBadge" style="display:none"></span></button>
731
+ <!-- §3.88:<900 时左栏收成抽屉,这个窄条图标负责打开/关闭(≥900 隐藏)。 -->
732
+ <button class="rail-btn" id="sideNavRailBtn" title="会话侧栏(打开/关闭)">☰</button>
669
733
  </div>
734
+ <!-- v0.6.24 §3.88 左侧栏:设计稿三栏的会话列。只新增;顶栏既有入口一个不删。
735
+ 数据复用 refreshSessions() 的 sessionsGroupedCache;切换走 #sessions change;
736
+ 工作空间镜像 #wsSel(原控件保留在顶栏原位);新建对话复用 #newChat handler。 -->
737
+ <aside id="sideNav" aria-label="会话侧栏">
738
+ <div class="sn-head">
739
+ <span class="sn-title">会话</span>
740
+ <button id="sideNavCollapse" type="button" title="折叠侧栏" aria-label="折叠侧栏" aria-expanded="true">«</button>
741
+ </div>
742
+ <div class="sn-body">
743
+ <button id="sideNavNew" type="button" title="新建对话">+<span class="sn-label">新建对话</span></button>
744
+ <div class="sn-ws">
745
+ <span class="sn-ws-ico" aria-hidden="true">📁</span>
746
+ <select id="sideNavWs" title="工作空间(与顶栏 #wsSel 同源,切换走同一个 handler)"></select>
747
+ </div>
748
+ <button id="sideNavWsIcon" type="button" title="工作空间" aria-label="工作空间">📁</button>
749
+ <div class="sn-sec-title">最近会话</div>
750
+ <div id="sideNavSessions" role="list" aria-label="会话列表"></div>
751
+ </div>
752
+ </aside>
670
753
  <main>
671
754
  <!-- P1-6 空态三步引导(v0.6.19 §3.70):无会话 / 无消息时显示;示例任务只填入输入框,不自动发送。 -->
672
755
  <div id="emptyState" hidden>
package/src/web/server.js CHANGED
@@ -77,24 +77,8 @@ import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList,
77
77
 
78
78
  const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
79
79
 
80
- /**
81
- * v0.6.22(§3.84):落盘前给**新增**消息补 ISO 时间戳。
82
- *
83
- * 只补缺 ts 的消息(`{...m, ts}` 不改动原对象),已有 ts 的原样保留:
84
- * 旧会话/旧消息不会被"补写"成另一个时间;压缩重写时也不会把历史时间冲掉。
85
- * 时间只在服务端落盘这一刻取,前端渲染用同一字段——不引入第二套时间来源。
86
- * @template T
87
- * @param {T[]} messages
88
- * @param {Date} [now]
89
- * @returns {T[]}
90
- */
91
- function stampMessageTs(messages, now = new Date()) {
92
- const ts = now.toISOString();
93
- return messages.map((/** @type {any} */ m) => {
94
- if (!m || typeof m !== 'object' || m.ts) return m;
95
- return { ...m, ts };
96
- });
97
- }
80
+ // v0.6.23(§3.87):Web 落盘**不再自建 stamp**——ts 的唯一补齐点是 `src/session.js` 的
81
+ // `appendMessages()`(CLI / REPL / 调度 / Web 共用);下面两处 chat 落盘都只调它,不引入第二套时间来源。
98
82
 
99
83
  /** 合法权限模式(与 /api/config 的校验同一口径:非法值一律忽略,绝不 fail-open)。 */
100
84
  const PERMISSION_MODES = new Set(['ask', 'auto', 'readonly']);
@@ -902,13 +886,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
902
886
  }
903
887
  messages.push({ role: 'user', content: built.content });
904
888
  // 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)。
905
- // v0.6.22(§3.84):同一次落盘的时间戳也写回内存里的这条消息——后续自动压缩整文件重写时
906
- // 不会把这一轮刚写下的 ts 冲掉;历史消息(已有/缺失 ts)不在这里补。
907
- const userTs = new Date().toISOString();
908
- await withSessionLock(session.file, () => appendMessages(session.file, [{ role: 'user', content: built.persistText, ts: userTs }]));
889
+ // v0.6.23(§3.87):ts 由 session.appendMessages 统一补齐(唯一来源);把落盘对象上的 ts
890
+ // 同步回内存里的这条消息,后续自动压缩整文件重写时才不会把这一轮刚写下的时间冲掉。
891
+ /** @type {any} */
892
+ const persistUser = { role: 'user', content: built.persistText };
893
+ await withSessionLock(session.file, () => appendMessages(session.file, [persistUser]));
909
894
  {
910
895
  const lastUserMsg = messages[messages.length - 1];
911
- if (lastUserMsg && typeof lastUserMsg === 'object' && !lastUserMsg.ts) lastUserMsg.ts = userTs;
896
+ if (lastUserMsg && typeof lastUserMsg === 'object' && !lastUserMsg.ts && persistUser.ts) lastUserMsg.ts = persistUser.ts;
912
897
  }
913
898
  let persistedBefore = messages.length;
914
899
 
@@ -1091,13 +1076,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
1091
1076
  // 会话级路由统计(Hermes C2 升级检测):累计工具步数与截断次数,粘滞 flash 会话复杂度上来后自动升 planner
1092
1077
  const st = session.routeStats || { steps: 0, truncated: 0 };
1093
1078
  session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
1094
- // v0.6.22(§3.84):本回合新增的 assistant/tool 消息落盘时补 ts,并同步回内存数组,
1095
- // 让之后发生的自动压缩整文件重写保留这些时间戳(历史消息不动)。
1079
+ // v0.6.23(§3.87):本回合新增的 assistant/tool 消息由 appendMessages 就地补 ts,
1080
+ // 内存数组随之带上时间戳——之后自动压缩整文件重写保留这些时间(历史消息不动)。
1096
1081
  {
1097
1082
  const tail = messages.slice(persistedBefore);
1098
- const stamped = stampMessageTs(tail);
1099
- for (let i = 0; i < stamped.length; i += 1) messages[persistedBefore + i] = stamped[i];
1100
- await withSessionLock(session.file, () => appendMessages(session.file, stamped));
1083
+ await withSessionLock(session.file, () => appendMessages(session.file, tail));
1101
1084
  }
1102
1085
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
1103
1086
  // v0.4.4:补记最后一轮 + 兜底总结的剩余 usage(自动续跑轮已在 onUsage 逐轮入账,避免重复计)