dsh-any-agent 0.3.1 → 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
@@ -23,7 +23,8 @@ import {
23
23
  rmSync,
24
24
  readdirSync,
25
25
  } from 'node:fs';
26
- import { dirname, isAbsolute, join } from 'node:path';
26
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
27
+ import { homedir, tmpdir } from 'node:os';
27
28
  import { spawnSync } from 'node:child_process';
28
29
  import {
29
30
  APPROVAL_POLICIES,
@@ -40,6 +41,7 @@ import {
40
41
  SENTINEL_INCLUDE_REASONING,
41
42
  SENTINEL_TTL_MS,
42
43
  STATE_FILE,
44
+ DSH_HOME,
43
45
  defaultApprovalPolicy,
44
46
  } from './config.mjs';
45
47
  import { AcpProcess } from './acp.mjs';
@@ -59,6 +61,92 @@ import {
59
61
  const now = () => Date.now();
60
62
  const NOOP_LOG = { info: () => {}, debug: () => {}, dshStderr: () => {} };
61
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
+
62
150
  /** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
63
151
  const MAX_OUT_ENTRIES = 500;
64
152
  /** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
@@ -148,11 +236,21 @@ async function applySessionConfig(proc, sessionId, cfg) {
148
236
  * 默认剥离以维持"思考不落盘"的保证,并留下 thinking_omitted 标记,
149
237
  * 免得调用方以为字段是意外丢失的。
150
238
  */
151
- function sanitizeResultForSentinel(result) {
239
+ export function sanitizeResultForSentinel(result) {
152
240
  if (!result || typeof result !== 'object') return result ?? null;
153
241
  if (SENTINEL_INCLUDE_REASONING) return result;
154
242
  const { thinking, ...rest } = result;
155
- 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) };
156
254
  }
157
255
 
158
256
  export class Conversation {
@@ -193,6 +291,8 @@ export class Conversation {
193
291
  */
194
292
  this.out = [];
195
293
  this.outSeq = 0;
294
+ /** 输出缓冲的**修订号**:每次追加(含合并进已有条目)都 +1,供游标式增量读使用。 */
295
+ this.outRev = 0;
196
296
  this.turnOutChars = 0;
197
297
  this.currentTurn = 0;
198
298
  this.turnStartedAt = 0;
@@ -228,6 +328,11 @@ export class Conversation {
228
328
  };
229
329
  }
230
330
 
331
+ /** 侧车元数据:与注册表同字段,但独立落盘,forget 之后仍可恢复。 */
332
+ toMeta() {
333
+ return { version: 1, ...this.toState() };
334
+ }
335
+
231
336
  /** 合规的公开快照(含磁盘上的元信息)。 */
232
337
  snapshot() {
233
338
  const meta = readConversationMeta(this.id);
@@ -236,6 +341,13 @@ export class Conversation {
236
341
  conversation_id: this.id,
237
342
  /** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
238
343
  title: nativeTitle ?? this.titleHint ?? null,
344
+ /**
345
+ * DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
346
+ * 例如「这是一次连通性冒烟测试,请」),信息量很低。
347
+ * 所以调用方给了 title_hint 时,把两者分开返回,由调用方自己选:
348
+ * 想按 DSH 原生标题认会话就看 title,想要可读标签就看 title_hint。
349
+ */
350
+ title_hint: this.titleHint ?? null,
239
351
  cwd: this.cwd,
240
352
  permission: this.permission,
241
353
  on_approval: this.onApproval,
@@ -487,11 +599,33 @@ export class Conversation {
487
599
 
488
600
  async onPermissionRequest(params) {
489
601
  const options = params?.options ?? [];
490
- 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;
491
617
  const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
492
618
  const allowId = pickOption(options, 'allow');
493
619
  const rejectId = pickOption(options, 'reject');
494
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
+ }
495
629
  if (this.onApproval === 'auto-allow') {
496
630
  this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
497
631
  return allowId
@@ -624,6 +758,14 @@ export class Conversation {
624
758
  this.busy = false;
625
759
  this.collector = null;
626
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();
627
769
  this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
628
770
  phase: 'end',
629
771
  stop_reason: stopReason,
@@ -756,17 +898,24 @@ export class Conversation {
756
898
  if (last && last.kind === kind && last.turn === this.currentTurn && text) {
757
899
  last.text += text;
758
900
  last.at = now();
901
+ // ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
902
+ // 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
903
+ last.rev = ++this.outRev;
759
904
  } else {
760
- 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 });
761
906
  if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
762
907
  }
763
908
  if (kind === 'text') this.turnOutChars += text?.length ?? 0;
764
909
  }
765
910
 
766
- /** 读输出:游标式增量。返回 seq > cursor 的条目 + 新游标。 */
911
+ /** 读输出:**按修订号**的游标式增量。条目被追加时会再次返回(按 seq 替换全文)。 */
767
912
  readOut({ cursor = 0, includeReasoning = false, limit = 200 } = {}) {
768
- const entries = this.out.filter((e) => e.seq > cursor).slice(0, limit);
769
- 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;
770
919
  const res = {
771
920
  conversation_id: this.id,
772
921
  state: this.state(),
@@ -774,9 +923,9 @@ export class Conversation {
774
923
  current_tool: this.currentTool,
775
924
  entries,
776
925
  cursor: next,
777
- has_more: this.out.some((e) => e.seq > next),
926
+ has_more: this.out.some((e) => revOf(e) > next),
778
927
  /** 缓冲被环形覆盖时会为 true,表示你错过了中间一段。 */
779
- 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,
780
929
  };
781
930
  // 思考内容只在显式要求时、且只针对"当前正在跑的回合"返回;不保留、不进缓冲
782
931
  if (includeReasoning && this.collector?.reasoning) {
@@ -816,24 +965,27 @@ export class Conversation {
816
965
  return this.runs.length ? this.runs[this.runs.length - 1] : null;
817
966
  }
818
967
 
819
- /** 等到当前回合结束(不忙则立即返回 true;超时返回 false)。 */
968
+ /** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
820
969
  async waitIdle(timeoutMs = 30 * 60 * 1000) {
821
970
  if (!this.busy) return true;
822
971
  return await new Promise((resolve) => {
823
972
  let settled = false;
973
+ let timer = null;
824
974
  const done = () => {
825
975
  if (settled) return;
826
976
  settled = true;
827
- clearTimeout(timer);
977
+ if (timer) clearTimeout(timer);
828
978
  resolve(true);
829
979
  };
830
- const timer = setTimeout(() => {
831
- if (settled) return;
832
- settled = true;
833
- const i = this.idleWaiters.indexOf(done);
834
- if (i >= 0) this.idleWaiters.splice(i, 1);
835
- resolve(false);
836
- }, 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
+ }
837
989
  this.idleWaiters.push(done);
838
990
  });
839
991
  }
@@ -860,7 +1012,8 @@ export class Conversation {
860
1012
  if (mode === 'interject') {
861
1013
  this.proc?.notify('session/cancel', { sessionId: this.id });
862
1014
  }
863
- const idle = await this.waitIdle();
1015
+ // 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
1016
+ const idle = await this.waitIdle(timeoutMs);
864
1017
  const waited = now() - t0;
865
1018
  if (!idle) throw new Error('等待当前回合结束超时,插话未发送');
866
1019
  const r = await this.prompt(message, { reasoning, timeoutMs });
@@ -877,10 +1030,21 @@ export class Conversation {
877
1030
  } catch {}
878
1031
  await proc.stop();
879
1032
  }
1033
+ // 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
1034
+ for (const [, p] of this.pendingApprovals) {
1035
+ try {
1036
+ p.resolve({ outcome: { outcome: 'cancelled' } });
1037
+ } catch {}
1038
+ }
1039
+ this.pendingApprovals.clear();
880
1040
  // 进程没了 → 写锁也释放了,登记必须跟着删(否则会把"孤儿持有者"误报给抢占逻辑)
881
1041
  removeMarker(this.id);
882
- if (forget) this.#hub.remove(this.id);
883
- 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();
884
1048
  return { closed: true, forgotten: forget };
885
1049
  }
886
1050
  }
@@ -915,6 +1079,43 @@ export function formatReasoning(c, mode) {
915
1079
 
916
1080
  // ── 注册表 ────────────────────────────────────────────────────────
917
1081
 
1082
+ /**
1083
+ * 工作区能不能用在家目录本身?
1084
+ *
1085
+ * DSH 自己的 ACL 沙箱要求临时目录在工作区**之外**。Windows 上 %TEMP% 是
1086
+ * C:/Users/<name>/AppData/Local/Temp,正好在用户家目录里面,所以把家目录
1087
+ * 当作工作区时,沙箱初始化会失败:
1088
+ * Windows ACL: temp root must be outside the workspace
1089
+ * 症状出现在第一次起 shell 的时候,看起来像这个服务坏了,其实只是 cwd
1090
+ * 选得太大。read-only 档不写盘、也不起 ACL 沙箱,所以不受影响。
1091
+ *
1092
+ * @returns {string|null} null 表示没问题,否则返回一段可直接抛出的说明。
1093
+ */
1094
+ export function homeWorkspaceProblem(cwd, permission) {
1095
+ if (permission === 'read-only') return null;
1096
+ const home = homedir();
1097
+ if (!home) return null;
1098
+ const norm = (p) => {
1099
+ const r = resolve(p);
1100
+ return process.platform === 'win32' ? r.toLowerCase() : r;
1101
+ };
1102
+ if (norm(cwd) !== norm(home)) return null;
1103
+
1104
+ const temp = tmpdir();
1105
+ const tempInside = norm(temp) === norm(home) || norm(temp).startsWith(norm(home) + sep);
1106
+ const lines = ['cwd 不能是用户家目录本身: ' + cwd];
1107
+ if (tempInside) lines.push(' 临时目录 ' + temp + ' 就在它里面,');
1108
+ lines.push(
1109
+ ' DSH 的 ACL 沙箱要求临时目录在工作区之外,否则第一次起 shell 就会报:',
1110
+ ' Windows ACL: temp root must be outside the workspace',
1111
+ '',
1112
+ ' 换个具体项目目录最省事,或者改权限档:',
1113
+ ' permission=read-only 不写盘,不需要该沙箱',
1114
+ ' permission=danger-full-access 不受该沙箱约束',
1115
+ );
1116
+ return lines.join('\n');
1117
+ }
1118
+
918
1119
  export class Hub {
919
1120
  constructor({ log } = {}) {
920
1121
  this.conversations = new Map();
@@ -928,11 +1129,14 @@ export class Hub {
928
1129
  #probeCache = null;
929
1130
 
930
1131
  load() {
1132
+ const stale = pruneStaleTombstones();
1133
+ if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
931
1134
  if (!existsSync(STATE_FILE)) return;
932
1135
  try {
933
1136
  const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
934
1137
  for (const s of doc.conversations ?? []) {
935
1138
  if (!s?.id || !s?.cwd) continue;
1139
+ if (isForgotten(s.id)) continue;
936
1140
  this.conversations.set(s.id, new Conversation(s, this));
937
1141
  }
938
1142
  this.log.info(`[hub] 载入 ${this.conversations.size} 个会话`);
@@ -955,6 +1159,9 @@ export class Hub {
955
1159
  const tmp = `${STATE_FILE}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
956
1160
  writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8');
957
1161
  renameSync(tmp, STATE_FILE);
1162
+ // 侧车元数据独立于注册表:forget 移除注册表条目后仍然保留,
1163
+ // 让按 id 重建的会话不会丢掉审批策略/思考深度/标题/轮数。
1164
+ for (const c of this.conversations.values()) writeConversationMeta(c);
958
1165
  } catch (e) {
959
1166
  this.log.info(`[hub] 状态保存失败: ${e.message}`);
960
1167
  }
@@ -1052,6 +1259,14 @@ export class Hub {
1052
1259
  }
1053
1260
 
1054
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
+ }
1055
1270
  let c = this.conversations.get(id);
1056
1271
  if (!c) {
1057
1272
  // 多实例常见情形:这个会话是**另一个 dsh-any-agent 实例**刚建的,我们内存里的注册表还没有。
@@ -1068,18 +1283,39 @@ export class Hub {
1068
1283
  // 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
1069
1284
  // resume 时 DSH_PERMISSION_MODE 会把档位写进会话 —— 等于**悄悄提权**。
1070
1285
  const meta = readConversationMeta(id);
1286
+ const persisted = readConversationMetaFile(id);
1071
1287
  const recordedPreset = meta?.permissions?.preset;
1072
- 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);
1073
1296
  const recordedApproval = meta?.permissions?.approval;
1074
- const onApproval = APPROVAL_POLICIES.includes(recordedApproval)
1075
- ? recordedApproval
1076
- : 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';
1077
1303
  c = new Conversation(
1078
1304
  {
1079
1305
  id,
1080
- cwd: header.cwd,
1306
+ cwd: persisted?.cwd ?? header.cwd,
1081
1307
  permission,
1082
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,
1083
1319
  },
1084
1320
  this,
1085
1321
  );
@@ -1108,6 +1344,7 @@ export class Hub {
1108
1344
  const doc = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
1109
1345
  for (const s of doc.conversations ?? []) {
1110
1346
  if (!s?.id || !s?.cwd) continue;
1347
+ if (isForgotten(s.id)) continue;
1111
1348
  if (this.conversations.has(s.id)) continue;
1112
1349
  this.conversations.set(s.id, new Conversation(s, this));
1113
1350
  added++;
@@ -1121,15 +1358,26 @@ export class Hub {
1121
1358
 
1122
1359
  remove(id) {
1123
1360
  this.conversations.delete(id);
1361
+ // 注意:**不删侧车元数据**。forget 的语义是“从注册表移除”,
1362
+ // 之后按 id 重建(多实例恢复)时,侧车让审批策略/思考深度/标题/轮数不漂移。
1124
1363
  this.save();
1125
1364
  }
1126
1365
 
1127
1366
  /** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
1128
1367
  async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model } = {}) {
1129
- if (!cwd) throw new Error('必须提供 cwd(工作区绝对路径)');
1368
+ if (!cwd) {
1369
+ throw new Error(
1370
+ '没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
1371
+ ' 传一个项目目录即可,例如 cwd: "D:/code/my-project"。\n' +
1372
+ ' 不要传用户家目录本身 —— 那会让 DSH 的 ACL 沙箱起不来。',
1373
+ );
1374
+ }
1130
1375
  if (!isAbsolute(cwd)) throw new Error(`cwd 必须是绝对路径: ${cwd}`);
1131
- if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
1132
- throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建它)`);
1376
+ if (!existsSync(cwd)) {
1377
+ throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建这个目录)`);
1378
+ }
1379
+ if (!statSync(cwd).isDirectory()) {
1380
+ throw new Error(`工作区不存在或不是目录: ${cwd}(这是一个文件路径,请传目录)`);
1133
1381
  }
1134
1382
  if (!PERMISSION_TIERS.includes(permission)) {
1135
1383
  throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
@@ -1138,6 +1386,14 @@ export class Hub {
1138
1386
  if (!APPROVAL_POLICIES.includes(policy)) {
1139
1387
  throw new Error(`on_approval 必须是 ${APPROVAL_POLICIES.join(' | ')} 之一`);
1140
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
+ }
1141
1397
  const effort = reasoningEffort ?? DEFAULT_REASONING_EFFORT;
1142
1398
  if (!REASONING_EFFORTS.includes(effort)) {
1143
1399
  throw new Error(`reasoning_effort 必须是 ${REASONING_EFFORTS.join(' | ')} 之一`);
@@ -1145,6 +1401,10 @@ export class Hub {
1145
1401
  if (model && !provider) {
1146
1402
  throw new Error('指定 model 时必须同时给 provider(ACP 的模型标识是 [provider, model] 二元组)');
1147
1403
  }
1404
+ // 家目录当选工作区 → DSH 的 ACL 沙箱起不来,第一次起 shell 才暴露。
1405
+ // 与其让用户以为服务坏了,不如在这里把原因和出路一起说清楚。
1406
+ const homeProblem = homeWorkspaceProblem(cwd, permission);
1407
+ if (homeProblem) throw new Error(homeProblem);
1148
1408
  const proc = new AcpProcess({
1149
1409
  cwd,
1150
1410
  permission,
@@ -1153,9 +1413,11 @@ export class Hub {
1153
1413
  onPermissionRequest: () => ({ outcome: { outcome: 'cancelled' } }),
1154
1414
  });
1155
1415
  proc.start();
1416
+ let createdSessionId = null;
1156
1417
  try {
1157
1418
  await proc.initialize();
1158
1419
  const res = await proc.request('session/new', { cwd, mcpServers: [] });
1420
+ createdSessionId = res.sessionId;
1159
1421
  const conv = new Conversation(
1160
1422
  {
1161
1423
  id: res.sessionId,
@@ -1180,6 +1442,16 @@ export class Hub {
1180
1442
  });
1181
1443
  // 套用会话级配置(思考深度默认 max;ACP 自己的默认是 Provider default)
1182
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
+ }
1183
1455
  // 登记工作区,否则这些会话在你的 DSH GUI 里看不到。
1184
1456
  // ★ 必须容错:registerWorkspace 会写 workspace.json(磁盘满/被占时会抛),
1185
1457
  // 而它在 this.conversations.set 之前 —— 抛出去就会**丢掉这个会话并泄漏进程**。
@@ -1197,6 +1469,10 @@ export class Hub {
1197
1469
  );
1198
1470
  return conv;
1199
1471
  } catch (e) {
1472
+ // 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
1473
+ // 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
1474
+ // 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
1475
+ if (createdSessionId) removeMarker(createdSessionId);
1200
1476
  await proc.stop().catch(() => {});
1201
1477
  throw e;
1202
1478
  }
@@ -1207,6 +1483,7 @@ export class Hub {
1207
1483
  const out = new Map();
1208
1484
  for (const c of this.conversations.values()) {
1209
1485
  if (cwd && c.cwd !== cwd) continue;
1486
+ if (isForgotten(c.id)) continue;
1210
1487
  out.set(c.id, c.snapshot());
1211
1488
  }
1212
1489
  if (!includeClosed) return [...out.values()];
@@ -1239,6 +1516,7 @@ export class Hub {
1239
1516
  }
1240
1517
  for (const s of sessions) {
1241
1518
  if (out.has(s.sessionId)) continue;
1519
+ if (isForgotten(s.sessionId)) continue;
1242
1520
  const meta = readConversationMeta(s.sessionId);
1243
1521
  out.set(s.sessionId, {
1244
1522
  conversation_id: s.sessionId,