dsh-any-agent 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hub.mjs CHANGED
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import {
17
+ copyFileSync,
18
+ cpSync,
17
19
  mkdirSync,
18
20
  readFileSync,
19
21
  writeFileSync,
@@ -41,13 +43,17 @@ import {
41
43
  SENTINEL_INCLUDE_REASONING,
42
44
  SENTINEL_TTL_MS,
43
45
  STATE_FILE,
46
+ LEGACY_STATE_FILE,
47
+ USING_DEFAULT_STATE,
44
48
  DSH_HOME,
49
+ assertWritableDir,
45
50
  defaultApprovalPolicy,
46
51
  } from './config.mjs';
47
52
  import { AcpProcess } from './acp.mjs';
48
53
  import { readConversationMeta } from './titles.mjs';
49
54
  import { registerWorkspace, findSessionHeader } from './workspace.mjs';
50
55
  import {
56
+ LOCKS_DIR,
51
57
  classifyHolder,
52
58
  describeLeaseConflict,
53
59
  heartbeat as lockHeartbeat,
@@ -149,6 +155,8 @@ function pruneStaleTombstones() {
149
155
 
150
156
  /** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
151
157
  const MAX_OUT_ENTRIES = 500;
158
+ /** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
159
+ const MAX_OUT_TEXT = 200_000;
152
160
  /** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
153
161
  const MAX_PROMPT_ECHO = 4000;
154
162
  /** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
@@ -240,13 +248,18 @@ export function sanitizeResultForSentinel(result) {
240
248
  if (!result || typeof result !== 'object') return result ?? null;
241
249
  if (SENTINEL_INCLUDE_REASONING) return result;
242
250
  const { thinking, ...rest } = result;
251
+ // 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
252
+ for (const key of ['answer', 'error', 'title']) {
253
+ if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
254
+ }
243
255
  // 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
244
256
  // 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
245
257
  if (Array.isArray(rest.tools_used)) {
246
258
  rest.tools_used = rest.tools_used.map((t) => {
247
259
  if (!t || typeof t !== 'object') return t;
248
260
  const { input, ...meta } = t;
249
- return input === undefined ? meta : { ...meta, input: sanitizeApprovalContext(input) };
261
+ const safeMeta = sanitizeApprovalContext(meta);
262
+ return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
250
263
  });
251
264
  }
252
265
  // 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
@@ -271,6 +284,8 @@ export class Conversation {
271
284
  model: state.model ?? null,
272
285
  };
273
286
  this.lastConfigApplied = null;
287
+ /** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
288
+ this.permissionVerified = null;
274
289
  /** 工作区登记结果(决定你的 DSH GUI 里能不能看到这个会话)。 */
275
290
  this.workspace = state.workspace ?? null;
276
291
  this.#hub = hub;
@@ -337,10 +352,15 @@ export class Conversation {
337
352
  snapshot() {
338
353
  const meta = readConversationMeta(this.id);
339
354
  const nativeTitle = meta?.title ?? null;
355
+ const holder = this.proc?.alive
356
+ ? { kind: 'self', reason: '本进程持有该会话的写锁' }
357
+ : classifyHolder(this.id, { knownChildPid: this.lastChildPid });
340
358
  return {
341
359
  conversation_id: this.id,
342
360
  /** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
343
361
  title: nativeTitle ?? this.titleHint ?? null,
362
+ /** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
363
+ title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
344
364
  /**
345
365
  * DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
346
366
  * 例如「这是一次连通性冒烟测试,请」),信息量很低。
@@ -350,6 +370,8 @@ export class Conversation {
350
370
  title_hint: this.titleHint ?? null,
351
371
  cwd: this.cwd,
352
372
  permission: this.permission,
373
+ /** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
374
+ permission_verified: this.permissionVerified,
353
375
  on_approval: this.onApproval,
354
376
  alive: !!this.proc?.alive,
355
377
  busy: this.busy,
@@ -375,8 +397,13 @@ export class Conversation {
375
397
  approval_id: approvalId,
376
398
  ...pending.params.details,
377
399
  })),
378
- /** 不在本进程手里时,写锁在谁手上(none=多半是你自己的 GUI / stale-mcp=可抢占的孤儿 / live-mcp=另一个实例)。 */
379
- lock_holder: this.proc?.alive ? 'self' : classifyHolder(this.id, { knownChildPid: this.lastChildPid }).kind,
400
+ /**
401
+ * 不在本进程手里时,写锁在谁手上:
402
+ * self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
403
+ * 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
404
+ */
405
+ lock_holder: holder.kind,
406
+ lock_holder_reason: holder.reason ?? null,
380
407
  /** ★ 最近一次"撞写锁"的持久记录。
381
408
  * 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
382
409
  * dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
@@ -418,6 +445,8 @@ export class Conversation {
418
445
  // 新进程不记得会话级配置(思考深度/模型),resume 后必须重套
419
446
  this.lastConfigApplied = await applySessionConfig(proc, this.id, this.cfg);
420
447
  for (const f of this.lastConfigApplied.failed) this.#hub.log.info(`[conv ${this.id}] ${f}`);
448
+ // ★ 回读会话自己的权限记录,防止 profile defaultPreset 无声提权。
449
+ await this.verifyPermissionRecorded();
421
450
  // 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
422
451
  writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
423
452
  this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
@@ -465,6 +494,39 @@ export class Conversation {
465
494
  }
466
495
  }
467
496
 
497
+ /**
498
+ * 核对**会话自己记录**的权限档是否与请求一致。
499
+ *
500
+ * 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
501
+ * session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
502
+ * read-only 被静默提升)。投影缓存可能稍后才刷盘,所以短轮询;读不到时
503
+ * 不阻断(read-only 仍有审批层的硬拒绝兜底),但读到不一致就立刻失败。
504
+ */
505
+ async verifyPermissionRecorded({ attempts = 6, delayMs = 250 } = {}) {
506
+ for (let i = 0; i < attempts; i++) {
507
+ const meta = readConversationMeta(this.id);
508
+ const recorded = meta?.permissions?.preset;
509
+ if (recorded) {
510
+ if (!PERMISSION_TIERS.includes(recorded) || recorded !== this.permission) {
511
+ throw new Error(
512
+ `会话实际权限档为 ${JSON.stringify(recorded)},与请求的 ${this.permission} 不一致。\n` +
513
+ ' 这通常意味着 DSH profile 里写了 permission.defaultPreset,覆盖了启动时传入的权限档。\n' +
514
+ ` 请检查 ${join(DSH_HOME, 'profiles', process.env.DSH_MCP_PROFILE ?? 'dsh-mcp', 'cordis.patch.yml')},` +
515
+ '删掉 permission.defaultPreset 后重试。',
516
+ );
517
+ }
518
+ this.permissionVerified = true;
519
+ return recorded;
520
+ }
521
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
522
+ }
523
+ this.permissionVerified = false;
524
+ this.#hub.log.info(
525
+ `[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(read-only 仍由审批层强制拒绝)`,
526
+ );
527
+ return null;
528
+ }
529
+
468
530
  /**
469
531
  * 抢占写锁。
470
532
  *
@@ -519,7 +581,7 @@ export class Conversation {
519
581
  }
520
582
 
521
583
  // 3) 杀 + 重试(内核在持有者进程退出时释放信号量)
522
- const kill = killHolderChild(killTarget);
584
+ const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
523
585
  report.killed_pid = killTarget;
524
586
  report.kill = kill;
525
587
  if (!kill.killed) {
@@ -806,7 +868,9 @@ export class Conversation {
806
868
  // 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
807
869
  // 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
808
870
  // 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
809
- this.#hub.writeRunSentinel(this.id, runRecord);
871
+ const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
872
+ result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
873
+ if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
810
874
  return result;
811
875
  } catch (e) {
812
876
  this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
@@ -830,7 +894,9 @@ export class Conversation {
830
894
  runRecord.status = 'error';
831
895
  runRecord.error = fallback.error;
832
896
  runRecord.result = fallback;
833
- this.#hub.writeRunSentinel(this.id, runRecord);
897
+ const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
898
+ fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
899
+ if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
834
900
  return fallback;
835
901
  }
836
902
  };
@@ -895,14 +961,15 @@ export class Conversation {
895
961
  /** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
896
962
  pushOut(kind, text, extra = {}) {
897
963
  const last = this.out[this.out.length - 1];
964
+ const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
898
965
  if (last && last.kind === kind && last.turn === this.currentTurn && text) {
899
- last.text += text;
966
+ last.text = cap(last.text + text);
900
967
  last.at = now();
901
968
  // ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
902
969
  // 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
903
970
  last.rev = ++this.outRev;
904
971
  } else {
905
- this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
972
+ this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
906
973
  if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
907
974
  }
908
975
  if (kind === 'text') this.turnOutChars += text?.length ?? 0;
@@ -1051,8 +1118,17 @@ export class Conversation {
1051
1118
 
1052
1119
  /** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
1053
1120
  export function gitStatus(cwd) {
1121
+ if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
1054
1122
  try {
1055
- const r = spawnSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8', timeout: 8000 });
1123
+ // ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
1124
+ // 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
1125
+ // DSH_MCP_GIT_STATUS=0 完全关掉。
1126
+ const r = spawnSync('git', ['-c', 'core.fsmonitor=false', 'status', '--porcelain', '--no-optional-locks'], {
1127
+ cwd,
1128
+ encoding: 'utf8',
1129
+ timeout: 8000,
1130
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
1131
+ });
1056
1132
  if (r.status !== 0 || !r.stdout) return null;
1057
1133
  const lines = r.stdout.split('\n').filter(Boolean);
1058
1134
  if (!lines.length) return null;
@@ -1120,7 +1196,12 @@ export class Hub {
1120
1196
  constructor({ log } = {}) {
1121
1197
  this.conversations = new Map();
1122
1198
  this.log = log ?? NOOP_LOG;
1123
- this.approvalTimeoutMs = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
1199
+ /** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
1200
+ this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
1201
+ const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
1202
+ // 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
1203
+ this.approvalTimeoutMs =
1204
+ Number.isFinite(rawApprovalTimeout) && rawApprovalTimeout > 0 ? rawApprovalTimeout : 5 * 60 * 1000;
1124
1205
  this.#reaper = null;
1125
1206
  }
1126
1207
 
@@ -1129,6 +1210,30 @@ export class Hub {
1129
1210
  #probeCache = null;
1130
1211
 
1131
1212
  load() {
1213
+ // ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
1214
+ // 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
1215
+ assertWritableDir(dirname(STATE_FILE), '状态目录');
1216
+ assertWritableDir(RUNS_DIR, '哨兵目录');
1217
+ assertWritableDir(LOCKS_DIR, '锁登记目录');
1218
+
1219
+ // 旧版本的默认状态文件在包安装目录里;一次性迁移到新的用户可写目录。
1220
+ // 只在没有显式设置 DSH_MCP_STATE 时做,避免覆盖用户指定的路径。
1221
+ if (USING_DEFAULT_STATE && !existsSync(STATE_FILE) && existsSync(LEGACY_STATE_FILE)) {
1222
+ try {
1223
+ copyFileSync(LEGACY_STATE_FILE, STATE_FILE);
1224
+ this.log.info(`[hub] 已迁移旧状态文件: ${LEGACY_STATE_FILE} → ${STATE_FILE}`);
1225
+ // 侧车元数据(审批策略/思考深度/标题/轮数)也一并搬过来;
1226
+ // 否则旧会话按 id 重建时会悄悄回落到默认值。
1227
+ const legacyMeta = join(dirname(LEGACY_STATE_FILE), 'meta');
1228
+ if (existsSync(legacyMeta) && !existsSync(META_DIR)) {
1229
+ cpSync(legacyMeta, META_DIR, { recursive: true });
1230
+ this.log.info(`[hub] 已迁移旧元数据侧车: ${legacyMeta} → ${META_DIR}`);
1231
+ }
1232
+ } catch (e) {
1233
+ this.log.info(`[hub] 旧状态文件迁移失败(继续用新路径): ${e.message}`);
1234
+ }
1235
+ }
1236
+
1132
1237
  const stale = pruneStaleTombstones();
1133
1238
  if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
1134
1239
  if (!existsSync(STATE_FILE)) return;
@@ -1186,24 +1291,34 @@ export class Hub {
1186
1291
  return join(RUNS_DIR, conversationId, `${runId}.json`);
1187
1292
  }
1188
1293
 
1189
- /** 写完成哨兵(原子)。失败只记日志,绝不影响主流程。 */
1294
+ /**
1295
+ * 写完成哨兵(原子)。
1296
+ * @returns {string|null} 成功返回文件路径;失败返回 null,并把原因记进 run.sentinel_error。
1297
+ *
1298
+ * 失败不阻断回合收尾,但**必须让调用方看得见**:以前只记 silent 日志,
1299
+ * 调用方会拿着一个永远不会出现的路径一直等。
1300
+ */
1190
1301
  writeRunSentinel(conversationId, run) {
1302
+ const file = this.runSentinelPath(conversationId, run.run_id);
1191
1303
  try {
1192
- const file = this.runSentinelPath(conversationId, run.run_id);
1193
1304
  mkdirSync(dirname(file), { recursive: true });
1194
1305
  const payload = {
1195
1306
  conversation_id: conversationId,
1196
1307
  run_id: run.run_id,
1197
1308
  status: run.status ?? 'unknown',
1198
1309
  ended_at: run.ended_at ?? Date.now(),
1199
- error: run.error ?? run.result?.error ?? null,
1310
+ error: sanitizeApprovalContext(run.error ?? run.result?.error ?? null, 'error'),
1200
1311
  result: sanitizeResultForSentinel(run.result ?? null),
1201
1312
  };
1202
1313
  const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
1203
1314
  writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
1204
1315
  renameSync(tmp, file);
1316
+ return file;
1205
1317
  } catch (e) {
1206
- this.log.info(`[hub] 写完成哨兵失败: ${e.message}`);
1318
+ const msg = `写完成哨兵失败(${file}): ${e.message}`;
1319
+ run.sentinel_error = msg;
1320
+ this.log.info(`[hub] ${msg}`);
1321
+ return null;
1207
1322
  }
1208
1323
  }
1209
1324
 
@@ -1287,12 +1402,15 @@ export class Hub {
1287
1402
  const recordedPreset = meta?.permissions?.preset;
1288
1403
  const recordedPermission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : null;
1289
1404
  // 安全优先:会话记录说 read-only 就绝不能被侧车“提升”为可写档。
1405
+ // ★ 档位未知(没有投影缓存、也没有本服务侧车)时 **fail-closed 到 read-only**。
1406
+ // 以前回落到 DEFAULT_PERMISSION(danger-full-access),等于把一个可能是
1407
+ // 只读的外部会话悄悄提升为完全访问。宁可让用户显式新建会话,也不要无声提权。
1290
1408
  const permission =
1291
1409
  recordedPermission === 'read-only'
1292
1410
  ? 'read-only'
1293
1411
  : PERMISSION_TIERS.includes(persisted?.permission)
1294
1412
  ? persisted.permission
1295
- : (recordedPermission ?? DEFAULT_PERMISSION);
1413
+ : (recordedPermission ?? 'read-only');
1296
1414
  const recordedApproval = meta?.permissions?.approval;
1297
1415
  let onApproval = APPROVAL_POLICIES.includes(persisted?.onApproval)
1298
1416
  ? persisted.onApproval
@@ -1452,6 +1570,8 @@ export class Hub {
1452
1570
  ` 请检查 ${provider}/${model} 是否在 profile 中声明;不传这两个参数可使用 profile 默认模型。`,
1453
1571
  );
1454
1572
  }
1573
+ // ★ 回读会话自己的权限记录;不一致会在这里失败并走 catch(停进程、删登记)。
1574
+ await conv.verifyPermissionRecorded();
1455
1575
  // 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
1456
1576
  // ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
1457
1577
  // 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
@@ -1486,7 +1606,10 @@ export class Hub {
1486
1606
  if (isForgotten(c.id)) continue;
1487
1607
  out.set(c.id, c.snapshot());
1488
1608
  }
1489
- if (!includeClosed) return [...out.values()];
1609
+ if (!includeClosed) {
1610
+ this.lastProbe = { ok: null, error: null, cached: false, at: now() };
1611
+ return [...out.values()];
1612
+ }
1490
1613
 
1491
1614
  // 磁盘探测要 spawn 一个 DSH 进程(实测约 1 秒),所以同 cwd 的结果缓存一小段时间,
1492
1615
  // 避免"连着列几次会话"就反复拉进程。
@@ -1498,7 +1621,9 @@ export class Hub {
1498
1621
 
1499
1622
  const probeCwd = cwd ?? [...this.conversations.values()][0]?.cwd ?? process.cwd();
1500
1623
  let sessions = cached;
1501
- if (!sessions) {
1624
+ if (cached) {
1625
+ this.lastProbe = { ok: true, error: null, cached: true, at: this.#probeCache.at };
1626
+ } else if (!sessions) {
1502
1627
  let proc = null;
1503
1628
  try {
1504
1629
  proc = new AcpProcess({ cwd: probeCwd, permission: 'read-only' });
@@ -1507,9 +1632,12 @@ export class Hub {
1507
1632
  const res = await proc.request('session/list', cwd ? { cwd } : {});
1508
1633
  sessions = res?.sessions ?? [];
1509
1634
  this.#probeCache = { key: cacheKey, at: now(), sessions };
1635
+ this.lastProbe = { ok: true, error: null, cached: false, at: this.#probeCache.at };
1510
1636
  } catch (e) {
1511
1637
  this.log.info(`[hub] session/list 探测失败: ${e.message}`);
1512
1638
  sessions = []; // 失败不缓存,下次重试
1639
+ // ★ 调用方必须能区分"没有别的会话"和"探测失败",否则会以为会话丢了。
1640
+ this.lastProbe = { ok: false, error: e.message, cached: false, at: now() };
1513
1641
  } finally {
1514
1642
  await proc?.stop().catch(() => {});
1515
1643
  }
@@ -1619,10 +1747,17 @@ export class Hub {
1619
1747
  * 场景:默认"永不回收"下每个用过的会话会常驻一个 DSH 进程(实测约 300-480MB ✗)。
1620
1748
  * 一批活干完了就调它,内存立刻归零 ✓;下次派活按需 resume ✓。
1621
1749
  */
1622
- async releaseAll() {
1750
+ async releaseAll({ force = false } = {}) {
1623
1751
  const released = [];
1752
+ const skippedBusy = [];
1624
1753
  for (const c of this.conversations.values()) {
1625
1754
  if (!c.proc?.alive) continue;
1755
+ // ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
1756
+ // 确实要连跑着的也停掉时传 force=true。
1757
+ if (c.busy && !force) {
1758
+ skippedBusy.push(c.id);
1759
+ continue;
1760
+ }
1626
1761
  const proc = c.proc;
1627
1762
  c.proc = null;
1628
1763
  await proc.stop().catch(() => {});
@@ -1630,7 +1765,7 @@ export class Hub {
1630
1765
  released.push(c.id);
1631
1766
  }
1632
1767
  this.save();
1633
- return { released: released.length, conversations: released };
1768
+ return { released: released.length, conversations: released, skipped_busy: skippedBusy };
1634
1769
  }
1635
1770
 
1636
1771
  async shutdown() {
package/src/locks.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, renameSync } from 'node:fs';
30
+ import { spawnSync } from 'node:child_process';
30
31
  import { join, dirname } from 'node:path';
31
32
  import { randomUUID } from 'node:crypto';
32
33
  import { STATE_FILE, REAP_INTERVAL_MS, numEnv } from './config.mjs';
@@ -220,8 +221,21 @@ export function classifyHolder(conversationId, { knownChildPid = null } = {}) {
220
221
  * 杀掉一个 DSH 子进程。**只允许对 classification 判定为 `stale-mcp` / `live-mcp(force)`
221
222
  * 的、由本 MCP 登记过的进程调用** —— 调用方负责先做分类。
222
223
  */
223
- export function killHolderChild(pid) {
224
+ export function killHolderChild(pid, { expectedSpawnedAt } = {}) {
224
225
  if (!pid || !isPidAlive(pid)) return { killed: false, reason: '进程已经不在了' };
226
+ // ★ PID 会被复用。抢占是"杀进程"这种不可逆动作,所以先核对启动时间:
227
+ // 登记时间与 OS 记录的启动时间差太多 → 这已经不是当初登记的那个进程,拒绝杀。
228
+ if (Number.isFinite(expectedSpawnedAt)) {
229
+ const actual = processStartTime(pid);
230
+ if (Number.isFinite(actual) && Math.abs(actual - expectedSpawnedAt) > 10 * 60 * 1000) {
231
+ return {
232
+ killed: false,
233
+ reason:
234
+ `PID ${pid} 的启动时间与登记不符(登记 ${new Date(expectedSpawnedAt).toISOString()},` +
235
+ `实际 ${new Date(actual).toISOString()}),疑似 PID 已被复用,拒绝杀。`,
236
+ };
237
+ }
238
+ }
225
239
  try {
226
240
  process.kill(pid, 'SIGKILL');
227
241
  } catch (e) {
@@ -230,6 +244,30 @@ export function killHolderChild(pid) {
230
244
  return { killed: true, reason: `已向 PID ${pid} 发送 SIGKILL` };
231
245
  }
232
246
 
247
+ /**
248
+ * 读取进程启动时间(毫秒时间戳)。拿不到时返回 null(调用方按旧行为处理)。
249
+ * Windows 用 PowerShell 的 Get-Process.StartTime;POSIX 用 ps -o lstart=。
250
+ * 只在 takeover 这种低频、不可逆动作前调用,不在热路径上。
251
+ */
252
+ export function processStartTime(pid) {
253
+ if (!pid) return null;
254
+ try {
255
+ const args =
256
+ process.platform === 'win32'
257
+ ? ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToString('o')`]
258
+ : ['-o', 'lstart=', '-p', String(pid)];
259
+ const r = spawnSync(process.platform === 'win32' ? 'powershell' : 'ps', args, {
260
+ encoding: 'utf8',
261
+ timeout: 3000,
262
+ windowsHide: true,
263
+ });
264
+ const t = Date.parse(String(r.stdout || '').trim());
265
+ return Number.isFinite(t) ? t : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
233
271
  /**
234
272
  * 启动清理:删掉"MCP 已死 + 子进程也死了"的登记文件。
235
273
  * 注意**不删**那些子进程还活着的登记 —— 那正是后面抢占要用的线索。
package/src/mcp.mjs CHANGED
@@ -183,6 +183,12 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
183
183
  // 通知(无 id):不需要回包
184
184
  if (id === undefined) {
185
185
  if (method === 'notifications/initialized') initialized = true;
186
+ // 客户端换了打开的项目:丢掉缓存并重新问一次,下一次 dsh_start 才会用新目录。
187
+ if (method === 'notifications/roots/list_changed') {
188
+ clientRoots = null;
189
+ rootsRequested = false;
190
+ requestRoots().catch(() => {});
191
+ }
186
192
  return;
187
193
  }
188
194
 
package/src/tools.mjs CHANGED
@@ -45,7 +45,7 @@ export const TOOL_DEFS = [
45
45
  type: 'string',
46
46
  enum: PERMISSION_TIERS,
47
47
  description:
48
- `权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write 越界操作会冒泡为待决审批。` +
48
+ `权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write 由 DSH 沙箱限制可写范围,越界请求默认 auto-allow,想在调用方挂起审批请传 on_approval=ask。` +
49
49
  '**档位在创建时定下**:DSH 设计上"resume 的会话保留它自己记录的权限",所以事后改这个参数不会改变已有会话,要换档需新建。' +
50
50
  '(本档位是**真实生效**的 —— 由启动时传入的 env 决定,并有 test/permission.mjs 对着会话自己的记录逐档核验。)',
51
51
  },
@@ -188,7 +188,7 @@ export const TOOL_DEFS = [
188
188
  enum: REASONING_MODES,
189
189
  description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
190
190
  },
191
- timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。到期转后台,不取消回合。' },
191
+ timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
192
192
  },
193
193
  ['conversation_id', 'message'],
194
194
  ),
@@ -229,14 +229,19 @@ export const TOOL_DEFS = [
229
229
  '会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
230
230
  '(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
231
231
  '★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
232
- 'all=true 可立即全部释放,之后随时 resume)。',
232
+ 'all=true 会释放**空闲**会话,正在跑回合的默认跳过;确实要连跑着的也停掉请传 force=true)。' +
233
+ '对单个会话 release 会停掉其 DSH 进程;若它正在跑回合,该回合会被中断。',
233
234
  inputSchema: S(
234
235
  {
235
236
  conversation_id: CONV_ID,
236
237
  forget: { type: 'boolean', description: '同时从本服务的注册表里移除,默认 false。' },
237
238
  all: {
238
239
  type: 'boolean',
239
- description: '忽略 conversation_id,释放**所有**活着的会话进程(腾内存用)。默认 false。',
240
+ description: '忽略 conversation_id,释放所有**空闲**会话进程(腾内存用)。正在跑回合的默认跳过。默认 false。',
241
+ },
242
+ force: {
243
+ type: 'boolean',
244
+ description: '仅配合 all=true:连正在跑回合的会话也停掉(会中断那些回合)。默认 false。',
240
245
  },
241
246
  },
242
247
  [],
@@ -454,9 +459,13 @@ export function createHandlers(hub, opts = {}) {
454
459
  if (c.turns != null) bits.push(`轮数=${c.turns}`);
455
460
  return `- ${bits.join(' | ')}`;
456
461
  };
462
+ const probe = hub.lastProbe ?? { ok: null, error: null, cached: false };
463
+ const probeNote = probe.ok === false
464
+ ? `\n\n⚠️ 磁盘会话探测失败:${probe.error}\n 上面只列出本服务已打开的会话;磁盘上未打开的会话可能没显示。`
465
+ : '';
457
466
  const text = items.length
458
467
  ? `${items.length} 个会话(其中 ${running} 个正在跑):\n${items.map(describe).join('\n')}`
459
- : '(没有会话)';
468
+ : probe.ok === false ? '(本服务没有已打开的会话;磁盘探测失败)' : '(没有会话)';
460
469
  // 孤儿锁持有者:上一代 MCP 崩了但它的 DSH 子进程还活着握着写锁 —— 不主动报出来,
461
470
  // 用户只会在下一次撞上"被占用"时才发现。这里顺手提示,并给出可执行的动作。
462
471
  const orphans = listOrphans();
@@ -466,8 +475,16 @@ export function createHandlers(hub, opts = {}) {
466
475
  '\n→ 用 dsh_takeover 抢占即可(这些是本服务自己的残留,抢占是安全的)。'
467
476
  : '';
468
477
  return {
469
- structured: { count: items.length, running, conversations: items, orphan_holders: orphans },
470
- text: text + orphanNote,
478
+ structured: {
479
+ count: items.length,
480
+ running,
481
+ conversations: items,
482
+ orphan_holders: orphans,
483
+ probe_ok: probe.ok,
484
+ probe_error: probe.error ?? null,
485
+ probe_cached: probe.cached === true,
486
+ },
487
+ text: text + probeNote + orphanNote,
471
488
  };
472
489
  },
473
490
 
@@ -503,7 +520,12 @@ export function createHandlers(hub, opts = {}) {
503
520
  let run = null;
504
521
  const meta = [];
505
522
  // DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
506
- meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(DSH 自动生成)` +
523
+ const titleKind = snap.title_source === 'native'
524
+ ? 'DSH 自动生成'
525
+ : snap.title_source === 'title_hint'
526
+ ? '来自 title_hint(DSH 尚未生成标题)'
527
+ : '尚未生成';
528
+ meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(${titleKind})` +
507
529
  (snap.title_hint && snap.title !== snap.title_hint ? ` / 标题提示: ${snap.title_hint}` : ''));
508
530
  meta.push(`工作区: ${snap.cwd}`);
509
531
  meta.push(`状态: ${snap.busy ? '忙' : snap.alive ? '空闲(进程活着)' : '未打开(可用 resume 复活)'}`);
@@ -587,12 +609,15 @@ export function createHandlers(hub, opts = {}) {
587
609
  // ★ all=true:一次性放掉所有活着的会话进程(腾内存)。
588
610
  // 无损:会话日志都在磁盘上,之后照样 resume。
589
611
  if (args.all === true) {
590
- const r = await hub.releaseAll();
612
+ const r = await hub.releaseAll({ force: args.force === true });
591
613
  return {
592
- structured: { released: r.released, conversations: r.conversations },
614
+ structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
593
615
  text:
594
- `已释放 ${r.released} 个会话进程(写锁全部让出,内存立刻回收)。` +
595
- `\n会话日志都在磁盘上,之后派活会自动 resume;其中一个都没在跑时 released=0。`,
616
+ `已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
617
+ (r.skipped_busy.length
618
+ ? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
619
+ : '') +
620
+ `\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
596
621
  };
597
622
  }
598
623
  const conv = hub.get(args.conversation_id);
@@ -633,11 +658,14 @@ export function createHandlers(hub, opts = {}) {
633
658
  if (snap.lock_holder && snap.lock_holder !== 'self') {
634
659
  const hint =
635
660
  snap.lock_holder === 'stale-mcp'
636
- ? '(本服务残留的孤儿,可 dsh_takeover 抢占)'
661
+ ? '(本服务残留/卡死的持有者,可 dsh_takeover 抢占)'
637
662
  : snap.lock_holder === 'live-mcp'
638
663
  ? '(另一个活着的 dsh-any-agent 实例在用;要夺需 dsh_takeover(force=true))'
639
- : '(多半是你自己的 DSH GUI 正开着它;本服务不会去杀它)';
664
+ : snap.lock_conflict
665
+ ? '(最近撞过锁:多半是你自己的 DSH GUI 正开着它;本服务不会去杀它)'
666
+ : '(没有本服务的持锁登记;空闲/已释放时也会是 none,只有在撞锁时才可能是 GUI)';
640
667
  bits.push(`写锁=${snap.lock_holder}${hint}`);
668
+ if (snap.lock_holder_reason) bits.push(` 判定:${snap.lock_holder_reason}`);
641
669
  }
642
670
  // ★ 撞过锁就把"谁占用 + 怎么解"讲清楚。实测:GUI 点开一次就会长期持有写锁
643
671
  // (切走、等十几分钟都不释放),所以这条信息必须持久可见,而不是一闪而过的报错。
@@ -661,6 +689,7 @@ export function createHandlers(hub, opts = {}) {
661
689
  pending_approval_details: snap.pending_approval_details,
662
690
  turns: snap.turns,
663
691
  lock_holder: snap.lock_holder,
692
+ lock_holder_reason: snap.lock_holder_reason,
664
693
  lock_conflict: snap.lock_conflict,
665
694
  run: run
666
695
  ? {