dsh-any-agent 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -13,7 +13,7 @@ An MCP server that drives DSH as a **long-lived agent runtime** instead of wrapp
13
13
 
14
14
  **Works with any MCP client.** Nothing here is Codex- or Claude-specific: the server speaks plain MCP over stdio and uses only the four standard methods (`initialize`, `ping`, `tools/list`, `tools/call`). Verified against a bare-bones client that negotiates an older protocol version and never sends `notifications/initialized`.
15
15
 
16
- 🧪 632 offline checks green on 0.3.0 (619 of them in the client-registration suites) · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
16
+ 🧪 660 offline checks green on 0.3.2 (619 of them in the client-registration suites) · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
17
17
 
18
18
  > 📌 **Read the [Usage Notes](USAGE-NOTES.en.md) first** — the practical gotchas that will actually bite you: the ACP model-config trap, external MCP tools bypassing DSH's sandbox, the workspace/GUI registry cache, cost control, and a troubleshooting table.
19
19
  >
@@ -210,6 +210,14 @@ Removal is not accepted because it *looks* right: the check is that **taking our
210
210
  Afterwards every JSON config still parses and `codex mcp list` still reads the file; no client needs hand repair.
211
211
 
212
212
 
213
+ **`cwd` is optional: it defaults to the project the client has open**
214
+
215
+ The client already knows which folder the user is working in (MCP roots). `dsh_start` without `cwd` now sends one `roots/list` request and converts the returned `file://` URI into a path, so "DSH works where the client is looking" is the default - which also removes the usual way to end up on the home directory by building the path by hand. A client that does not advertise roots, or does not answer in time, gets an actionable error instead of a guessed directory.
216
+
217
+ **Do not use the home directory as `cwd`**
218
+
219
+ DSH's own ACL sandbox requires its temp directory to be **outside** the workspace. On Windows `%TEMP%` is `C:/Users/<you>/AppData/Local/Temp`, inside the home directory, so `cwd` set to home makes the first shell command fail with `Windows ACL: temp root must be outside the workspace`. `dsh_start` now rejects that combination and names both escapes: a narrower project directory, or `permission=read-only` / `danger-full-access`.
220
+
213
221
  **Backups and rollback**
214
222
 
215
223
  Both registering and removing snapshot the file first: `<config>.bak-dsh-<timestamp>`, holding the exact pre-operation bytes. Both directions, because the state you want back differs — the pre-register backup undoes a bad install, the pre-remove backup reinstalls after a bad removal.
@@ -518,7 +526,7 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
518
526
  | `permission` | 11 | ★ **whether the permission tiers actually take effect** (a safety property): it ignores our own return values (the very thing that used to lie) and reads **the session's own record** (`permissions.preset` / `sandboxMode` in the projection cache), checking all three tiers and that their recorded values differ. **This suite caught a silent privilege escalation**: a `defaultPreset` in the profile overrides `DSH_PERMISSION_MODE` at session creation |
519
527
  | `list` | 13 | **the cost of `dsh_list`** (measured): a default call with the on-disk probe takes ~1 s (it spawns a DSH process) while **repeated calls hit a cache and drop to single-digit milliseconds**; `only_running=true` and `include_closed=false` **skip the probe** with equivalent semantics (unopened on-disk sessions are simply excluded) |
520
528
 
521
- **The offline half is 632 checks, all green on 0.3.0** — `guards` 31, `prune` 11, `approval-details` 24, `sandbox-guard` 18, `setup` 41, `removal-scope` 24, `config-safety` 403, `real-configs` 66, `package-check` 14. Every suite exited 0; none needs a model, a DSH install or a provider.
529
+ **The offline half is 660 checks, all green on 0.3.2** — `guards` 31, `prune` 11, `approval-details` 24, `sandbox-guard` 18, `setup` 41, `removal-scope` 24, `config-safety` 403, `real-configs` 66, `package-check` 14, `roots` 12, `workspace-titles` 15. Every suite exited 0; none needs a model, a DSH install or a provider.
522
530
 
523
531
  That is **619 assertions in the client-registration suites alone** (`setup`, `removal-scope`, `config-safety`, `real-configs`), which is where the 0.3 work actually is.
524
532
 
package/README.zh.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  **任何 MCP 客户端都能用。** 这里没有任何 Codex 或 Claude 专属的东西:服务端只讲标准 MCP over stdio,只用四个标准方法(`initialize`、`ping`、`tools/list`、`tools/call`)。已用一个"原始"客户端验证过——它协商更旧的协议版本、且从不发 `notifications/initialized`。
15
15
 
16
- 🧪 0.3.0 离线 632 项检查全绿(其中 619 项属于客户端注册器套件) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
16
+ 🧪 0.3.2 离线 660 项检查全绿(其中 619 项属于客户端注册器套件) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
17
17
 
18
18
  > 📌 **建议先读 [使用注意事项](USAGE-NOTES.md)** —— 会真正踩到的坑:ACP 模型配置陷阱、**外部 MCP 工具的副作用不受 DSH 沙箱约束**、工作区与 GUI 的注册表缓存、成本控制、以及一张故障速查表。
19
19
  >
@@ -225,6 +225,14 @@ Codex 的 `config.toml` 同理,是按行处理的:其它 `[表]` 及其注
225
225
  撤销后所有 JSON 仍可解析、`codex mcp list` 仍能正常读取配置,客户端不需要人工修复。
226
226
 
227
227
 
228
+ **`cwd` 可以省略:默认用客户端当前打开的项目**
229
+
230
+ 客户端本来就知道用户在哪个目录干活(MCP roots)。现在 `dsh_start` 不传 `cwd` 时,会向客户端发一次 `roots/list`,把返回的 `file://` URI 转成路径直接用。于是「客户端里看到哪个项目,DSH 就在哪个项目干活」成了默认行为,也顺带堵掉了「手拼路径拼到家目录」这条最常见的翻车路径。客户端不声明 roots、或者超时没回,就退回一条能照着改的报错,而不是猜一个目录。
231
+
232
+ **cwd 不要选家目录本身**
233
+
234
+ DSH 自己的 ACL 沙箱要求临时目录在工作区**之外**。Windows 上 `%TEMP%` 是 `C:/Users/<你>/AppData/Local/Temp`,就在家目录里面,所以 `cwd` 取家目录时,第一条 shell 命令会失败并报 `Windows ACL: temp root must be outside the workspace`。现在 `dsh_start` 会直接拒绝这个组合并给出两条出路:换一个具体项目目录,或改用 `permission=read-only` / `danger-full-access`。
235
+
228
236
  **备份与回滚**
229
237
 
230
238
  注册和删除**都会**在动文件之前先存一份:`<配置文件>.bak-dsh-<时间戳>`,内容就是操作前的原始字节。两个方向都存,因为要退回的状态不同——注册前那份用来「装错了退回原样」,删除前那份用来「删错了装回去」。
@@ -538,7 +546,7 @@ node test/cleanup.mjs # 单独跑收尾清理(只针对临时目录;--d
538
546
  | `permission` | 11 | ★ **权限档是否真的生效**(安全属性):不看我们的返回值(那正是会说谎的地方),而是读**会话自己记录的事实**(投影缓存里的 `permissions.preset` / `sandboxMode`),三档逐一核对且三档记录值互不相同。**这条测试抓到过无声提权**:profile 里 `defaultPreset` 会在会话创建时盖掉 `DSH_PERMISSION_MODE` |
539
547
  | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
540
548
 
541
- **离线那半边是 632 项检查,0.3.0 上全绿** —— `guards` 31、`prune` 11、`approval-details` 24、`sandbox-guard` 18、`setup` 41、`removal-scope` 24、`config-safety` 403、`real-configs` 66、`package-check` 14。九套全部退出码 0,都不需要模型、不需要装 DSH、也不需要 provider。
549
+ **离线那半边是 660 项检查,0.3.2 上全绿** —— `guards` 31、`prune` 11、`approval-details` 24、`sandbox-guard` 18、`setup` 41、`removal-scope` 24、`config-safety` 403、`real-configs` 66、`package-check` 14、`roots` 12、`workspace-titles` 15。九套全部退出码 0,都不需要模型、不需要装 DSH、也不需要 provider。
542
550
 
543
551
  其中 **619 项属于客户端注册器套件**(`setup`、`removal-scope`、`config-safety`、`real-configs`)—— 0.3 的活其实都在这里。
544
552
 
@@ -53,6 +53,7 @@ const { createLogger } = await import('../src/config.mjs');
53
53
  const { Hub } = await import('../src/hub.mjs');
54
54
  const { createMcpServer } = await import('../src/mcp.mjs');
55
55
  const { listOrphans, pruneDeadMarkers } = await import('../src/locks.mjs');
56
+ const { createHandlers } = await import('../src/tools.mjs');
56
57
 
57
58
  const log = createLogger();
58
59
 
@@ -78,7 +79,9 @@ if (deadMarkers.removed || orphans.length) {
78
79
  }
79
80
  if (orphans.length) log.info(`[dsh-any-agent] 孤儿持有者会话: ${orphans.map((o) => o.conversation_id).join(', ')}`);
80
81
 
81
- const server = createMcpServer({ hub, log });
82
+ // dsh_start 不传 cwd 时,用客户端当前打开的项目目录(MCP roots)。
83
+ // 这样「客户端里看到哪个项目,DSH 就在哪个项目干活」是默认行为。
84
+ const server = createMcpServer({ hub, log, handlers: createHandlers(hub, { projectDir: () => server.projectDir() }) });
82
85
  server.start();
83
86
 
84
87
  log.info(`[dsh-any-agent] 已启动(注册表载入 ${hub.conversations.size} 个会话,日志级别 ${log.level})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-agent",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code. Resume-aware, interruptible, approval-capable, and it only ever touches the one config entry it owns.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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,
@@ -236,6 +237,13 @@ export class Conversation {
236
237
  conversation_id: this.id,
237
238
  /** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
238
239
  title: nativeTitle ?? this.titleHint ?? null,
240
+ /**
241
+ * DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
242
+ * 例如「这是一次连通性冒烟测试,请」),信息量很低。
243
+ * 所以调用方给了 title_hint 时,把两者分开返回,由调用方自己选:
244
+ * 想按 DSH 原生标题认会话就看 title,想要可读标签就看 title_hint。
245
+ */
246
+ title_hint: this.titleHint ?? null,
239
247
  cwd: this.cwd,
240
248
  permission: this.permission,
241
249
  on_approval: this.onApproval,
@@ -915,6 +923,43 @@ export function formatReasoning(c, mode) {
915
923
 
916
924
  // ── 注册表 ────────────────────────────────────────────────────────
917
925
 
926
+ /**
927
+ * 工作区能不能用在家目录本身?
928
+ *
929
+ * DSH 自己的 ACL 沙箱要求临时目录在工作区**之外**。Windows 上 %TEMP% 是
930
+ * C:/Users/<name>/AppData/Local/Temp,正好在用户家目录里面,所以把家目录
931
+ * 当作工作区时,沙箱初始化会失败:
932
+ * Windows ACL: temp root must be outside the workspace
933
+ * 症状出现在第一次起 shell 的时候,看起来像这个服务坏了,其实只是 cwd
934
+ * 选得太大。read-only 档不写盘、也不起 ACL 沙箱,所以不受影响。
935
+ *
936
+ * @returns {string|null} null 表示没问题,否则返回一段可直接抛出的说明。
937
+ */
938
+ export function homeWorkspaceProblem(cwd, permission) {
939
+ if (permission === 'read-only') return null;
940
+ const home = homedir();
941
+ if (!home) return null;
942
+ const norm = (p) => {
943
+ const r = resolve(p);
944
+ return process.platform === 'win32' ? r.toLowerCase() : r;
945
+ };
946
+ if (norm(cwd) !== norm(home)) return null;
947
+
948
+ const temp = tmpdir();
949
+ const tempInside = norm(temp) === norm(home) || norm(temp).startsWith(norm(home) + sep);
950
+ const lines = ['cwd 不能是用户家目录本身: ' + cwd];
951
+ if (tempInside) lines.push(' 临时目录 ' + temp + ' 就在它里面,');
952
+ lines.push(
953
+ ' DSH 的 ACL 沙箱要求临时目录在工作区之外,否则第一次起 shell 就会报:',
954
+ ' Windows ACL: temp root must be outside the workspace',
955
+ '',
956
+ ' 换个具体项目目录最省事,或者改权限档:',
957
+ ' permission=read-only 不写盘,不需要该沙箱',
958
+ ' permission=danger-full-access 不受该沙箱约束',
959
+ );
960
+ return lines.join('\n');
961
+ }
962
+
918
963
  export class Hub {
919
964
  constructor({ log } = {}) {
920
965
  this.conversations = new Map();
@@ -1126,7 +1171,13 @@ export class Hub {
1126
1171
 
1127
1172
  /** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
1128
1173
  async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model } = {}) {
1129
- if (!cwd) throw new Error('必须提供 cwd(工作区绝对路径)');
1174
+ if (!cwd) {
1175
+ throw new Error(
1176
+ '没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
1177
+ ' 传一个项目目录即可,例如 cwd: "D:/code/my-project"。\n' +
1178
+ ' 不要传用户家目录本身 —— 那会让 DSH 的 ACL 沙箱起不来。',
1179
+ );
1180
+ }
1130
1181
  if (!isAbsolute(cwd)) throw new Error(`cwd 必须是绝对路径: ${cwd}`);
1131
1182
  if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
1132
1183
  throw new Error(`工作区不存在或不是目录: ${cwd}(请先创建它)`);
@@ -1145,6 +1196,10 @@ export class Hub {
1145
1196
  if (model && !provider) {
1146
1197
  throw new Error('指定 model 时必须同时给 provider(ACP 的模型标识是 [provider, model] 二元组)');
1147
1198
  }
1199
+ // 家目录当选工作区 → DSH 的 ACL 沙箱起不来,第一次起 shell 才暴露。
1200
+ // 与其让用户以为服务坏了,不如在这里把原因和出路一起说清楚。
1201
+ const homeProblem = homeWorkspaceProblem(cwd, permission);
1202
+ if (homeProblem) throw new Error(homeProblem);
1148
1203
  const proc = new AcpProcess({
1149
1204
  cwd,
1150
1205
  permission,
package/src/mcp.mjs CHANGED
@@ -39,7 +39,8 @@ DSH 自己有文件读写、shell、搜索、技能、子代理等全套工具
39
39
  - cursor —— dsh_read 的增量游标,用来观察某个会话当前吐出的内容。
40
40
 
41
41
  【标准流程】
42
- 1. dsh_start(cwd, ...) 建会话。cwd 必须是**已存在**的绝对路径。
42
+ 1. dsh_start(cwd?, ...) 建会话。cwd 省略时自动用客户端当前打开的项目目录
43
+ (MCP roots);给了就必须是**已存在**的绝对路径,别传家目录本身。
43
44
  2. dsh_send(conversation_id, prompt) **默认不阻塞**:立刻返回 run_id + sentinel_file。
44
45
  想原地等结果才传 wait=true。
45
46
  3. 之后按需:
@@ -96,10 +97,70 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
96
97
  - 不要把 DSH 内部的工具名(read/pwsh/glob 等)当成本服务的工具——本服务只有 dsh_* 这些。
97
98
  - 不要假设回合秒回。长任务请走 wait=false + 事后验收。`;
98
99
 
100
+ /**
101
+ * 把 MCP root 的 file:// URI 转成本地路径。
102
+ * 只接受 file 协议;其它(http/内嵌工作区等)一律当作“用不了”,
103
+ * 宁可退回要求显式 cwd,也不要把一个奇怪的字符串当成目录。
104
+ */
105
+ function fileURLToPathSafe(uri) {
106
+ try {
107
+ if (!uri.startsWith('file:')) return null;
108
+ return fileURLToPath(uri);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
99
114
  export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHandlers(hub), log }) {
100
115
  const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
101
116
  let buffer = '';
102
117
  let initialized = false;
118
+ let clientCapabilities = {};
119
+ let rootsSeq = 0;
120
+ const pendingRoots = new Map();
121
+ /** 客户端当前打开的项目目录(来自 MCP roots)。null 表示还没有/不支持。 */
122
+ let clientRoots = null;
123
+ let rootsRequested = false;
124
+
125
+ /**
126
+ * 主动向客户端要一次 roots。
127
+ * 这不是 JSON-RPC 的“请求-响应”对称用法:MCP 的服务端可以反向发请求,
128
+ * 客户端用同一个 stdout 回响应。所以这里带 id 发出去,再等 onData 收到
129
+ * 对应 id 的消息来完成 promise。超时/不支持都必须安静降级 —— roots 只是
130
+ * 一个更聪明的默认值,拿不到就让调用方显式传 cwd。
131
+ */
132
+ function requestRoots(timeoutMs = 1500) {
133
+ if (rootsRequested) return Promise.resolve(clientRoots);
134
+ rootsRequested = true;
135
+ const caps = clientCapabilities?.roots;
136
+ if (!caps) return Promise.resolve(null);
137
+ const id = 'roots-' + (rootsSeq++);
138
+ return new Promise((resolve) => {
139
+ const timer = setTimeout(() => {
140
+ pendingRoots.delete(id);
141
+ resolve(null);
142
+ }, timeoutMs);
143
+ timer.unref?.();
144
+ pendingRoots.set(id, (roots) => {
145
+ clearTimeout(timer);
146
+ clientRoots = roots;
147
+ resolve(roots);
148
+ });
149
+ write({ jsonrpc: '2.0', id, method: 'roots/list', params: {} });
150
+ });
151
+ }
152
+
153
+ /** 从 roots 里挑一个最像“项目目录”的路径。 */
154
+ function pickRoot(roots) {
155
+ if (!Array.isArray(roots) || !roots.length) return null;
156
+ const uris = roots.map((r) => r && typeof r.uri === 'string' ? r.uri : null).filter(Boolean);
157
+ if (!uris.length) return null;
158
+ for (const uri of uris) {
159
+ const asPath = fileURLToPathSafe(uri);
160
+ if (asPath) return asPath;
161
+ }
162
+ return null;
163
+ }
103
164
 
104
165
  const ok = (id, result) => write({ jsonrpc: '2.0', id, result });
105
166
  const fail = (id, code, message) => write({ jsonrpc: '2.0', id, error: { code, message } });
@@ -107,6 +168,14 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
107
168
  async function handle(msg) {
108
169
  const { id, method, params } = msg;
109
170
 
171
+ // 客户端对我们反向请求(roots/list)的响应:没有 method,只有 result/error。
172
+ if (method === undefined && pendingRoots.has(id)) {
173
+ const done = pendingRoots.get(id);
174
+ pendingRoots.delete(id);
175
+ done(msg.error ? [] : (msg.result?.roots ?? []));
176
+ return;
177
+ }
178
+
110
179
  // 通知(无 id):不需要回包
111
180
  if (id === undefined) {
112
181
  if (method === 'notifications/initialized') initialized = true;
@@ -116,6 +185,11 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
116
185
  switch (method) {
117
186
  case 'initialize': {
118
187
  const requested = params?.protocolVersion;
188
+ // 记住客户端声明了哪些能力:有 roots 的客户端(Claude Code / Cursor /
189
+ // VS Code 等)会告诉我们它当前打开的项目目录,那是 dsh_start 最合理的
190
+ // 默认 cwd —— 用户在客户端里看到哪个项目,就在这里干活。
191
+ const caps = params?.capabilities;
192
+ clientCapabilities = caps && typeof caps === 'object' ? caps : {};
119
193
  ok(id, {
120
194
  protocolVersion: typeof requested === 'string' ? requested : FALLBACK_PROTOCOL,
121
195
  capabilities: { tools: { listChanged: false } },
@@ -124,6 +198,11 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
124
198
  });
125
199
  return;
126
200
  }
201
+ case 'roots/list': {
202
+ // 有的客户端会主动问;我们不需要“暴露自己的根”,回空即可。
203
+ ok(id, { roots: [] });
204
+ return;
205
+ }
127
206
  case 'ping':
128
207
  ok(id, {});
129
208
  return;
@@ -190,8 +269,13 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
190
269
  process.stdin.on('end', () => log?.debug('[mcp] stdin 结束'));
191
270
  return this;
192
271
  },
272
+ /** 客户端当前打开的项目目录;不支持 roots 的客户端返回 null。 */
273
+ async projectDir() {
274
+ const roots = await requestRoots();
275
+ return pickRoot(roots);
276
+ },
193
277
  get initialized() {
194
278
  return initialized;
195
279
  },
196
280
  };
197
- }
281
+ }
package/src/tools.mjs CHANGED
@@ -34,7 +34,13 @@ export const TOOL_DEFS = [
34
34
  '返回 conversation_id 与对话标题。建好后用 dsh_send 派活。',
35
35
  inputSchema: S(
36
36
  {
37
- cwd: { type: 'string', description: '工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' },
37
+ cwd: {
38
+ type: 'string',
39
+ description:
40
+ '工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' +
41
+ '**省略时自动用客户端当前打开的项目目录**(MCP roots),这样客户端里看到哪个项目就在这里干活;' +
42
+ '客户端不支持 roots 时才会要求你显式传入。别传用户家目录本身 —— 那里会让 DSH 的 ACL 沙箱起不来。',
43
+ },
38
44
  permission: {
39
45
  type: 'string',
40
46
  enum: PERMISSION_TIERS,
@@ -48,7 +54,12 @@ export const TOOL_DEFS = [
48
54
  enum: APPROVAL_POLICIES,
49
55
  description: `审批处理策略。默认 read-only → auto-deny,其余 → ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
50
56
  },
51
- title_hint: { type: 'string', description: '可选的标题提示(DSH 的标题由首条消息自动生成,这里只是兜底显示)。' },
57
+ title_hint: {
58
+ type: 'string',
59
+ description:
60
+ '可读的会话标签。注意 DSH 自己的标题是首条消息的截断前缀(约 13–15 字),会优先显示;' +
61
+ '这里给的值不会被覆盖,而是在 dsh_get / dsh_start 里作为「标题提示」一并返回。',
62
+ },
52
63
  reasoning_effort: {
53
64
  type: 'string',
54
65
  enum: REASONING_EFFORTS,
@@ -57,7 +68,8 @@ export const TOOL_DEFS = [
57
68
  provider: { type: 'string', description: '模型提供方名(如 deepseek-official)。留空用 profile 默认。' },
58
69
  model: { type: 'string', description: '模型名(如 deepseek-flash)。指定时必须同时给 provider。' },
59
70
  },
60
- ['cwd'],
71
+ // cwd 不再是必填:有 MCP roots 的客户端会自动带上当前项目目录。
72
+ [],
61
73
  ),
62
74
  },
63
75
  {
@@ -319,11 +331,30 @@ function backgroundReceipt(hub, conv, res) {
319
331
  };
320
332
  }
321
333
 
322
- export function createHandlers(hub) {
334
+ /**
335
+ * @param {object} hub
336
+ * @param {{projectDir?: () => Promise<string|null>}} [opts]
337
+ * projectDir:客户端当前打开的项目目录(MCP roots)。不传 cwd 时用它兜底 ——
338
+ * 让「在客户端里看到哪个项目,就在哪个项目干活」成为默认行为,而不是让
339
+ * 调用方每次都自己拼绝对路径(拼错了就落到家目录,撞上 ACL 沙箱那个坑)。
340
+ */
341
+ export function createHandlers(hub, opts = {}) {
342
+ const projectDir = typeof opts.projectDir === 'function' ? opts.projectDir : null;
343
+
323
344
  return {
324
345
  async dsh_start(args) {
346
+ let cwd = args.cwd;
347
+ let cwdSource = cwd ? 'explicit' : null;
348
+ if (!cwd && projectDir) {
349
+ // 客户端声明了 roots 才有;拿不到就照旧要求显式传,不猜。
350
+ const fromClient = await projectDir();
351
+ if (fromClient) {
352
+ cwd = fromClient;
353
+ cwdSource = 'client-root';
354
+ }
355
+ }
325
356
  const conv = await hub.create({
326
- cwd: args.cwd,
357
+ cwd,
327
358
  permission: args.permission ?? DEFAULT_PERMISSION,
328
359
  onApproval: args.on_approval,
329
360
  titleHint: args.title_hint,
@@ -336,10 +367,11 @@ export function createHandlers(hub) {
336
367
  const lines = [
337
368
  '已创建会话。',
338
369
  `conversation_id: ${snap.conversation_id}`,
339
- `工作区: ${snap.cwd}`,
370
+ `工作区: ${snap.cwd}` + (cwdSource === 'client-root' ? '(取自客户端当前打开的项目)' : ''),
340
371
  `权限档: ${snap.permission}(审批策略 ${snap.on_approval})`,
341
372
  `思考深度: ${snap.reasoning_effort}`,
342
- `对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}`,
373
+ `对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}` +
374
+ (snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
343
375
  `工作区登记: ${snap.workspace_registered ? '已写入 DSH 工作区注册表(GUI 里能看到)' : '未登记'}`,
344
376
  ];
345
377
  if (snap.config_failed?.length) lines.push(`⚠️ 配置未完全套用: ${snap.config_failed.join('; ')}`);
@@ -451,7 +483,9 @@ export function createHandlers(hub) {
451
483
  const snap = conv.snapshot();
452
484
  let run = null;
453
485
  const meta = [];
454
- meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(DSH 自动生成)`);
486
+ // DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
487
+ meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(DSH 自动生成)` +
488
+ (snap.title_hint && snap.title !== snap.title_hint ? ` / 标题提示: ${snap.title_hint}` : ''));
455
489
  meta.push(`工作区: ${snap.cwd}`);
456
490
  meta.push(`状态: ${snap.busy ? '忙' : snap.alive ? '空闲(进程活着)' : '未打开(可用 resume 复活)'}`);
457
491
  meta.push(`权限档: ${snap.permission} / 审批 ${snap.on_approval}`);
@@ -620,4 +654,4 @@ export function createHandlers(hub) {
620
654
  };
621
655
  },
622
656
  };
623
- }
657
+ }