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 +10 -2
- package/README.zh.md +10 -2
- package/bin/dsh-any-agent.mjs +4 -1
- package/package.json +1 -1
- package/src/hub.mjs +57 -2
- package/src/mcp.mjs +86 -2
- package/src/tools.mjs +43 -9
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
|
-
🧪
|
|
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
|
|
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.
|
|
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
|
-
**离线那半边是
|
|
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
|
|
package/bin/dsh-any-agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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)
|
|
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
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|