dsh-any-agent 0.3.7 → 0.3.9

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/src/hub.mjs CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  } from './config.mjs';
52
52
  import { AcpProcess } from './acp.mjs';
53
53
  import { readConversationMeta } from './titles.mjs';
54
- import { registerWorkspace, findSessionHeader } from './workspace.mjs';
54
+ import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
55
55
  import {
56
56
  LOCKS_DIR,
57
57
  classifyHolder,
@@ -134,7 +134,7 @@ function isForgotten(id) {
134
134
  * 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
135
135
  * 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
136
136
  */
137
- async function pruneStaleTombstones() {
137
+ async function pruneStaleTombstones(log = NOOP_LOG) {
138
138
  let removed = 0;
139
139
  try {
140
140
  if (!existsSync(FORGOTTEN_DIR)) return 0;
@@ -142,7 +142,13 @@ async function pruneStaleTombstones() {
142
142
  if (!f.endsWith('.json')) continue;
143
143
  const id = f.slice(0, -5);
144
144
  // 异步读会话头:启动路径不能被大日志的同步解压卡住。
145
- if (await findSessionHeader(id)) continue;
145
+ // ★ 只有确认会话文件不存在时才删墓碑;读超时/损坏时保留,
146
+ // 否则一次慢盘/挂载异常就会让 forget 失效。
147
+ const found = await findSessionHeaderStatus(id);
148
+ if (found.status === 'found' || found.status === 'error') {
149
+ if (found.status === 'error') log.info(`[hub] 墓碑 ${id} 暂不清理:${found.reason}`);
150
+ continue;
151
+ }
146
152
  try {
147
153
  rmSync(join(FORGOTTEN_DIR, f), { force: true });
148
154
  removed++;
@@ -247,8 +253,9 @@ async function applySessionConfig(proc, sessionId, cfg) {
247
253
  */
248
254
  export function sanitizeResultForSentinel(result) {
249
255
  if (!result || typeof result !== 'object') return result ?? null;
250
- if (SENTINEL_INCLUDE_REASONING) return result;
251
256
  const { thinking, ...rest } = result;
257
+ // ★ 即使 DSH_MCP_SENTINEL_INCLUDE_REASONING=1,也只额外保留 thinking;
258
+ // answer/error/title/tools_used 仍然必须脱敏(以前这个开关会跳过整段清洗)。
252
259
  // 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
253
260
  for (const key of ['answer', 'error', 'title']) {
254
261
  if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
@@ -264,7 +271,9 @@ export function sanitizeResultForSentinel(result) {
264
271
  });
265
272
  }
266
273
  // 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
267
- return { ...rest, thinking_omitted: Boolean(thinking) };
274
+ const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
275
+ if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
276
+ return out;
268
277
  }
269
278
 
270
279
  export class Conversation {
@@ -462,9 +471,10 @@ export class Conversation {
462
471
  // 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
463
472
  // 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
464
473
  // 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
465
- await proc.stop().catch(() => {});
466
- // 只删**自己的**登记(removeMarker 会校验 token);别人的登记必须留着当线索
467
- removeMarker(this.id);
474
+ const stopped = await proc.stop().catch(() => false);
475
+ // 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
476
+ // 让 classifyHolder/takeover 仍能归因。
477
+ if (stopped) removeMarker(this.id);
468
478
  // 写锁冲突是最常见也最需要"说人话"的失败:DSH 的 SessionAlreadyOwnedError 会被
469
479
  // ACP 包成 -32603 "Internal error",只说这句调用方根本不知道该怎么办。
470
480
  if (isLeaseError(e.message)) {
@@ -500,11 +510,14 @@ export class Conversation {
500
510
  *
501
511
  * 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
502
512
  * session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
503
- * read-only 被静默提升)。投影缓存可能稍后才刷盘,所以短轮询;读不到时
504
- * 不阻断(read-only 仍有审批层的硬拒绝兜底),但读到不一致就立刻失败。
513
+ * read-only 被静默提升)。投影缓存可能稍后才刷盘;read-only 必须 fail-closed:
514
+ * 等待至少一个刷盘周期,仍读不到就拒绝继续,不能依赖“审批层兜底”
515
+ * (danger sandbox 下根本不会产生审批请求)。
505
516
  */
506
- async verifyPermissionRecorded({ attempts = 6, delayMs = 250 } = {}) {
507
- for (let i = 0; i < attempts; i++) {
517
+ async verifyPermissionRecorded({ attempts, delayMs = 250 } = {}) {
518
+ // read-only 需要覆盖 DSH 约 5 秒的投影缓存刷新周期;其它档位保持较短等待。
519
+ const tries = Number.isFinite(attempts) ? attempts : this.permission === 'read-only' ? 40 : 6;
520
+ for (let i = 0; i < tries; i++) {
508
521
  const meta = readConversationMeta(this.id);
509
522
  const recorded = meta?.permissions?.preset;
510
523
  if (recorded) {
@@ -519,11 +532,18 @@ export class Conversation {
519
532
  this.permissionVerified = true;
520
533
  return recorded;
521
534
  }
522
- if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
535
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, delayMs));
523
536
  }
524
537
  this.permissionVerified = false;
538
+ if (this.permission === 'read-only') {
539
+ throw new Error(
540
+ `无法在 ${(tries * delayMs) / 1000}s 内确认会话实际为 read-only(投影缓存无权限记录)。\n` +
541
+ ' 为避免 profile 的 defaultPreset 把只读会话静默提升为完全访问,本次调用已拒绝。\n' +
542
+ ' 请确认 DSH 会话存储可读,或稍后重试;非只读档位不受此限制。',
543
+ );
544
+ }
525
545
  this.#hub.log.info(
526
- `[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(read-only 仍由审批层强制拒绝)`,
546
+ `[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(非只读档)`,
527
547
  );
528
548
  return null;
529
549
  }
@@ -565,7 +585,8 @@ export class Conversation {
565
585
  report.holder_kind = holder.kind;
566
586
  report.holder = holder.marker;
567
587
  // 登记可能已随回收被删,但"我们自己的残留子进程"仍可归因(见 locks.classifyHolder)
568
- const killTarget = holder.marker?.child_pid ?? holder.known_child_pid ?? null;
588
+ const markerChild = holder.marker?.child_pid ?? null;
589
+ const killTarget = isPidAlive(markerChild) ? markerChild : (holder.known_child_pid ?? markerChild);
569
590
 
570
591
  if (holder.kind === 'none' || !holder.childAlive) {
571
592
  report.message =
@@ -857,7 +878,7 @@ export class Conversation {
857
878
  },
858
879
  tools_used: [...c.tools.values()],
859
880
  usage: c.usage,
860
- workspace_changed: before !== after,
881
+ workspace_changed: !sameWorkspaceSnapshot(before, after),
861
882
  diff_stat: after,
862
883
  elapsed_ms: now() - t0,
863
884
  };
@@ -1092,11 +1113,12 @@ export class Conversation {
1092
1113
  async close({ forget = false } = {}) {
1093
1114
  const proc = this.proc;
1094
1115
  this.proc = null;
1116
+ let stopped = true;
1095
1117
  if (proc?.alive) {
1096
1118
  try {
1097
1119
  await proc.request('session/close', { sessionId: this.id }, 10_000);
1098
1120
  } catch {}
1099
- await proc.stop();
1121
+ stopped = await proc.stop().catch(() => false);
1100
1122
  }
1101
1123
  // 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
1102
1124
  for (const [, p] of this.pendingApprovals) {
@@ -1105,18 +1127,32 @@ export class Conversation {
1105
1127
  } catch {}
1106
1128
  }
1107
1129
  this.pendingApprovals.clear();
1108
- // 进程没了 → 写锁也释放了,登记必须跟着删(否则会把"孤儿持有者"误报给抢占逻辑)
1109
- removeMarker(this.id);
1130
+ // 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
1131
+ // 还能把这个仍握着写锁的孤儿归因出来。
1132
+ if (stopped) removeMarker(this.id);
1133
+ else {
1134
+ this.#hub.log.info(`[conv ${this.id}] 进程未能确认退出,保留写锁登记以便后续归因`);
1135
+ // 进程还活着时不能 forget:墓碑会让按 id 查询直接失败,而活着的进程
1136
+ // 仍握着写锁,之后连 takeover 都无法归因/接管。
1137
+ if (forget) return { closed: false, forgotten: false, error: '进程未能确认退出,拒绝 forget' };
1138
+ }
1110
1139
  if (forget) {
1111
1140
  // 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
1112
1141
  // 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
1113
1142
  writeTombstone(this.id);
1114
1143
  this.#hub.remove(this.id);
1115
1144
  } else this.#hub.save();
1116
- return { closed: true, forgotten: forget };
1145
+ return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
1117
1146
  }
1118
1147
  }
1119
1148
 
1149
+ /** 两次 git status 快照是否表示同一组工作区变更。 */
1150
+ export function sameWorkspaceSnapshot(a, b) {
1151
+ if (!a && !b) return true;
1152
+ if (!a || !b) return false;
1153
+ return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
1154
+ }
1155
+
1120
1156
  /** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
1121
1157
  export function gitStatus(cwd) {
1122
1158
  if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
@@ -1207,6 +1243,8 @@ export class Hub {
1207
1243
  }
1208
1244
 
1209
1245
  #reaper;
1246
+ /** 同一 id 的磁盘接管 in-flight Promise,防止并发请求各自构造一个 Conversation。 */
1247
+ #adopting = new Map();
1210
1248
  /** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
1211
1249
  #probeCache = null;
1212
1250
 
@@ -1235,7 +1273,7 @@ export class Hub {
1235
1273
  }
1236
1274
  }
1237
1275
 
1238
- const stale = await pruneStaleTombstones();
1276
+ const stale = await pruneStaleTombstones(this.log);
1239
1277
  if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
1240
1278
  if (!existsSync(STATE_FILE)) return;
1241
1279
  try {
@@ -1390,10 +1428,26 @@ export class Hub {
1390
1428
  this.adoptNewFromDisk();
1391
1429
  c = this.conversations.get(id);
1392
1430
  }
1431
+ if (!c) {
1432
+ // 同一个磁盘态会话的并发查询共用一次 adopt,避免各自构造 Conversation。
1433
+ const inflight = this.#adopting.get(id);
1434
+ if (inflight) {
1435
+ await inflight;
1436
+ c = this.conversations.get(id);
1437
+ }
1438
+ }
1393
1439
  if (!c) {
1394
1440
  // 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
1395
1441
  // 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
1396
- const header = await findSessionHeader(id);
1442
+ let release;
1443
+ const gate = new Promise((resolve) => { release = resolve; });
1444
+ this.#adopting.set(id, gate);
1445
+ try {
1446
+ const found = await findSessionHeaderStatus(id);
1447
+ if (found.status === 'error') {
1448
+ throw new Error(`无法读取会话 ${id} 的会话头:${found.reason}`);
1449
+ }
1450
+ const header = found.header;
1397
1451
  if (header?.cwd) {
1398
1452
  // ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
1399
1453
  // 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
@@ -1442,6 +1496,10 @@ export class Hub {
1442
1496
  this.save();
1443
1497
  this.log.info(`[hub] 从会话存储吸收了 ${id}(注册表里没有,但磁盘上有;权限档沿用 ${permission})`);
1444
1498
  }
1499
+ } finally {
1500
+ this.#adopting.delete(id);
1501
+ release();
1502
+ }
1445
1503
  }
1446
1504
  if (!c) {
1447
1505
  throw new Error(
@@ -1593,8 +1651,8 @@ export class Hub {
1593
1651
  // 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
1594
1652
  // 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
1595
1653
  // 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
1596
- if (createdSessionId) removeMarker(createdSessionId);
1597
- await proc.stop().catch(() => {});
1654
+ const stopped = await proc.stop().catch(() => false);
1655
+ if (createdSessionId && stopped) removeMarker(createdSessionId);
1598
1656
  throw e;
1599
1657
  }
1600
1658
  }
@@ -1732,8 +1790,8 @@ export class Hub {
1732
1790
  // ★ 顺序要紧:**先确认子进程真的退出(写锁真的释放),再删登记**。
1733
1791
  // 反过来的话,一旦它没死透,锁的持有者就变成"无登记的未知进程" ——
1734
1792
  // 既报不清是谁,连 dsh_takeover 都会拒绝(判成 none)。
1735
- await proc.stop().catch(() => {});
1736
- removeMarker(c.id);
1793
+ const stopped = await proc.stop().catch(() => false);
1794
+ if (stopped) removeMarker(c.id);
1737
1795
  }
1738
1796
  } catch (e) {
1739
1797
  this.log.info(`[hub] 回收周期异常(已忽略,下个周期继续): ${e.message}`);
@@ -1751,6 +1809,7 @@ export class Hub {
1751
1809
  async releaseAll({ force = false } = {}) {
1752
1810
  const released = [];
1753
1811
  const skippedBusy = [];
1812
+ const failed = [];
1754
1813
  for (const c of this.conversations.values()) {
1755
1814
  if (!c.proc?.alive) continue;
1756
1815
  // ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
@@ -1761,12 +1820,18 @@ export class Hub {
1761
1820
  }
1762
1821
  const proc = c.proc;
1763
1822
  c.proc = null;
1764
- await proc.stop().catch(() => {});
1765
- removeMarker(c.id);
1766
- released.push(c.id);
1823
+ const stopped = await proc.stop().catch(() => false);
1824
+ if (stopped) {
1825
+ removeMarker(c.id);
1826
+ released.push(c.id);
1827
+ } else {
1828
+ // 进程没确认退出:保留登记,让 takeover 还能归因这个孤儿。
1829
+ failed.push(c.id);
1830
+ this.log.info(`[hub] 释放 ${c.id} 时进程未确认退出,保留写锁登记`);
1831
+ }
1767
1832
  }
1768
1833
  this.save();
1769
- return { released: released.length, conversations: released, skipped_busy: skippedBusy };
1834
+ return { released: released.length, conversations: released, skipped_busy: skippedBusy, failed };
1770
1835
  }
1771
1836
 
1772
1837
  async shutdown() {
@@ -1775,9 +1840,10 @@ export class Hub {
1775
1840
  for (const c of this.conversations.values()) {
1776
1841
  const proc = c.proc;
1777
1842
  c.proc = null;
1778
- await proc?.stop().catch(() => {});
1779
- // 子进程停了,锁就释放了;登记也要删,否则下个实例会把我们看成"孤儿持有者"
1780
- removeMarker(c.id);
1843
+ const stopped = await proc?.stop().catch(() => false);
1844
+ // 只有确认子进程退出、锁真的释放后才删登记;否则留给下个实例归因。
1845
+ if (stopped) removeMarker(c.id);
1846
+ else this.log.info(`[hub] 关闭 ${c.id} 时进程未确认退出,保留写锁登记`);
1781
1847
  }
1782
1848
  this.save();
1783
1849
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 行式 UTF-8 解码器。
3
+ *
4
+ * Node 的 stream 回调给的是 Buffer;直接对每个 chunk 调 `toString('utf8')`
5
+ * 会在 chunk 边界把一个多字节字符(中文/emoji)截成 U+FFFD,JSON 仍然合法,
6
+ * 但内容被静默改写。StringDecoder 会跨 chunk 保留未完成的多字节序列。
7
+ *
8
+ * 同时负责按 `\n` 切行;行内保留 `\r`,由调用方按需 trim。
9
+ */
10
+ import { StringDecoder } from 'node:string_decoder';
11
+
12
+ export class LineDecoder {
13
+ #decoder = new StringDecoder('utf8');
14
+ #buf = '';
15
+
16
+ /** 喂一个 Buffer;返回本轮切出的完整行(不含换行符)。 */
17
+ push(chunk) {
18
+ this.#buf += this.#decoder.write(chunk);
19
+ return this.#drain(false);
20
+ }
21
+
22
+ /** EOF:把 decoder 里残留的半个字符和最后一行一起吐出来。 */
23
+ end() {
24
+ this.#buf += this.#decoder.end();
25
+ return this.#drain(true);
26
+ }
27
+
28
+ #drain(flush) {
29
+ const lines = [];
30
+ let i;
31
+ while ((i = this.#buf.indexOf('\n')) !== -1) {
32
+ lines.push(this.#buf.slice(0, i));
33
+ this.#buf = this.#buf.slice(i + 1);
34
+ }
35
+ if (flush && this.#buf) {
36
+ lines.push(this.#buf);
37
+ this.#buf = '';
38
+ }
39
+ return lines;
40
+ }
41
+ }
package/src/locks.mjs CHANGED
@@ -106,7 +106,12 @@ export function writeMarker(conversationId, { childPid, cwd }) {
106
106
  mcp_token: MCP_TOKEN,
107
107
  child_pid: childPid ?? null,
108
108
  cwd: cwd ?? null,
109
- spawned_at: prev?.spawned_at ?? now,
109
+ // 只有同一个子进程才沿用旧启动时间;子进程换了(PID 变化)必须重置,
110
+ // 否则 PID 复用保护会把新进程误判成“复用旧 PID”而拒绝 takeover。
111
+ spawned_at:
112
+ prev && prev.child_pid === childPid && Number.isFinite(prev.spawned_at)
113
+ ? prev.spawned_at
114
+ : now,
110
115
  heartbeat_at: now,
111
116
  });
112
117
  return true;
package/src/mcp.mjs CHANGED
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { createHandlers, TOOL_DEFS } from './tools.mjs';
12
12
  import { DEFAULT_PERMISSION } from './config.mjs';
13
+ import { LineDecoder } from './line-decoder.mjs';
13
14
 
14
15
  const SERVER_NAME = 'dsh-any-agent';
15
16
  /**
@@ -117,7 +118,7 @@ function fileURLToPathSafe(uri) {
117
118
 
118
119
  export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHandlers(hub), log }) {
119
120
  const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
120
- let buffer = '';
121
+ const lineDecoder = new LineDecoder();
121
122
  let initialized = false;
122
123
  let clientCapabilities = {};
123
124
  let rootsSeq = 0;
@@ -251,11 +252,7 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
251
252
  }
252
253
 
253
254
  function onData(chunk) {
254
- buffer += chunk.toString('utf8');
255
- let idx;
256
- while ((idx = buffer.indexOf('\n')) !== -1) {
257
- const line = buffer.slice(0, idx);
258
- buffer = buffer.slice(idx + 1);
255
+ for (const line of lineDecoder.push(chunk)) {
259
256
  if (!line.trim()) continue;
260
257
  let msg;
261
258
  try {
package/src/tools.mjs CHANGED
@@ -292,7 +292,7 @@ function renderSendResult(r) {
292
292
  }
293
293
  const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
294
294
  if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
295
- if (r.workspace_changed) meta.push(`工作区有改动(${r.diff_stat?.changed_files ?? '?'} 个文件)`);
295
+ if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
296
296
  lines.push('');
297
297
  lines.push(`[${meta.join(' · ')}]`);
298
298
 
@@ -611,12 +611,15 @@ export function createHandlers(hub, opts = {}) {
611
611
  if (args.all === true) {
612
612
  const r = await hub.releaseAll({ force: args.force === true });
613
613
  return {
614
- structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
614
+ structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy, failed: r.failed },
615
615
  text:
616
616
  `已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
617
617
  (r.skipped_busy.length
618
618
  ? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
619
619
  : '') +
620
+ (r.failed.length
621
+ ? `\n⚠️ ${r.failed.length} 个会话的进程未确认退出,写锁登记已保留;可稍后用 dsh_status/dsh_takeover 处理。`
622
+ : '') +
620
623
  `\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
621
624
  };
622
625
  }
package/src/workspace.mjs CHANGED
@@ -19,6 +19,7 @@ import { copyFileSync, createReadStream, readFileSync, writeFileSync, existsSync
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
21
21
  import { tmpdir } from 'node:os';
22
+ import { StringDecoder } from 'node:string_decoder';
22
23
  import { createZstdDecompress, zstdDecompressSync } from 'node:zlib';
23
24
  import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
24
25
 
@@ -245,6 +246,7 @@ export async function readHeaderAsync(file, { timeoutMs = 5000, maxHeaderBytes =
245
246
  let settled = false;
246
247
  let buf = '';
247
248
  let timer = null;
249
+ const decoder = new StringDecoder('utf8');
248
250
  const rs = createReadStream(file);
249
251
  const zs = createZstdDecompress();
250
252
  const finish = (value) => {
@@ -268,12 +270,15 @@ export async function readHeaderAsync(file, { timeoutMs = 5000, maxHeaderBytes =
268
270
  rs.on('error', () => finish(null));
269
271
  zs.on('error', () => finish(null));
270
272
  zs.on('data', (chunk) => {
271
- buf += chunk.toString('utf8');
273
+ // 用 StringDecoder 处理跨 chunk 的多字节 UTF-8(中文/emoji 路径),
274
+ // 避免 chunk.toString() 把字符截成乱码。
275
+ buf += decoder.write(chunk);
272
276
  const nl = buf.search(/\r?\n/);
273
277
  if (nl >= 0) parseLine(buf.slice(0, nl));
274
278
  else if (buf.length > maxHeaderBytes) finish(null);
275
279
  });
276
280
  zs.on('end', () => {
281
+ buf += decoder.end();
277
282
  const line = buf.split(/\r?\n/).find(Boolean);
278
283
  if (line) parseLine(line);
279
284
  else finish(null);
@@ -324,22 +329,33 @@ export function scanSessions() {
324
329
  *
325
330
  * 会对 id 做形状校验(只允许 UUID / `session-` 前缀那类字符),避免 `..` 之类逃出会话目录。
326
331
  */
327
- export async function findSessionHeader(sessionId) {
328
- if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
329
- if (!existsSync(SESSIONS_ROOT)) return null;
332
+ export async function findSessionHeaderStatus(sessionId) {
333
+ if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) {
334
+ return { status: 'missing', header: null, reason: 'id 形状非法' };
335
+ }
336
+ if (!existsSync(SESSIONS_ROOT)) return { status: 'missing', header: null, reason: '会话目录不存在' };
330
337
  let buckets;
331
338
  try {
332
339
  buckets = readdirSync(SESSIONS_ROOT);
333
- } catch {
334
- return null;
340
+ } catch (e) {
341
+ return { status: 'error', header: null, reason: `会话目录不可读: ${e.message}` };
335
342
  }
336
343
  for (const bucket of buckets) {
337
344
  const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
338
345
  if (!existsSync(f)) continue;
339
346
  const header = await readHeaderAsync(f);
340
- if (header?.id === sessionId) return header;
347
+ if (header?.id === sessionId) return { status: 'found', header, file: f };
348
+ if (header === null) {
349
+ // 文件存在但头读不出来(超时/损坏/IO):不能当成“会话不存在”,
350
+ // 否则 forget 墓碑会被误删、按 id 查询也会误报未知会话。
351
+ return { status: 'error', header: null, reason: `无法读取会话头: ${f}`, file: f };
352
+ }
341
353
  }
342
- return null;
354
+ return { status: 'missing', header: null, reason: '会话文件不存在' };
355
+ }
356
+
357
+ export async function findSessionHeader(sessionId) {
358
+ return (await findSessionHeaderStatus(sessionId)).header;
343
359
  }
344
360
 
345
361
  /**