mingdao-harness 0.6.21 → 0.6.23

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
@@ -394,6 +394,23 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
394
394
 
395
395
  长期偏好仍走用户记忆(`AGENTS.md`,见上),不受此开关影响。
396
396
 
397
+ ### WebUI 会话列表字段与消息时间(v0.6.22,§3.84)
398
+
399
+ `GET /api/sessions` 的每一项在既有 `file` / `mtime` / `label` 之外**只增**三个只读字段:
400
+
401
+ | 字段 | 含义 | 来源 |
402
+ | --- | --- | --- |
403
+ | `updatedAt` | 会话最后更新时间,ISO 字符串 | 会话文件 mtime |
404
+ | `messageCount` | 该会话的 JSONL 消息条数(坏行跳过) | 只读解析文件 |
405
+ | `title` | 会话标题(已有文件名即标题;自动标题/重命名都落在文件名上) | 文件名去 `.jsonl` |
406
+
407
+ `GET /api/session?file=` 的每条消息**只增** `ts`(ISO 字符串)。旧会话(v0.6.21 及以前写的
408
+ JSONL 没有 `ts`)返回 `"ts": null`,其余字段语义不变;WebUI 对 `ts` 为 `null` 的消息不渲染时间,
409
+ 不会出现 `Invalid Date` / `undefined`。`ts` 由 `session.appendMessages` **统一补齐**(v0.6.23,§3.87):
410
+ CLI / REPL / 调度 / Web 写入都走这一个入口,只补缺少 `ts` 的消息、已有值原样保留(重复写入与压缩
411
+ 重写不刷新时间);读取路径不回填、不改写旧文件,因此旧会话仍返回 `null`。前端不自行推算历史消息时间。
412
+ 分组(今天/昨天/本周/更早)与时间显示都在 `src/web/app.js`;分组只在会话列表刷新时计算一次,不引入轮询。
413
+
397
414
  ## 模型名称以 API 为准(v0.6.19,§3.67)
398
415
 
399
416
  **仓库不维护具体版本号,只维护「家族能力 / 定价模式」。** 官方改名、升版本(例如
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.21",
3
+ "version": "0.6.23",
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
  }
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
  }
package/src/web/app.js CHANGED
@@ -224,16 +224,87 @@ chatEl.addEventListener('click', (e) => {
224
224
  const done = () => { b.textContent = '已复制'; setTimeout(() => { b.textContent = '复制'; }, 1200); };
225
225
  if (navigator.clipboard?.writeText) navigator.clipboard.writeText(t).then(done).catch(() => {});
226
226
  });
227
+ // ==== v0.6.22 §3.84 会话分组 + 消息时间(纯函数,test/ui-sessions.js 可直接抽取执行)====
228
+ // 只做展示:分组顺序固定「今天 → 昨天 → 本周 → 更早」,组标题不可点击/不可折叠。
229
+ // 「现在」一律由调用方传入(默认 new Date()),测试用固定 now 避免跨午夜抖动。
230
+ const SESSION_GROUP_ORDER = ['today', 'yesterday', 'week', 'earlier'];
231
+ const SESSION_GROUP_TITLES = { today: '今天', yesterday: '昨天', week: '本周', earlier: '更早' };
232
+
233
+ /** 取本地时区某一天的 00:00(用日期构造器而非减 86400000,跨夏令时也不会偏一小时)。 */
234
+ function localDayStart(d) {
235
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
236
+ }
237
+
238
+ /**
239
+ * 单个会话落在哪个分组。时间不可解析/缺失 → earlier(绝不抛错)。
240
+ * 边界:今天 00:00 起算今天;昨天 00:00 起算昨天;本周从周一 00:00 起算。
241
+ * @param {string|number|Date|undefined|null} updatedAt
242
+ * @param {Date} [now]
243
+ * @returns {string}
244
+ */
245
+ function sessionGroupKey(updatedAt, now) {
246
+ const t = new Date(updatedAt == null ? NaN : updatedAt).getTime();
247
+ const n = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
248
+ if (!Number.isFinite(t) || Number.isNaN(n.getTime())) return 'earlier';
249
+ const today = localDayStart(n);
250
+ if (t >= today) return 'today';
251
+ const yesterday = new Date(n.getFullYear(), n.getMonth(), n.getDate() - 1).getTime();
252
+ if (t >= yesterday) return 'yesterday';
253
+ const dow = (n.getDay() + 6) % 7; // 周一 = 0
254
+ const weekStart = new Date(n.getFullYear(), n.getMonth(), n.getDate() - dow).getTime();
255
+ if (t >= weekStart) return 'week';
256
+ return 'earlier';
257
+ }
258
+
259
+ /**
260
+ * 一次性把会话列表分到四个桶(空桶不返回;顺序固定)。调用方只在数据刷新时调用一次,
261
+ * 渲染函数只读 `sessionsGroupedCache`——不得在每次渲染时重算。
262
+ * @param {any[]} sessions
263
+ * @param {Date} [now]
264
+ * @returns {{key:string,title:string,items:any[]}[]}
265
+ */
266
+ function groupSessionsByUpdatedAt(sessions, now) {
267
+ /** @type {Record<string, any[]>} */
268
+ const buckets = { today: [], yesterday: [], week: [], earlier: [] };
269
+ for (const s of sessions || []) {
270
+ // updatedAt 是 v0.6.22 新增字段;mtime 是旧接口既有字段(毫秒),两者都能分组。
271
+ buckets[sessionGroupKey(s && (s.updatedAt ?? s.mtime), now)].push(s);
272
+ }
273
+ return SESSION_GROUP_ORDER
274
+ .filter((key) => buckets[key].length > 0)
275
+ .map((key) => ({ key, title: SESSION_GROUP_TITLES[key], items: buckets[key] }));
276
+ }
277
+
278
+ /**
279
+ * 消息时间文案:同一天 `14:32`;跨天 `10-19 14:32`;缺失/非法 → ''(前端不渲染任何节点,
280
+ * 所以不会出现 "Invalid Date" / "undefined")。
281
+ * @param {any} ts
282
+ * @param {Date} [now]
283
+ * @returns {string}
284
+ */
285
+ function msgTimeText(ts, now) {
286
+ if (typeof ts !== 'string' || !ts.trim()) return '';
287
+ const d = new Date(ts);
288
+ if (Number.isNaN(d.getTime())) return '';
289
+ const n = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
290
+ if (Number.isNaN(n.getTime())) return '';
291
+ const hm = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
292
+ const sameDay = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
293
+ if (sameDay) return hm;
294
+ return String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + hm;
295
+ }
296
+ // ==== /v0.6.22 §3.84 ====
297
+
227
298
  let generating = false; // 生成中:发送按钮复用为停止按钮
228
299
  let currentSession = null, thinking = null;
229
300
 
230
301
 
231
302
 
232
- function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
303
+ 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; }
233
304
  function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
234
305
  function addReasoning(msg){ const d=document.createElement('details'); d.className='reasoning'; d.innerHTML='<summary>思考过程</summary><div class="body"></div>'; msg.appendChild(d); return d.querySelector('.body'); }
235
306
 
236
- function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); renderEmptyState(); scrollBottom(); }
307
+ function addUser(text, ts){ const el=document.createElement('div'); el.className='msg-user'; const t=msgTimeText(ts); el.innerHTML='<div class="bubble">'+esc(text)+(t?'<span class="msg-time">'+esc(t)+'</span>':'')+'</div>'; chatEl.appendChild(el); renderEmptyState(); scrollBottom(); }
237
308
 
238
309
  const runningTools = new Map();
239
310
  // P1-1 工具时间线(v0.6.19 §3.70)保留为轨迹详情的渲染原语(toolStepNode/paintToolStep/
@@ -484,8 +555,8 @@ async function send(){
484
555
  if(attachments.length) payload.attachments=attachments;
485
556
  const sentAttachments=attachments;
486
557
  attachments=[]; renderAttachments();
487
- addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')));
488
- const msg=newAiMsg(); const content=aiContent(msg); let raw='';
558
+ addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')), new Date().toISOString());
559
+ const msg=newAiMsg(new Date().toISOString()); const content=aiContent(msg); let raw='';
489
560
  activeAiMsg=msg; msg._traj=[]; msg._steps=0; msg._round=++turnSeq; msg._startedAt=Date.now(); msg._endedAt=null; pendingSubagent=null; curSteps=0; curWorkT0=Date.now();
490
561
  // P0-1:本轮开始 → 运行状态条归零(上一轮的费用/首帧/工具数不许泄漏到本轮;上下文基数保留)
491
562
  runState.rawPhase='发请求(等首帧)'; runState.phase='等首帧'; runState.aborted=false;
@@ -1159,6 +1230,8 @@ function layoutClassesFor(width) {
1159
1230
  // 快捷键表 / 会话设置 / 空态 / 设置搜索 / 诊断。全部消费上面纯函数的结果。
1160
1231
  // ============================================================================
1161
1232
  let sessionsCache = [];
1233
+ // §3.84:分组只在 refreshSessions 里算一次;下拉/会话管理列表都只读这份缓存(不随渲染重算)。
1234
+ let sessionsGroupedCache = [];
1162
1235
  let workspacesCache = [];
1163
1236
  let currentWorkspaceName = '';
1164
1237
  let modelsCache = [];
@@ -2273,8 +2346,14 @@ async function refreshSessions(q){
2273
2346
  const r=await fetch(u).catch(()=>null); if(!r)return;
2274
2347
  const j=await r.json();
2275
2348
  sessionsCache=Array.isArray(j.sessions)?j.sessions:[];
2349
+ sessionsGroupedCache=groupSessionsByUpdatedAt(sessionsCache); // 一次性分组(性能:不在渲染时重算)
2276
2350
  const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>';
2277
- for(const s of sessionsCache){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); }
2351
+ for(const g of sessionsGroupedCache){
2352
+ // 原生 optgroup 的组标题由系统渲染:不可点击、不可折叠——正好符合"保持简单"。
2353
+ const og=document.createElement('optgroup'); og.label=g.title;
2354
+ for(const s of g.items){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; og.appendChild(o); }
2355
+ sel.appendChild(og);
2356
+ }
2278
2357
  if(currentSession&&!q) sel.value=currentSession;
2279
2358
  renderCrumb();
2280
2359
  }
@@ -2850,7 +2929,7 @@ function finalizeCurrentSession(){
2850
2929
  if(!currentSession) return;
2851
2930
  fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
2852
2931
  }
2853
- async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; resetTraceState(); const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); renderEmptyState(); renderCrumb(); }
2932
+ async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; resetTraceState(); const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content, m.ts); else { const el=newAiMsg(m.ts); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); renderEmptyState(); renderCrumb(); }
2854
2933
 
2855
2934
  // §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
2856
2935
  sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
@@ -2861,15 +2940,19 @@ $('#sessMgrBtn').onclick=openSessModal;
2861
2940
  $('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
2862
2941
  async function openSessModal(){
2863
2942
  $('#sessModal').style.display='flex';
2864
- const r=await fetch('/api/sessions',{cache:'no-store'}); const j=await r.json();
2943
+ await refreshSessions(); // 复用同一次分组缓存:打开管理面板不再单独算一遍
2865
2944
  const list=$('#sessList'); list.innerHTML='';
2866
- if(!j.sessions.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px">暂无会话</div>'; return; }
2867
- for(const s of j.sessions){
2868
- const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12.5px';
2869
- div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
2870
- const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=await uiPrompt('新标题:', s.file.replace(/\.jsonl$/,'')); if(!t) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',file:s.file,title:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshSessions(); openSessModal(); } else uiAlert(jj.error||'重命名失败'); };
2871
- const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!await uiConfirm('删除会话 '+s.file+'?此操作不可恢复。')) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',file:s.file})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ if(currentSession===s.file){ currentSession=null; chatEl.innerHTML=''; renderEmptyState(); } refreshSessions(); openSessModal(); } else uiAlert(jj.error||'删除失败'); };
2872
- div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
2945
+ if(!sessionsCache.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px">暂无会话</div>'; return; }
2946
+ for(const g of sessionsGroupedCache){
2947
+ // 小号次要文本、不可点击、不可折叠;只是分组标签。
2948
+ const gh=document.createElement('div'); gh.className='sess-group'; gh.textContent=g.title; list.appendChild(gh);
2949
+ for(const s of g.items){
2950
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12.5px';
2951
+ div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
2952
+ const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=await uiPrompt('新标题:', s.file.replace(/\.jsonl$/,'')); if(!t) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',file:s.file,title:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ await refreshSessions(); openSessModal(); } else uiAlert(jj.error||'重命名失败'); };
2953
+ const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!await uiConfirm('删除会话 '+s.file+'?此操作不可恢复。')) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',file:s.file})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ if(currentSession===s.file){ currentSession=null; chatEl.innerHTML=''; renderEmptyState(); } await refreshSessions(); openSessModal(); } else uiAlert(jj.error||'删除失败'); };
2954
+ div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
2955
+ }
2873
2956
  }
2874
2957
  }
2875
2958
  $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); } };
@@ -173,6 +173,12 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
173
173
  .msg-user{display:flex;justify-content:flex-end}
174
174
  .msg-user .bubble{background:var(--user);border:1px solid rgba(255,255,255,.08);border-radius:22px;padding:10px 16px;max-width:min(525px,82%);white-space:pre-wrap;word-break:break-word;font-size:16px;line-height:24px}
175
175
  .msg-ai{display:flex;flex-direction:column;gap:10px;font-size:15px;color:var(--text)}
176
+ /* §3.84:消息时间——小号次要文本;缺失 ts 时前端根本不渲染此节点(不会出现 Invalid Date / undefined)。 */
177
+ .msg-time{font-size:11px;line-height:1.4;color:var(--dim);font-variant-numeric:tabular-nums}
178
+ .msg-user .bubble .msg-time{display:block;text-align:right;margin-top:4px}
179
+ .msg-ai>.msg-time{margin-bottom:-4px}
180
+ /* §3.84:会话分组标题(今天/昨天/本周/更早)——不可点击、不折叠。 */
181
+ .sess-group{font-size:11px;line-height:1.6;color:var(--dim);padding:7px 4px 2px;letter-spacing:.02em}
176
182
  .msg-ai .content{white-space:normal;word-break:break-word;line-height:1.75}
177
183
  .msg-ai .content h1,.msg-ai .content h2,.msg-ai .content h3,.msg-ai .content h4{color:var(--text);font-weight:700;line-height:1.4}
178
184
  .msg-ai .content h1{font-size:20px;margin:18px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
@@ -19,6 +19,35 @@ import {
19
19
  removeSessionWorkspace,
20
20
  } from '../../../workspace.js';
21
21
 
22
+ /**
23
+ * v0.6.22(§3.84):会话列表的**只增**元数据。
24
+ *
25
+ * 只读文件、绝不改写;坏行/不可读文件按 0 条处理,不得让整个列表接口 500——
26
+ * 真机上确实存在历史遗留的半截 JSONL(进程被 kill 时最后一行可能不完整)。
27
+ * 标题沿用既有事实来源:会话文件名(自动标题 / 手工重命名都会落到文件名)。
28
+ * @param {string} file 会话文件绝对路径
29
+ * @param {string} name 会话文件名
30
+ * @returns {{messageCount:number,title:string}}
31
+ */
32
+ function sessionMeta(file, name) {
33
+ let messageCount = 0;
34
+ try {
35
+ const raw = fs.readFileSync(file, 'utf8');
36
+ for (const line of raw.split('\n')) {
37
+ if (!line.trim()) continue;
38
+ try {
39
+ const m = JSON.parse(line);
40
+ if (m && typeof m === 'object' && ['system', 'user', 'assistant', 'tool'].includes(m.role)) messageCount += 1;
41
+ } catch {
42
+ // 单行损坏只跳过该行:旧会话/半截写入都不能把整个列表打挂。
43
+ }
44
+ }
45
+ } catch {
46
+ // 文件在 readdir 之后被删除/无权限:计数按 0,元数据仍可返回。
47
+ }
48
+ return { messageCount, title: name.replace(/\.jsonl$/, '') };
49
+ }
50
+
22
51
  /**
23
52
  * 会话域路由。命中返回 true,未命中返回 false。
24
53
  * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
@@ -33,9 +62,22 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
33
62
 
34
63
  if (method === 'GET' && p === '/api/sessions') {
35
64
  const q = url.searchParams.get('q');
65
+ // v0.6.22(§3.84):每项**只增** updatedAt / messageCount / title 三个字段;
66
+ // file / mtime / label 的既有语义与顺序不变(前端 crumb、命令面板、同步等仍按旧字段消费)。
67
+ const decorate = (/** @type {any} */ s, /** @type {string} */ label) => {
68
+ const meta = sessionMeta(path.join(home, 'sessions', path.basename(s.name)), s.name);
69
+ return {
70
+ file: s.name,
71
+ mtime: s.mtime,
72
+ label,
73
+ updatedAt: new Date(s.mtime).toISOString(),
74
+ messageCount: meta.messageCount,
75
+ title: meta.title,
76
+ };
77
+ };
36
78
  const found = q
37
- ? searchSessions(home, q, { limit: 30 }).map((/** @type {any} */ s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${s.snippet}` }))
38
- : listSessions(home).slice(0, 30).map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
79
+ ? searchSessions(home, q, { limit: 30 }).map((/** @type {any} */ s) => decorate(s, `${relativeTime(s.mtime)} · ${s.snippet}`))
80
+ : listSessions(home).slice(0, 30).map((s) => decorate(s, `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}`));
39
81
  json(res, 200, { ok: true, sessions: found });
40
82
  return true;
41
83
  }
@@ -54,7 +96,11 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
54
96
  }
55
97
  json(res, 200, {
56
98
  ok: true,
57
- messages: loaded.messages.filter((m) => m.role === 'user' || m.role === 'assistant').map((m) => ({ role: m.role, content: m.content ?? '' })),
99
+ // v0.6.22(§3.84):只增 ts。旧会话消息没有 ts → **null**(前端据此不显示时间),
100
+ // 绝不在这里用文件 mtime 冒充消息时间,也绝不因缺字段报错。
101
+ messages: loaded.messages
102
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
103
+ .map((m) => ({ role: m.role, content: m.content ?? '', ts: typeof m.ts === 'string' && m.ts ? m.ts : null })),
58
104
  workspace: ws?.name || null,
59
105
  // v0.3.0 P0-2:未完成检查点(供前端提示续跑)
60
106
  taskState: loadTaskState(path.basename(file)),
package/src/web/server.js CHANGED
@@ -77,6 +77,9 @@ 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
+ // v0.6.23(§3.87):Web 落盘**不再自建 stamp**——ts 的唯一补齐点是 `src/session.js` 的
81
+ // `appendMessages()`(CLI / REPL / 调度 / Web 共用);下面两处 chat 落盘都只调它,不引入第二套时间来源。
82
+
80
83
  /** 合法权限模式(与 /api/config 的校验同一口径:非法值一律忽略,绝不 fail-open)。 */
81
84
  const PERMISSION_MODES = new Set(['ask', 'auto', 'readonly']);
82
85
  /** 各档位的"会发生什么"人话说明(可见说明与前端提示共用同一份文案)。 */
@@ -882,8 +885,16 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
882
885
  }
883
886
  }
884
887
  messages.push({ role: 'user', content: built.content });
885
- // 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)
886
- await withSessionLock(session.file, () => appendMessages(session.file, [{ role: 'user', content: built.persistText }]));
888
+ // 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)。
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]));
894
+ {
895
+ const lastUserMsg = messages[messages.length - 1];
896
+ if (lastUserMsg && typeof lastUserMsg === 'object' && !lastUserMsg.ts && persistUser.ts) lastUserMsg.ts = persistUser.ts;
897
+ }
887
898
  let persistedBefore = messages.length;
888
899
 
889
900
  // 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
@@ -1065,7 +1076,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
1065
1076
  // 会话级路由统计(Hermes C2 升级检测):累计工具步数与截断次数,粘滞 flash 会话复杂度上来后自动升 planner
1066
1077
  const st = session.routeStats || { steps: 0, truncated: 0 };
1067
1078
  session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
1068
- await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
1079
+ // v0.6.23(§3.87):本回合新增的 assistant/tool 消息由 appendMessages 就地补 ts,
1080
+ // 内存数组随之带上时间戳——之后自动压缩整文件重写保留这些时间(历史消息不动)。
1081
+ {
1082
+ const tail = messages.slice(persistedBefore);
1083
+ await withSessionLock(session.file, () => appendMessages(session.file, tail));
1084
+ }
1069
1085
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
1070
1086
  // v0.4.4:补记最后一轮 + 兜底总结的剩余 usage(自动续跑轮已在 onUsage 逐轮入账,避免重复计)
1071
1087
  const remaining = {