dsh-any-agent 0.3.2 → 0.3.3

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
@@ -41,6 +41,7 @@ import {
41
41
  SENTINEL_INCLUDE_REASONING,
42
42
  SENTINEL_TTL_MS,
43
43
  STATE_FILE,
44
+ DSH_HOME,
44
45
  defaultApprovalPolicy,
45
46
  } from './config.mjs';
46
47
  import { AcpProcess } from './acp.mjs';
@@ -60,6 +61,92 @@ import {
60
61
  const now = () => Date.now();
61
62
  const NOOP_LOG = { info: () => {}, debug: () => {}, dshStderr: () => {} };
62
63
 
64
+ /**
65
+ * 会话元数据侧车(`.state/meta/<id>.json`)。
66
+ *
67
+ * 为什么需要:`forget=true` 只从注册表移除;之后按 id 查询时,`Hub.get()` 会从
68
+ * DSH 会话存储重建会话(这是多实例恢复的刻意设计)。但 DSH 的会话记录里**没有**
69
+ * 本服务选择的 `onApproval` / `reasoning_effort` / `title_hint` / 轮数,
70
+ * 重建就会悄悄回落到默认值(实测 auto-allow→ask、low→max)。这个侧车保存
71
+ * **仅元数据**(不含任何对话/思考正文),让重建后仍与原会话一致。
72
+ */
73
+ const META_DIR = join(dirname(STATE_FILE), 'meta');
74
+ const metaFilePath = (id) => join(META_DIR, `${id}.json`);
75
+
76
+ function writeConversationMeta(conv) {
77
+ try {
78
+ mkdirSync(META_DIR, { recursive: true });
79
+ const file = metaFilePath(conv.id);
80
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
81
+ writeFileSync(tmp, JSON.stringify(conv.toMeta(), null, 2), 'utf8');
82
+ renameSync(tmp, file);
83
+ } catch {
84
+ /* 元数据侧车只是辅助信息,写失败不影响主流程 */
85
+ }
86
+ }
87
+
88
+ function readConversationMetaFile(id) {
89
+ try {
90
+ return JSON.parse(readFileSync(metaFilePath(id), 'utf8'));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * forget 墓碑:`release(forget=true)` 之后,按 id 查询必须**明确失败**,
98
+ * 而不是从 DSH 会话存储里悄悄复活。墓碑只含 id 与时间,不含任何内容;
99
+ * 删除 `.state/forgotten/<id>.json` 即可恢复按 id 找回(手动、可审计)。
100
+ */
101
+ // 墓碑放在 DSH home 下而不是注册表旁边:不同实例即使注册表路径不同,
102
+ // 也应该对同一个会话的 forget 达成一致(forget 是会话级终态,不是注册表级)。
103
+ const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
104
+ const forgottenPath = (id) => join(FORGOTTEN_DIR, `${id}.json`);
105
+
106
+ function writeTombstone(id) {
107
+ try {
108
+ mkdirSync(FORGOTTEN_DIR, { recursive: true });
109
+ const file = forgottenPath(id);
110
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
111
+ writeFileSync(tmp, JSON.stringify({ id, forgottenAt: Date.now() }, null, 2), 'utf8');
112
+ renameSync(tmp, file);
113
+ } catch {
114
+ /* 墓碑写失败时 forget 仍从注册表移除;下次 get 可能仍会复活,但不影响主流程 */
115
+ }
116
+ }
117
+
118
+ function isForgotten(id) {
119
+ try {
120
+ return existsSync(forgottenPath(id));
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 启动时清掉“会话文件已经不存在”的墓碑:它们不再阻止任何 resurrection,
128
+ * 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
129
+ * 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
130
+ */
131
+ function pruneStaleTombstones() {
132
+ let removed = 0;
133
+ try {
134
+ if (!existsSync(FORGOTTEN_DIR)) return 0;
135
+ for (const f of readdirSync(FORGOTTEN_DIR)) {
136
+ if (!f.endsWith('.json')) continue;
137
+ const id = f.slice(0, -5);
138
+ if (findSessionHeader(id)) continue;
139
+ try {
140
+ rmSync(join(FORGOTTEN_DIR, f), { force: true });
141
+ removed++;
142
+ } catch {}
143
+ }
144
+ } catch {
145
+ /* 清理失败不影响启动 */
146
+ }
147
+ return removed;
148
+ }
149
+
63
150
  /** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
64
151
  const MAX_OUT_ENTRIES = 500;
65
152
  /** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
@@ -149,11 +236,21 @@ async function applySessionConfig(proc, sessionId, cfg) {
149
236
  * 默认剥离以维持"思考不落盘"的保证,并留下 thinking_omitted 标记,
150
237
  * 免得调用方以为字段是意外丢失的。
151
238
  */
152
- function sanitizeResultForSentinel(result) {
239
+ export function sanitizeResultForSentinel(result) {
153
240
  if (!result || typeof result !== 'object') return result ?? null;
154
241
  if (SENTINEL_INCLUDE_REASONING) return result;
155
242
  const { thinking, ...rest } = result;
156
- return thinking ? { ...rest, thinking_omitted: true } : rest;
243
+ // 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
244
+ // 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
245
+ if (Array.isArray(rest.tools_used)) {
246
+ rest.tools_used = rest.tools_used.map((t) => {
247
+ if (!t || typeof t !== 'object') return t;
248
+ const { input, ...meta } = t;
249
+ return input === undefined ? meta : { ...meta, input: sanitizeApprovalContext(input) };
250
+ });
251
+ }
252
+ // 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
253
+ return { ...rest, thinking_omitted: Boolean(thinking) };
157
254
  }
158
255
 
159
256
  export class Conversation {
@@ -194,6 +291,8 @@ export class Conversation {
194
291
  */
195
292
  this.out = [];
196
293
  this.outSeq = 0;
294
+ /** 输出缓冲的**修订号**:每次追加(含合并进已有条目)都 +1,供游标式增量读使用。 */
295
+ this.outRev = 0;
197
296
  this.turnOutChars = 0;
198
297
  this.currentTurn = 0;
199
298
  this.turnStartedAt = 0;
@@ -229,6 +328,11 @@ export class Conversation {
229
328
  };
230
329
  }
231
330
 
331
+ /** 侧车元数据:与注册表同字段,但独立落盘,forget 之后仍可恢复。 */
332
+ toMeta() {
333
+ return { version: 1, ...this.toState() };
334
+ }
335
+
232
336
  /** 合规的公开快照(含磁盘上的元信息)。 */
233
337
  snapshot() {
234
338
  const meta = readConversationMeta(this.id);
@@ -495,11 +599,33 @@ export class Conversation {
495
599
 
496
600
  async onPermissionRequest(params) {
497
601
  const options = params?.options ?? [];
498
- const toolCall = params?.toolCall ?? {};
602
+ const rawToolCall = params?.toolCall ?? {};
603
+ // 真实 DSH 的 request_permission 只带 toolCallId,不带 rawInput;
604
+ // 同一 toolCallId 的参数通常已经在 tool_call 更新里记录过,按 id 关联回来,
605
+ // 否则审批详情里没有命令/路径,调用方无法裁决。
606
+ const known = this.collector?.tools?.get(rawToolCall.toolCallId) ?? null;
607
+ const toolCall = known
608
+ ? {
609
+ ...rawToolCall,
610
+ name: rawToolCall.name ?? known.name,
611
+ title: rawToolCall.title ?? known.title,
612
+ kind: rawToolCall.kind ?? known.kind,
613
+ rawInput: rawToolCall.rawInput ?? rawToolCall.input ?? known.input,
614
+ locations: rawToolCall.locations ?? known.locations,
615
+ }
616
+ : rawToolCall;
499
617
  const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
500
618
  const allowId = pickOption(options, 'allow');
501
619
  const rejectId = pickOption(options, 'reject');
502
620
 
621
+ // ★ read-only 是硬边界:即使会话被旧版本/显式参数配成 auto-allow,
622
+ // 这里也绝不放行任何审批 —— DSH 会借审批把 sandbox 提权到可写。
623
+ if (this.permission === 'read-only') {
624
+ this.#hub.log.info(`[conv ${this.id}] read-only 强制拒绝审批: ${desc}`);
625
+ return rejectId
626
+ ? { outcome: { outcome: 'selected', optionId: rejectId } }
627
+ : { outcome: { outcome: 'cancelled' } };
628
+ }
503
629
  if (this.onApproval === 'auto-allow') {
504
630
  this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
505
631
  return allowId
@@ -632,6 +758,14 @@ export class Conversation {
632
758
  this.busy = false;
633
759
  this.collector = null;
634
760
  this.currentTool = null;
761
+ // 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
762
+ // 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
763
+ for (const [, p] of this.pendingApprovals) {
764
+ try {
765
+ p.resolve({ outcome: { outcome: 'cancelled' } });
766
+ } catch {}
767
+ }
768
+ this.pendingApprovals.clear();
635
769
  this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
636
770
  phase: 'end',
637
771
  stop_reason: stopReason,
@@ -764,17 +898,24 @@ export class Conversation {
764
898
  if (last && last.kind === kind && last.turn === this.currentTurn && text) {
765
899
  last.text += text;
766
900
  last.at = now();
901
+ // ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
902
+ // 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
903
+ last.rev = ++this.outRev;
767
904
  } else {
768
- this.out.push({ seq: ++this.outSeq, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
905
+ this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
769
906
  if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
770
907
  }
771
908
  if (kind === 'text') this.turnOutChars += text?.length ?? 0;
772
909
  }
773
910
 
774
- /** 读输出:游标式增量。返回 seq > cursor 的条目 + 新游标。 */
911
+ /** 读输出:**按修订号**的游标式增量。条目被追加时会再次返回(按 seq 替换全文)。 */
775
912
  readOut({ cursor = 0, includeReasoning = false, limit = 200 } = {}) {
776
- const entries = this.out.filter((e) => e.seq > cursor).slice(0, limit);
777
- const next = entries.length ? entries[entries.length - 1].seq : cursor;
913
+ const cur = Number.isFinite(cursor) ? cursor : 0;
914
+ // limit 必须清洗:负数会被 slice 解释成“从头到尾少一条”,0 会让调用方原地空转。
915
+ const n = Number.isFinite(limit) ? Math.max(1, Math.min(Math.floor(limit), MAX_OUT_ENTRIES)) : 200;
916
+ const revOf = (e) => (typeof e.rev === 'number' ? e.rev : e.seq);
917
+ const entries = this.out.filter((e) => revOf(e) > cur).slice(0, n);
918
+ const next = entries.length ? revOf(entries[entries.length - 1]) : cur;
778
919
  const res = {
779
920
  conversation_id: this.id,
780
921
  state: this.state(),
@@ -782,9 +923,9 @@ export class Conversation {
782
923
  current_tool: this.currentTool,
783
924
  entries,
784
925
  cursor: next,
785
- has_more: this.out.some((e) => e.seq > next),
926
+ has_more: this.out.some((e) => revOf(e) > next),
786
927
  /** 缓冲被环形覆盖时会为 true,表示你错过了中间一段。 */
787
- gap: entries.length > 0 && this.out.length > 0 && this.out[0].seq > cursor + 1,
928
+ gap: entries.length > 0 && this.out.length > 0 && revOf(this.out[0]) > cur + 1,
788
929
  };
789
930
  // 思考内容只在显式要求时、且只针对"当前正在跑的回合"返回;不保留、不进缓冲
790
931
  if (includeReasoning && this.collector?.reasoning) {
@@ -824,24 +965,27 @@ export class Conversation {
824
965
  return this.runs.length ? this.runs[this.runs.length - 1] : null;
825
966
  }
826
967
 
827
- /** 等到当前回合结束(不忙则立即返回 true;超时返回 false)。 */
968
+ /** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
828
969
  async waitIdle(timeoutMs = 30 * 60 * 1000) {
829
970
  if (!this.busy) return true;
830
971
  return await new Promise((resolve) => {
831
972
  let settled = false;
973
+ let timer = null;
832
974
  const done = () => {
833
975
  if (settled) return;
834
976
  settled = true;
835
- clearTimeout(timer);
977
+ if (timer) clearTimeout(timer);
836
978
  resolve(true);
837
979
  };
838
- const timer = setTimeout(() => {
839
- if (settled) return;
840
- settled = true;
841
- const i = this.idleWaiters.indexOf(done);
842
- if (i >= 0) this.idleWaiters.splice(i, 1);
843
- resolve(false);
844
- }, timeoutMs);
980
+ if (timeoutMs > 0) {
981
+ timer = setTimeout(() => {
982
+ if (settled) return;
983
+ settled = true;
984
+ const i = this.idleWaiters.indexOf(done);
985
+ if (i >= 0) this.idleWaiters.splice(i, 1);
986
+ resolve(false);
987
+ }, timeoutMs);
988
+ }
845
989
  this.idleWaiters.push(done);
846
990
  });
847
991
  }
@@ -868,7 +1012,8 @@ export class Conversation {
868
1012
  if (mode === 'interject') {
869
1013
  this.proc?.notify('session/cancel', { sessionId: this.id });
870
1014
  }
871
- const idle = await this.waitIdle();
1015
+ // 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
1016
+ const idle = await this.waitIdle(timeoutMs);
872
1017
  const waited = now() - t0;
873
1018
  if (!idle) throw new Error('等待当前回合结束超时,插话未发送');
874
1019
  const r = await this.prompt(message, { reasoning, timeoutMs });
@@ -885,10 +1030,21 @@ export class Conversation {
885
1030
  } catch {}
886
1031
  await proc.stop();
887
1032
  }
1033
+ // 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
1034
+ for (const [, p] of this.pendingApprovals) {
1035
+ try {
1036
+ p.resolve({ outcome: { outcome: 'cancelled' } });
1037
+ } catch {}
1038
+ }
1039
+ this.pendingApprovals.clear();
888
1040
  // 进程没了 → 写锁也释放了,登记必须跟着删(否则会把"孤儿持有者"误报给抢占逻辑)
889
1041
  removeMarker(this.id);
890
- if (forget) this.#hub.remove(this.id);
891
- else this.#hub.save();
1042
+ if (forget) {
1043
+ // 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
1044
+ // 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
1045
+ writeTombstone(this.id);
1046
+ this.#hub.remove(this.id);
1047
+ } else this.#hub.save();
892
1048
  return { closed: true, forgotten: forget };
893
1049
  }
894
1050
  }
@@ -973,11 +1129,14 @@ export class Hub {
973
1129
  #probeCache = null;
974
1130
 
975
1131
  load() {
1132
+ const stale = pruneStaleTombstones();
1133
+ if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
976
1134
  if (!existsSync(STATE_FILE)) return;
977
1135
  try {
978
1136
  const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
979
1137
  for (const s of doc.conversations ?? []) {
980
1138
  if (!s?.id || !s?.cwd) continue;
1139
+ if (isForgotten(s.id)) continue;
981
1140
  this.conversations.set(s.id, new Conversation(s, this));
982
1141
  }
983
1142
  this.log.info(`[hub] 载入 ${this.conversations.size} 个会话`);
@@ -1000,6 +1159,9 @@ export class Hub {
1000
1159
  const tmp = `${STATE_FILE}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
1001
1160
  writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
1002
1161
  renameSync(tmp, STATE_FILE);
1162
+ // 侧车元数据独立于注册表:forget 移除注册表条目后仍然保留,
1163
+ // 让按 id 重建的会话不会丢掉审批策略/思考深度/标题/轮数。
1164
+ for (const c of this.conversations.values()) writeConversationMeta(c);
1003
1165
  } catch (e) {
1004
1166
  this.log.info(`[hub] 状态保存失败: ${e.message}`);
1005
1167
  }
@@ -1097,6 +1259,14 @@ export class Hub {
1097
1259
  }
1098
1260
 
1099
1261
  get(id) {
1262
+ // ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
1263
+ // 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
1264
+ if (isForgotten(id)) {
1265
+ throw new Error(
1266
+ `会话已被 forget(永久移除): ${id}\n` +
1267
+ ` 它不会再被按 id 自动恢复。确实要恢复的话,删除墓碑文件 ${forgottenPath(id)} 后重试。`,
1268
+ );
1269
+ }
1100
1270
  let c = this.conversations.get(id);
1101
1271
  if (!c) {
1102
1272
  // 多实例常见情形:这个会话是**另一个 dsh-any-agent 实例**刚建的,我们内存里的注册表还没有。
@@ -1113,18 +1283,39 @@ export class Hub {
1113
1283
  // 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
1114
1284
  // resume 时 DSH_PERMISSION_MODE 会把档位写进会话 —— 等于**悄悄提权**。
1115
1285
  const meta = readConversationMeta(id);
1286
+ const persisted = readConversationMetaFile(id);
1116
1287
  const recordedPreset = meta?.permissions?.preset;
1117
- const permission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : DEFAULT_PERMISSION;
1288
+ const recordedPermission = PERMISSION_TIERS.includes(recordedPreset) ? recordedPreset : null;
1289
+ // 安全优先:会话记录说 read-only 就绝不能被侧车“提升”为可写档。
1290
+ const permission =
1291
+ recordedPermission === 'read-only'
1292
+ ? 'read-only'
1293
+ : PERMISSION_TIERS.includes(persisted?.permission)
1294
+ ? persisted.permission
1295
+ : (recordedPermission ?? DEFAULT_PERMISSION);
1118
1296
  const recordedApproval = meta?.permissions?.approval;
1119
- const onApproval = APPROVAL_POLICIES.includes(recordedApproval)
1120
- ? recordedApproval
1121
- : defaultApprovalPolicy(permission);
1297
+ let onApproval = APPROVAL_POLICIES.includes(persisted?.onApproval)
1298
+ ? persisted.onApproval
1299
+ : APPROVAL_POLICIES.includes(recordedApproval)
1300
+ ? recordedApproval
1301
+ : defaultApprovalPolicy(permission);
1302
+ if (permission === 'read-only' && onApproval !== 'auto-deny') onApproval = 'auto-deny';
1122
1303
  c = new Conversation(
1123
1304
  {
1124
1305
  id,
1125
- cwd: header.cwd,
1306
+ cwd: persisted?.cwd ?? header.cwd,
1126
1307
  permission,
1127
1308
  onApproval,
1309
+ titleHint: persisted?.titleHint ?? null,
1310
+ reasoningEffort: REASONING_EFFORTS.includes(persisted?.reasoningEffort)
1311
+ ? persisted.reasoningEffort
1312
+ : DEFAULT_REASONING_EFFORT,
1313
+ provider: persisted?.provider ?? null,
1314
+ model: persisted?.model ?? null,
1315
+ createdAt: persisted?.createdAt,
1316
+ lastUsedAt: persisted?.lastUsedAt,
1317
+ turnCount: persisted?.turnCount,
1318
+ workspace: persisted?.workspace ?? null,
1128
1319
  },
1129
1320
  this,
1130
1321
  );
@@ -1153,6 +1344,7 @@ export class Hub {
1153
1344
  const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
1154
1345
  for (const s of doc.conversations ?? []) {
1155
1346
  if (!s?.id || !s?.cwd) continue;
1347
+ if (isForgotten(s.id)) continue;
1156
1348
  if (this.conversations.has(s.id)) continue;
1157
1349
  this.conversations.set(s.id, new Conversation(s, this));
1158
1350
  added++;
@@ -1166,6 +1358,8 @@ export class Hub {
1166
1358
 
1167
1359
  remove(id) {
1168
1360
  this.conversations.delete(id);
1361
+ // 注意:**不删侧车元数据**。forget 的语义是“从注册表移除”,
1362
+ // 之后按 id 重建(多实例恢复)时,侧车让审批策略/思考深度/标题/轮数不漂移。
1169
1363
  this.save();
1170
1364
  }
1171
1365
 
@@ -1179,8 +1373,11 @@ export class Hub {
1179
1373
  );
1180
1374
  }
1181
1375
  if (!isAbsolute(cwd)) throw new Error(`cwd 必须是绝对路径: ${cwd}`);
1182
- if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
1183
- throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建它)`);
1376
+ if (!existsSync(cwd)) {
1377
+ throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建这个目录)`);
1378
+ }
1379
+ if (!statSync(cwd).isDirectory()) {
1380
+ throw new Error(`工作区不存在或不是目录: ${cwd}(这是一个文件路径,请传目录)`);
1184
1381
  }
1185
1382
  if (!PERMISSION_TIERS.includes(permission)) {
1186
1383
  throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
@@ -1189,6 +1386,14 @@ export class Hub {
1189
1386
  if (!APPROVAL_POLICIES.includes(policy)) {
1190
1387
  throw new Error(`on_approval 必须是 ${APPROVAL_POLICIES.join(' | ')} 之一`);
1191
1388
  }
1389
+ // ★ read-only 是硬边界:显式 auto-allow/ask 会放行 DSH 的 sandbox 提权,
1390
+ // 实测能把文件写进工作区而会话记录仍显示 read-only。直接拒绝这个组合。
1391
+ if (permission === 'read-only' && policy !== 'auto-deny') {
1392
+ throw new Error(
1393
+ 'permission=read-only 强制要求 on_approval=auto-deny(或不传)。' +
1394
+ ' auto-allow/ask 会放行 DSH 的 sandbox 提权,使只读档失效。',
1395
+ );
1396
+ }
1192
1397
  const effort = reasoningEffort ?? DEFAULT_REASONING_EFFORT;
1193
1398
  if (!REASONING_EFFORTS.includes(effort)) {
1194
1399
  throw new Error(`reasoning_effort 必须是 ${REASONING_EFFORTS.join(' | ')} 之一`);
@@ -1208,9 +1413,11 @@ export class Hub {
1208
1413
  onPermissionRequest: () => ({ outcome: { outcome: 'cancelled' } }),
1209
1414
  });
1210
1415
  proc.start();
1416
+ let createdSessionId = null;
1211
1417
  try {
1212
1418
  await proc.initialize();
1213
1419
  const res = await proc.request('session/new', { cwd, mcpServers: [] });
1420
+ createdSessionId = res.sessionId;
1214
1421
  const conv = new Conversation(
1215
1422
  {
1216
1423
  id: res.sessionId,
@@ -1235,6 +1442,16 @@ export class Hub {
1235
1442
  });
1236
1443
  // 套用会话级配置(思考深度默认 max;ACP 自己的默认是 Provider default)
1237
1444
  conv.lastConfigApplied = await applySessionConfig(proc, res.sessionId, conv.cfg);
1445
+ // ★ 无效的 provider/model 必须 fail fast:旧行为是建出会话、静默回退
1446
+ // profile 默认模型,调用方以为指定模型已生效。这里在外层 catch 停止进程,
1447
+ // 不注册会话,直接报错(不传 provider/model 才走 profile 默认)。
1448
+ const modelFailure = (conv.lastConfigApplied.failed ?? []).find((f) => String(f).includes('模型='));
1449
+ if (modelFailure) {
1450
+ throw new Error(
1451
+ `指定的 provider/model 无效,会话未创建:${modelFailure}\n` +
1452
+ ` 请检查 ${provider}/${model} 是否在 profile 中声明;不传这两个参数可使用 profile 默认模型。`,
1453
+ );
1454
+ }
1238
1455
  // 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
1239
1456
  // ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
1240
1457
  // 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
@@ -1252,6 +1469,10 @@ export class Hub {
1252
1469
  );
1253
1470
  return conv;
1254
1471
  } catch (e) {
1472
+ // 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
1473
+ // 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
1474
+ // 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
1475
+ if (createdSessionId) removeMarker(createdSessionId);
1255
1476
  await proc.stop().catch(() => {});
1256
1477
  throw e;
1257
1478
  }
@@ -1262,6 +1483,7 @@ export class Hub {
1262
1483
  const out = new Map();
1263
1484
  for (const c of this.conversations.values()) {
1264
1485
  if (cwd && c.cwd !== cwd) continue;
1486
+ if (isForgotten(c.id)) continue;
1265
1487
  out.set(c.id, c.snapshot());
1266
1488
  }
1267
1489
  if (!includeClosed) return [...out.values()];
@@ -1294,6 +1516,7 @@ export class Hub {
1294
1516
  }
1295
1517
  for (const s of sessions) {
1296
1518
  if (out.has(s.sessionId)) continue;
1519
+ if (isForgotten(s.sessionId)) continue;
1297
1520
  const meta = readConversationMeta(s.sessionId);
1298
1521
  out.set(s.sessionId, {
1299
1522
  conversation_id: s.sessionId,
package/src/mcp.mjs CHANGED
@@ -48,6 +48,7 @@ DSH 自己有文件读写、shell、搜索、技能、子代理等全套工具
48
48
  dsh_read(conversation_id, cursor) 中途看它当前吐出的内容(增量)
49
49
  dsh_get(conversation_id, run_id) 验收:取那一次派活的完整结果
50
50
  4. dsh_interject / dsh_interrupt 跑偏了就插话改口 / 直接打断
51
+ (会话空闲时 interject 等价于 wait=true,会阻塞到回合结束)
51
52
  5. dsh_release 交还会话(释放写锁,之后仍可 resume)
52
53
  6. dsh_takeover 写锁被别人占着时的**抢占**(见下)
53
54
 
@@ -86,9 +87,12 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
86
87
  - 会话在忙时**不要**再 dsh_send:想改口用 dsh_interject,想停用 dsh_interrupt,
87
88
  想把话留到本轮结束后说用 dsh_interject(mode='queue')。
88
89
  - 思考内容**默认隐藏**(只返回字数/耗时统计)。确实需要时传 reasoning='full',但很占上下文。
89
- - 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}(read-only 一律 auto-deny)。分析不信任样本(恶意软件、来路不明 dump)时用
90
+ - 权限档默认 ${DEFAULT_PERMISSION},审批默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。**read-only 强制 auto-deny**:传 auto-allow/ask 会被直接拒绝(否则 DSH 的 sandbox 提权会被放行)。分析不信任样本(恶意软件、来路不明 dump)时用
90
91
  read-only:沙箱只读 + 审批一律拒绝。
91
92
  - 思考深度默认 max;想省钱可在 dsh_start 传 reasoning_effort 调低。
93
+ - 隐私边界:本服务自己的日志、哨兵、注册表都不写思考正文(哨兵只留思考统计);但 **DSH 自己的会话日志
94
+ ~/.dsh/sessions/** 会保存 reasoning,reasoning=hide 也照存** —— 分析敏感数据时要把它算进去。
95
+ - 异步哨兵文件保存最终正文与工具输入(常见密钥字面量已脱敏),默认保留 7 天;消费后建议自行删除。
92
96
  - 想并行:开多个会话,dsh_list(only_running=true) 看谁在跑,再轮流 dsh_read。
93
97
 
94
98
  【不要做】
package/src/tools.mjs CHANGED
@@ -39,7 +39,7 @@ export const TOOL_DEFS = [
39
39
  description:
40
40
  '工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' +
41
41
  '**省略时自动用客户端当前打开的项目目录**(MCP roots),这样客户端里看到哪个项目就在这里干活;' +
42
- '客户端不支持 roots 时才会要求你显式传入。别传用户家目录本身 —— 那里会让 DSH 的 ACL 沙箱起不来。',
42
+ '客户端不支持 roots、或 roots 为空时才会要求你显式传入。别传用户家目录本身 —— 那里会让 DSH 的 ACL 沙箱起不来。',
43
43
  },
44
44
  permission: {
45
45
  type: 'string',
@@ -52,7 +52,9 @@ export const TOOL_DEFS = [
52
52
  on_approval: {
53
53
  type: 'string',
54
54
  enum: APPROVAL_POLICIES,
55
- description: `审批处理策略。默认 read-only → auto-deny,其余 → ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
55
+ description:
56
+ `审批处理策略。**read-only 强制 auto-deny**(传 auto-allow/ask 会被直接拒绝,否则会放行 DSH 的 sandbox 提权);` +
57
+ `其余档位默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
56
58
  },
57
59
  title_hint: {
58
60
  type: 'string',
@@ -103,7 +105,7 @@ export const TOOL_DEFS = [
103
105
  timeout_ms: {
104
106
  type: 'number',
105
107
  description:
106
- '仅 wait=true 时的**等待**上限(毫秒),默认 30 分钟;0 = 一直等。到期不会取消回合,只会转成后台并给你 run_id。',
108
+ '仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
107
109
  },
108
110
  },
109
111
  ['conversation_id', 'prompt'],
@@ -137,7 +139,8 @@ export const TOOL_DEFS = [
137
139
  '不必等回合结束。典型用法:dsh_list(only_running) 找到在跑的会话 → 反复 dsh_read 并回传上次的 cursor,' +
138
140
  '就能像切换窗口一样轮流观察多个会话。' +
139
141
  '输出缓冲**只在内存里且有界**(环形,满则丢最旧),服务/进程退出即消失;' +
140
- '**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。',
142
+ '**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。' +
143
+ 'cursor 是**修订号**:同一条目被追加文本后会再次返回,调用方按 `seq` 用它替换全文(不要重复累加)。',
141
144
  inputSchema: S(
142
145
  {
143
146
  conversation_id: CONV_ID,
@@ -153,7 +156,8 @@ export const TOOL_DEFS = [
153
156
  description:
154
157
  '查看单个会话的详情;带 run_id 时**取那一次派活的最终结果**(异步模式的验收入口)。' +
155
158
  '不传 run_id 则取最近一次回合。返回:标题、工作区、权限档、状态、轮数、token 用量、' +
156
- '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。',
159
+ '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。' +
160
+ '**run 记录只在内存、最多保留最近 20 条**;服务重启或记录被挤掉后请改读哨兵文件(它落盘且扛重启)。',
157
161
  inputSchema: S(
158
162
  {
159
163
  conversation_id: CONV_ID,
@@ -167,7 +171,7 @@ export const TOOL_DEFS = [
167
171
  description:
168
172
  '插话:会话正在跑某个回合时往里补一句话。两种模式——' +
169
173
  'interject(默认)打断当前回合并立刻改口;queue 不打断,等本轮自然结束后紧接着说。' +
170
- '会话空闲时等同于一次普通 dsh_send。' +
174
+ '会话空闲时按 **wait=true 的普通回合**执行(会阻塞到回合结束并返回完整结果,不是收据)。' +
171
175
  '说明:ACP 明确拒绝并发 prompt,所以做不到"让模型在同一个回合里即时改向";' +
172
176
  'interject 是它的实用等价物(取消 + 立刻开新回合,历史完整保留)。',
173
177
  inputSchema: S(
@@ -184,7 +188,7 @@ export const TOOL_DEFS = [
184
188
  enum: REASONING_MODES,
185
189
  description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
186
190
  },
187
- timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),默认 30 分钟;0 = 一直等。到期转后台,不取消回合。' },
191
+ timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。到期转后台,不取消回合。' },
188
192
  },
189
193
  ['conversation_id', 'message'],
190
194
  ),
@@ -222,8 +226,10 @@ export const TOOL_DEFS = [
222
226
  name: 'dsh_release',
223
227
  description:
224
228
  '释放会话占用的 DSH 进程(释放写锁,让用户能在自己的 DSH GUI 里打开同一个会话)。' +
225
- '会话本体留在磁盘上,之后仍可 resume;forget=true 才会从注册表里摘掉。' +
226
- '★ 用完了想一次性腾内存就传 all=true(默认"永不回收"下每个会话约 300-480MB,全放掉即立刻归零,之后随时 resume)。',
229
+ '会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
230
+ '(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
231
+ '★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
232
+ 'all=true 可立即全部释放,之后随时 resume)。',
227
233
  inputSchema: S(
228
234
  {
229
235
  conversation_id: CONV_ID,
@@ -374,11 +380,24 @@ export function createHandlers(hub, opts = {}) {
374
380
  (snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
375
381
  `工作区登记: ${snap.workspace_registered ? '已写入 DSH 工作区注册表(GUI 里能看到)' : '未登记'}`,
376
382
  ];
377
- if (snap.config_failed?.length) lines.push(`⚠️ 配置未完全套用: ${snap.config_failed.join('; ')}`);
383
+ if (snap.config_failed?.length) {
384
+ lines.push(`⚠️ 配置未完全套用: ${snap.config_failed.join('; ')}`);
385
+ if (snap.config_failed.some((f) => String(f).startsWith('模型='))) {
386
+ lines.push(
387
+ '⚠️ 指定的 provider/model 无效:本会话已回退到 profile 默认模型,' +
388
+ '后续回合**不会**使用你指定的模型。',
389
+ );
390
+ }
391
+ }
378
392
  return { structured: snap, text: lines.join('\n') };
379
393
  },
380
394
 
381
395
  async dsh_send(args) {
396
+ // 空/纯空白 prompt 会被 DSH 以 [-32602] 拒绝;默认 wait=false 下那会变成
397
+ // 一个“accepted 但注定失败”的收据(错误只异步出现在哨兵里)。这里同步拦下。
398
+ if (typeof args.prompt !== 'string' || args.prompt.trim() === '') {
399
+ throw new Error('prompt 不能为空:至少包含一个非空白字符');
400
+ }
382
401
  const conv = hub.get(args.conversation_id);
383
402
  const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
384
403
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
@@ -531,6 +550,9 @@ export function createHandlers(hub, opts = {}) {
531
550
  },
532
551
 
533
552
  async dsh_interject(args) {
553
+ if (typeof args.message !== 'string' || args.message.trim() === '') {
554
+ throw new Error('message 不能为空:至少包含一个非空白字符');
555
+ }
534
556
  const conv = hub.get(args.conversation_id);
535
557
  const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
536
558
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
package/src/workspace.mjs CHANGED
@@ -24,6 +24,8 @@ import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
24
24
 
25
25
  const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
26
26
  const SESSIONS_ROOT = join(DSH_HOME, 'sessions');
27
+ /** forget 墓碑目录(存 id+时间,不含内容);清掉测试会话时一并清掉其墓碑。 */
28
+ const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
27
29
 
28
30
  /** 路径比较用:Windows 下大小写不敏感,且忽略结尾分隔符。 */
29
31
  const normPath = (p) => String(p ?? '').replace(/[\\/]+$/, '').toLowerCase();
@@ -329,6 +331,12 @@ export function purgeTestSessions({ dryRun = false, ids = [], tempOnly = false,
329
331
  } catch (e) {
330
332
  v.error = e.message;
331
333
  }
334
+ // 会话文件都没了,墓碑也就没有意义:一起清掉,避免测试残留。
335
+ try {
336
+ rmSync(join(FORGOTTEN_DIR, `${v.id}.json`), { force: true });
337
+ } catch {
338
+ /* 墓碑不存在或删不掉都不影响会话清理 */
339
+ }
332
340
  }
333
341
  // 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
334
342
  for (const bucket of readdirSync(SESSIONS_ROOT)) {