dsh-any-agent 0.3.3 → 0.3.5
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/CHANGELOG.md +628 -0
- package/DESIGN.md +2 -2
- package/README.md +75 -22
- package/README.zh.md +46 -25
- package/USAGE-MIGRATION.md +9 -9
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +7 -7
- package/bin/dsh-any-agent-workspaces.mjs +22 -3
- package/bin/dsh-any-agent.mjs +3 -2
- package/package.json +2 -1
- package/src/acp.mjs +24 -3
- package/src/bootstrap.mjs +73 -64
- package/src/clients.mjs +10 -15
- package/src/config.mjs +59 -8
- package/src/hub.mjs +124 -12
- package/src/tools.mjs +16 -8
- package/src/workspace.mjs +137 -39
- package/src/yaml-lite.mjs +424 -0
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions [--dry-run] [--ids=a,b]
|
|
16
16
|
*
|
|
17
17
|
* 只有 --purge-test-sessions 会动磁盘上的会话文件,且规则很死:只删
|
|
18
|
-
* "cwd
|
|
18
|
+
* "cwd 在 tmpdir()/dsh-any-agent-* 下" 或 "cwd 在 Desktop\dsh-any-agent-test-*" 的会话,
|
|
19
|
+
* 外加 --ids 显式指定的。**不会**删除用户在 %TEMP% 里自己的真实项目会话。
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import {
|
|
@@ -45,7 +46,7 @@ const ids = (val('--ids') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
|
45
46
|
|
|
46
47
|
// ── 清理测试会话(唯一会删文件的动作) ──────────────────────
|
|
47
48
|
if (has('--purge-test-sessions')) {
|
|
48
|
-
const res = purgeTestSessions({ dryRun, ids });
|
|
49
|
+
const res = purgeTestSessions({ dryRun, ids, testOnly: true });
|
|
49
50
|
console.log(`${flag}清理测试会话:${res.count} 个`);
|
|
50
51
|
for (const v of res.victims) {
|
|
51
52
|
console.log(` - ${v.id} [${v.reason}]${v.origin ? ` origin=${v.origin}` : ''} cwd=${v.cwd}`);
|
|
@@ -59,6 +60,10 @@ if (has('--purge-test-sessions')) {
|
|
|
59
60
|
// ── 回填 ────────────────────────────────────────────────────
|
|
60
61
|
if (has('--backfill')) {
|
|
61
62
|
const stats = backfillWorkspaces({ includeTemp: has('--include-temp'), dryRun });
|
|
63
|
+
if (stats.error) {
|
|
64
|
+
console.error(`${flag}回填失败(未写盘):${stats.error}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
62
67
|
console.log(`${flag}回填:`);
|
|
63
68
|
console.log(` 扫描到会话 ${stats.sessions_scanned}`);
|
|
64
69
|
console.log(` 新建工作区 ${stats.workspaces_created}`);
|
|
@@ -74,6 +79,10 @@ if (has('--backfill')) {
|
|
|
74
79
|
// ── 清理临时工作区 ──────────────────────────────────────────
|
|
75
80
|
if (has('--prune-temp')) {
|
|
76
81
|
const res = pruneTempWorkspaces({ dryRun });
|
|
82
|
+
if (res.error) {
|
|
83
|
+
console.error(`${flag}清理失败(未写盘):${res.error}`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
}
|
|
77
86
|
console.log(`${flag}清理临时工作区条目:${res.removed.length} 个`);
|
|
78
87
|
for (const r of res.removed) console.log(` - ${r.path}(含 ${r.sessions} 个会话条目)`);
|
|
79
88
|
console.log('');
|
|
@@ -82,6 +91,10 @@ if (has('--prune-temp')) {
|
|
|
82
91
|
// ── 清理空工作区 ────────────────────────────────────────────
|
|
83
92
|
if (has('--prune-empty')) {
|
|
84
93
|
const res = pruneEmptyWorkspaces({ dryRun });
|
|
94
|
+
if (res.error) {
|
|
95
|
+
console.error(`${flag}清理失败(未写盘):${res.error}`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
}
|
|
85
98
|
console.log(`${flag}清理"无会话且路径已不存在"的工作区:${res.removed.length} 个`);
|
|
86
99
|
for (const r of res.removed) console.log(` - ${r.path}`);
|
|
87
100
|
console.log('');
|
|
@@ -89,7 +102,13 @@ if (has('--prune-empty')) {
|
|
|
89
102
|
|
|
90
103
|
// ── 列表(或什么都不传时默认) ──────────────────────────────
|
|
91
104
|
if (has('--list') || argv.length === 0 || argv.every((a) => a === '--dry-run')) {
|
|
92
|
-
|
|
105
|
+
let list;
|
|
106
|
+
try {
|
|
107
|
+
list = listWorkspaces();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(`读取工作区注册表失败: ${e.message}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
93
112
|
const temp = list.filter((w) => w.is_temp);
|
|
94
113
|
console.log(`共 ${list.length} 个工作区(临时目录 ${temp.length} 个)——**这是文件里的内容**:`);
|
|
95
114
|
for (const w of list.sort((a, b) => Number(a.is_temp) - Number(b.is_temp))) {
|
package/bin/dsh-any-agent.mjs
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* 环境变量:
|
|
6
6
|
* DSH_BIN DSH CLI 入口(默认探测 npx 缓存位置)
|
|
7
7
|
* DSH_MCP_PROFILE 驱动用的 DSH profile(默认 dsh-mcp)
|
|
8
|
-
* DSH_MCP_STATE 会话注册表路径(默认
|
|
8
|
+
* DSH_MCP_STATE 会话注册表路径(默认 $DSH_HOME/.dsh-any-agent/conversations.json)
|
|
9
9
|
* DSH_MCP_PERMISSION 新建会话的默认权限档(默认 danger-full-access)
|
|
10
|
-
*
|
|
10
|
+
* DSH_MCP_APPROVAL 默认审批策略(非只读档默认 auto-allow;ask = 挂起待裁决)
|
|
11
|
+
* DSH_MCP_IDLE_TTL_MS 空闲多久回收会话进程(默认 0 = 永不回收)
|
|
11
12
|
* DSH_MCP_PROMPT_TIMEOUT_MS wait=true 时的等待上限(默认 0 = 不设超时)
|
|
12
13
|
* DSH_MCP_LOCK_STALE_MS 写锁登记多久没心跳算"持有者失联"(默认 90 秒)
|
|
13
14
|
* DSH_MCP_LOG silent(默认) | info | debug —— 日志级别
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"USAGE-NOTES.en.md",
|
|
37
37
|
"USAGE-NOTES.md",
|
|
38
38
|
"USAGE-MIGRATION.md",
|
|
39
|
+
"CHANGELOG.md",
|
|
39
40
|
"LICENSE",
|
|
40
41
|
"DESIGN.md",
|
|
41
42
|
"scripts/"
|
package/src/acp.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { ensureProfile } from './bootstrap.mjs';
|
|
14
|
-
import { DSH_PROFILE, REQUEST_TIMEOUT_MS, resolveDshBin } from './config.mjs';
|
|
14
|
+
import { DSH_PROFILE, PKG_VERSION, REQUEST_TIMEOUT_MS, resolveDshBin } from './config.mjs';
|
|
15
15
|
|
|
16
16
|
/** ACP 协议版本(@agentclientprotocol/sdk 的 v1)。 */
|
|
17
17
|
const PROTOCOL_VERSION = 1;
|
|
@@ -83,6 +83,22 @@ export class AcpProcess {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
this.#child.stdout.on('data', (d) => this.#onData(d));
|
|
86
|
+
// ★ stdio 的 'error' 必须常驻监听:子进程退出与下一次写入之间存在竞态,
|
|
87
|
+
// 未监听的 EPIPE 会以未处理错误终止**整个 MCP 进程**(连带所有会话)。
|
|
88
|
+
const streamError = (e) => {
|
|
89
|
+
this.#log.info(`[acp] 子进程 stdio 错误: ${e.message}`);
|
|
90
|
+
const err = new Error(`DSH 进程 stdio 不可用: ${e.message}`);
|
|
91
|
+
for (const [, p] of this.#pending) {
|
|
92
|
+
clearTimeout(p.timer);
|
|
93
|
+
p.reject(err);
|
|
94
|
+
}
|
|
95
|
+
this.#pending.clear();
|
|
96
|
+
this.#child = null;
|
|
97
|
+
this.initialized = false;
|
|
98
|
+
};
|
|
99
|
+
this.#child.stdin.on('error', streamError);
|
|
100
|
+
this.#child.stdout.on('error', streamError);
|
|
101
|
+
this.#child.stderr.on('error', streamError);
|
|
86
102
|
this.#child.stderr.on('data', (d) => {
|
|
87
103
|
for (const line of d.toString('utf8').split(/\r?\n/)) {
|
|
88
104
|
if (!line.trim()) continue;
|
|
@@ -205,7 +221,12 @@ export class AcpProcess {
|
|
|
205
221
|
}, timeoutMs)
|
|
206
222
|
: null;
|
|
207
223
|
this.#pending.set(id, { resolve, reject, timer });
|
|
208
|
-
this.#write({ jsonrpc: '2.0', id, method, params });
|
|
224
|
+
const written = this.#write({ jsonrpc: '2.0', id, method, params });
|
|
225
|
+
if (!written) {
|
|
226
|
+
if (timer) clearTimeout(timer);
|
|
227
|
+
this.#pending.delete(id);
|
|
228
|
+
reject(new Error(`ACP 写入失败(子进程 stdin 不可写): ${method}`));
|
|
229
|
+
}
|
|
209
230
|
});
|
|
210
231
|
}
|
|
211
232
|
|
|
@@ -227,7 +248,7 @@ export class AcpProcess {
|
|
|
227
248
|
this.#initResult = await this.request('initialize', {
|
|
228
249
|
protocolVersion: PROTOCOL_VERSION,
|
|
229
250
|
clientCapabilities: {},
|
|
230
|
-
clientInfo: { name: 'dsh-any-agent', version:
|
|
251
|
+
clientInfo: { name: 'dsh-any-agent', version: PKG_VERSION },
|
|
231
252
|
});
|
|
232
253
|
this.initialized = true;
|
|
233
254
|
return this.#initResult;
|
package/src/bootstrap.mjs
CHANGED
|
@@ -16,19 +16,15 @@
|
|
|
16
16
|
* 自己的凭据服务在每次请求时解析($DSH_HOME/.credentials.yaml > 继承环境 > .env),
|
|
17
17
|
* 与 GUI 完全同一条路径。
|
|
18
18
|
*
|
|
19
|
-
* 幂等性:用户手改过 profile
|
|
19
|
+
* 幂等性:用户手改过 profile(删掉 managed 标记)就不再覆盖,只报告。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createHash } from 'node:crypto';
|
|
23
|
-
import { createRequire } from 'node:module';
|
|
24
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
-
import {
|
|
26
|
-
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { join } from 'node:path';
|
|
27
25
|
|
|
28
26
|
import { BOOTSTRAP_ENABLED, DSH_HOME, DSH_PROFILE, ROUTE_SOURCE_PROFILE } from './config.mjs';
|
|
29
|
-
|
|
30
|
-
/** server 包根目录(本文件位于 <server>/src/)。 */
|
|
31
|
-
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
27
|
+
import { load as yamlLoad, dump as yamlDump } from './yaml-lite.mjs';
|
|
32
28
|
|
|
33
29
|
/** 与 dsh 内置 acp 模板一致的组合包列表。 */
|
|
34
30
|
const PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'];
|
|
@@ -47,25 +43,37 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
47
43
|
|
|
48
44
|
/** 一眼像密钥的值(防止有人把字面密钥写进 apiKeyEnv)。 */
|
|
49
45
|
const LOOKS_LIKE_SECRET = /^(sk-|ak-|Bearer\s|eyJ)/i;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
46
|
+
/** 任何层级都不复制的凭据字段(providers/models 的嵌套对象同样适用)。 */
|
|
47
|
+
const SECRET_KEY = /^(?:api[_-]?key|apikey|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|credentials?|headers|client[_-]?secret|private[_-]?key|access[_-]?key[_-]?id|oauth|env|environment)$/i;
|
|
48
|
+
|
|
49
|
+
/** 递归剥掉凭据字段;baseURL 里的 userinfo 也一并清掉。 */
|
|
50
|
+
function scrubSecrets(value, key = '') {
|
|
51
|
+
if (SECRET_KEY.test(key)) return undefined;
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
if (key === 'baseURL') {
|
|
54
|
+
try {
|
|
55
|
+
const u = new URL(value);
|
|
56
|
+
if (u.username || u.password) {
|
|
57
|
+
u.username = '';
|
|
58
|
+
u.password = '';
|
|
59
|
+
return u.toString();
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* 不是合法 URL:原样保留,让 DSH 自己报错 */
|
|
63
|
+
}
|
|
66
64
|
}
|
|
65
|
+
return value;
|
|
67
66
|
}
|
|
68
|
-
return
|
|
67
|
+
if (Array.isArray(value)) return value.map((item) => scrubSecrets(item));
|
|
68
|
+
if (value && typeof value === 'object') {
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [k, v] of Object.entries(value)) {
|
|
71
|
+
const clean = scrubSecrets(v, k);
|
|
72
|
+
if (clean !== undefined) out[k] = clean;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/** profile 目录:$DSH_HOME/profiles/<name>。 */
|
|
@@ -96,29 +104,16 @@ function sanitizeProviders(raw) {
|
|
|
96
104
|
const out = {};
|
|
97
105
|
for (const [route, profile] of Object.entries(raw)) {
|
|
98
106
|
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) continue;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
clean[k] = v;
|
|
104
|
-
}
|
|
107
|
+
// 递归剥离:provider 层与 models[] 里的 headers/apiKey/clientSecret 等
|
|
108
|
+
// 都不会被抄进受管 profile(以前只处理顶层,模型级 headers 会漏过去)。
|
|
109
|
+
const clean = scrubSecrets(profile);
|
|
110
|
+
if (!clean || typeof clean !== 'object') continue;
|
|
105
111
|
if (typeof clean.apiKeyEnv === 'string') {
|
|
106
112
|
if (LOOKS_LIKE_SECRET.test(clean.apiKeyEnv) || !ENV_NAME.test(clean.apiKeyEnv)) {
|
|
107
113
|
// 疑似把字面密钥写进了 apiKeyEnv:丢掉引用,而不是把密钥抄进新文件
|
|
108
114
|
delete clean.apiKeyEnv;
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
|
-
if (Array.isArray(clean.models)) {
|
|
112
|
-
clean.models = clean.models.map((m) => {
|
|
113
|
-
if (!m || typeof m !== 'object') return m;
|
|
114
|
-
const copy = {};
|
|
115
|
-
for (const [k, v] of Object.entries(m)) {
|
|
116
|
-
if (k === 'apiKey') continue;
|
|
117
|
-
copy[k] = v;
|
|
118
|
-
}
|
|
119
|
-
return copy;
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
117
|
out[route] = clean;
|
|
123
118
|
}
|
|
124
119
|
return Object.keys(out).length > 0 ? out : null;
|
|
@@ -165,7 +160,7 @@ function sanitizeDeepseek(config) {
|
|
|
165
160
|
}
|
|
166
161
|
|
|
167
162
|
/** 依次从候选来源里找第一份能用的路由。 */
|
|
168
|
-
function findRouting(
|
|
163
|
+
function findRouting() {
|
|
169
164
|
const candidates = ROUTE_SOURCE_PROFILE ? [ROUTE_SOURCE_PROFILE] : DEFAULT_SOURCES;
|
|
170
165
|
const tried = [];
|
|
171
166
|
for (const name of candidates) {
|
|
@@ -179,7 +174,7 @@ function findRouting(yaml) {
|
|
|
179
174
|
}
|
|
180
175
|
let entries;
|
|
181
176
|
try {
|
|
182
|
-
entries =
|
|
177
|
+
entries = yamlLoad(readFileSync(file, 'utf8'));
|
|
183
178
|
} catch (e) {
|
|
184
179
|
tried.push({ source: name, file, reason: '解析失败: ' + e.message });
|
|
185
180
|
continue;
|
|
@@ -257,22 +252,42 @@ function buildEntries(routing) {
|
|
|
257
252
|
// - acp-app 包里把它写死成 deepseek-official/deepseek-v4-flash(会去找 DEEPSEEK_API_KEY);
|
|
258
253
|
// - 若清成 {},ACP 会话就没有初始模型选择,settings 里的 reasoning_effort 也就无处可设。
|
|
259
254
|
// 所以这里显式指向本机自己的默认路由 —— 与 GUI 会话用的是同一个模型。
|
|
255
|
+
if (!defaultModel?.provider || !defaultModel?.model) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
'找到的路由里没有可用的默认 provider/model:请先在 DSH GUI 的 Models 页面\n' +
|
|
258
|
+
' 配置一个带 models 列表的 provider(或在来源 profile 里写 agent-default-model)。',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
260
261
|
entries.push({
|
|
261
262
|
id: 'acp',
|
|
262
263
|
name: '@deepseek-ai/dsh-acp',
|
|
263
|
-
config:
|
|
264
|
+
config: { provider: defaultModel.provider, model: defaultModel.model },
|
|
264
265
|
});
|
|
266
|
+
entries.push({ id: 'agent-default-model', config: defaultModel });
|
|
265
267
|
|
|
266
|
-
|
|
268
|
+
// ★ 与 profile-example 对齐:显式声明三档预设,但**绝不写 defaultPreset**。
|
|
269
|
+
// defaultPreset 会在会话创建时覆盖 DSH_PERMISSION_MODE,导致 read-only 被静默提权
|
|
270
|
+
// (test/permission.mjs 实测抓到过)。不写它时,DSH 按 sandbox+approval 组合推断。
|
|
271
|
+
entries.push({
|
|
272
|
+
id: 'permission',
|
|
273
|
+
name: '@deepseek-ai/dsh-permission-presets',
|
|
274
|
+
config: {
|
|
275
|
+
presets: {
|
|
276
|
+
'read-only': { sandbox: 'read-only', approval: 'ask' },
|
|
277
|
+
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' },
|
|
278
|
+
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
267
282
|
return entries;
|
|
268
283
|
}
|
|
269
284
|
|
|
270
|
-
function renderDocument(
|
|
285
|
+
function renderDocument(entries, source) {
|
|
271
286
|
const where =
|
|
272
287
|
source === '__home__' ? join(DSH_HOME, 'cordis.patch.yml') : 'profile "' + source + '"';
|
|
273
288
|
const header = [
|
|
274
289
|
'# ' + MANAGED_MARKER + ': 本文件由 dsh-any-agent 首次启动时自动生成,可安全删除后重建。',
|
|
275
|
-
'# 也可以手改:手改之后 dsh-any-agent
|
|
290
|
+
'# 也可以手改:手改之后 dsh-any-agent 不会再覆盖它(用开头的 managed 标记判断)。',
|
|
276
291
|
'#',
|
|
277
292
|
'# 这里**不含任何密钥**:路由只声明 apiKeyEnv / baseURL / api / models,',
|
|
278
293
|
'# 真正的凭据由 DSH 在每次请求时从 $DSH_HOME/.credentials.yaml(或环境变量)解析,',
|
|
@@ -281,7 +296,7 @@ function renderDocument(yaml, entries, source) {
|
|
|
281
296
|
'# 路由来源: ' + where,
|
|
282
297
|
'',
|
|
283
298
|
].join('\n');
|
|
284
|
-
return header +
|
|
299
|
+
return header + yamlDump(entries);
|
|
285
300
|
}
|
|
286
301
|
|
|
287
302
|
/**
|
|
@@ -306,18 +321,8 @@ export function ensureProfile({ log } = {}) {
|
|
|
306
321
|
return { ok: false, error: '创建 profile 目录失败: ' + e.message };
|
|
307
322
|
}
|
|
308
323
|
|
|
309
|
-
const yaml = loadYaml();
|
|
310
|
-
if (!yaml) {
|
|
311
|
-
return {
|
|
312
|
-
ok: false,
|
|
313
|
-
error: '未找到 YAML 解析器(js-yaml):无法从本机已有 profile 复制模型路由',
|
|
314
|
-
reason: '请在 server/ 下安装 js-yaml,或使用自包含导出包',
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
324
|
const patchPath = join(dir, 'cordis.patch.yml');
|
|
319
325
|
const existed = existsSync(patchPath);
|
|
320
|
-
const state = readManagedState();
|
|
321
326
|
|
|
322
327
|
/**
|
|
323
328
|
* 归属判断:**只看文件里有没有 MANAGED_MARKER**。
|
|
@@ -338,7 +343,7 @@ export function ensureProfile({ log } = {}) {
|
|
|
338
343
|
return { ok: true, skipped: 'profile 由用户维护(无管理标记),保持原样' };
|
|
339
344
|
}
|
|
340
345
|
|
|
341
|
-
const found = findRouting(
|
|
346
|
+
const found = findRouting();
|
|
342
347
|
if (!found.routing) {
|
|
343
348
|
if (existed) return { ok: true, skipped: '本机没有可复制的模型路由,且 profile 已存在' };
|
|
344
349
|
return {
|
|
@@ -349,8 +354,13 @@ export function ensureProfile({ log } = {}) {
|
|
|
349
354
|
};
|
|
350
355
|
}
|
|
351
356
|
|
|
352
|
-
|
|
353
|
-
|
|
357
|
+
let entries;
|
|
358
|
+
try {
|
|
359
|
+
entries = buildEntries(found.routing);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
return { ok: false, error: e.message, reason: '未写入 profile' };
|
|
362
|
+
}
|
|
363
|
+
const text = renderDocument(entries, found.source);
|
|
354
364
|
const hash = sha256(text);
|
|
355
365
|
|
|
356
366
|
// 与磁盘上的实际内容比对(不用 state.hash:DSH 自己也会改写这个文件)
|
|
@@ -401,12 +411,11 @@ export function inspectProfile() {
|
|
|
401
411
|
const dir = profileDir();
|
|
402
412
|
const patchPath = join(dir, 'cordis.patch.yml');
|
|
403
413
|
const state = readManagedState();
|
|
404
|
-
const yaml = loadYaml();
|
|
405
414
|
let routes = [];
|
|
406
415
|
let agentDefaultModel = null;
|
|
407
|
-
if (
|
|
416
|
+
if (existsSync(patchPath)) {
|
|
408
417
|
try {
|
|
409
|
-
const routing = extractRouting(
|
|
418
|
+
const routing = extractRouting(yamlLoad(readFileSync(patchPath, 'utf8')));
|
|
410
419
|
routes = Object.keys(routing?.providers || {});
|
|
411
420
|
agentDefaultModel = routing?.agentDefaultModel || null;
|
|
412
421
|
} catch {
|
package/src/clients.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from 'node:fs';
|
|
33
33
|
import { platform } from 'node:os';
|
|
34
34
|
import { dirname } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
35
36
|
|
|
36
37
|
/** 写进每个客户端的条目名。同时也是 --remove 唯一的识别依据。 */
|
|
37
38
|
export const ENTRY_NAME = 'dsh-any-agent';
|
|
@@ -652,7 +653,7 @@ export function existingShapeMode(client, file) {
|
|
|
652
653
|
export function clientList(dirs) {
|
|
653
654
|
const { home, appData } = dirs;
|
|
654
655
|
return [
|
|
655
|
-
|
|
656
|
+
jsonClient('claude-code', 'Claude Code', 'vscode', 'mcpServers', () => join(home, '.claude.json')),
|
|
656
657
|
jsonClient(
|
|
657
658
|
'claude-desktop', 'Claude Desktop', 'default', 'mcpServers',
|
|
658
659
|
() => IS_WIN || IS_MAC
|
|
@@ -684,7 +685,9 @@ export function isPresent(client, dirs) {
|
|
|
684
685
|
|
|
685
686
|
export function defaultLaunch() {
|
|
686
687
|
// 与本模块相邻的 bin/dsh-any-agent.mjs 优先;否则退回 PATH 上的命令。
|
|
687
|
-
|
|
688
|
+
// ★ 必须用 fileURLToPath:new URL(...).pathname 会把路径里的空格/中文变成
|
|
689
|
+
// %20 / 百分号编码,existsSync 失败后静默退回 PATH 命令,客户端随后连接失败。
|
|
690
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
688
691
|
const sibling = join(here, '..', 'bin', 'dsh-any-agent.mjs');
|
|
689
692
|
if (existsSync(sibling)) return { command: process.execPath, args: [sibling], style: 'node + sibling entry' };
|
|
690
693
|
return { command: ENTRY_NAME, args: [], style: 'PATH command' };
|
|
@@ -866,24 +869,16 @@ export function applyClient(plan, ctx, stamp) {
|
|
|
866
869
|
if (originalMode !== null) {
|
|
867
870
|
try { chmodSync(plan.path, originalMode); } catch { /* 尽力而为 */ }
|
|
868
871
|
}
|
|
869
|
-
|
|
872
|
+
// ★ 不再调用客户端 CLI 做验证。
|
|
873
|
+
// Claude Code 的 `claude mcp get` 会让客户端**整体回写** ~/.claude.json
|
|
874
|
+
// (实测会丢掉 projects[*].history、往顶层塞十几个键)。我们只承诺
|
|
875
|
+
// "只改自己那一条",因此验证只报告我们写入的字节与备份路径,不碰客户端。
|
|
876
|
+
return {
|
|
870
877
|
...plan,
|
|
871
878
|
status: remove ? 'removed' : 'applied',
|
|
872
879
|
summary: (remove ? 'entry removed' : 'entry written') + (originalMode !== null ? ' (read-only file overridden by --force)' : ''),
|
|
873
880
|
backup,
|
|
874
881
|
};
|
|
875
|
-
// 有的客户端自己维护这份文件(Claude Code 会缓存并回写 ~/.claude.json)。
|
|
876
|
-
// 有 CLI 时用它做一次只读确认,除了状态行不产生任何副作用。
|
|
877
|
-
if (client.verifyCli && which(client.verifyCli)) {
|
|
878
|
-
const probe = spawnSync(client.verifyCli, ['mcp', 'get', ENTRY_NAME], {
|
|
879
|
-
encoding: 'utf8', shell: IS_WIN, windowsHide: true,
|
|
880
|
-
});
|
|
881
|
-
const registered = probe.status === 0;
|
|
882
|
-
base.summary += registered
|
|
883
|
-
? ' (confirmed by ' + client.verifyCli + ' mcp get)'
|
|
884
|
-
: ' (note: ' + client.verifyCli + ' mcp get does not see it yet; restart the client)';
|
|
885
|
-
}
|
|
886
|
-
return base;
|
|
887
882
|
} catch (e) {
|
|
888
883
|
if (originalMode !== null) {
|
|
889
884
|
try { chmodSync(plan.path, originalMode); } catch { /* 尽力而为 */ }
|
package/src/config.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 因为本服务是被 Codex/Claude 以未知环境拉起的。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { join, dirname } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
@@ -13,6 +13,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
export const PKG_ROOT = join(HERE, '..');
|
|
15
15
|
|
|
16
|
+
/** 包版本(与 package.json 单一来源;ACP clientInfo 也用它,避免硬编码漂移)。 */
|
|
17
|
+
export const PKG_VERSION = (() => {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
20
|
+
} catch {
|
|
21
|
+
return '0.0.0';
|
|
22
|
+
}
|
|
23
|
+
})();
|
|
24
|
+
|
|
16
25
|
/** 相对 DSH 包入口的固定尾巴。 */
|
|
17
26
|
const DSH_ENTRY = join('@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
18
27
|
|
|
@@ -105,9 +114,45 @@ export const ROUTE_SOURCE_PROFILE = (() => {
|
|
|
105
114
|
return v === undefined || String(v).trim() === '' ? null : String(v).trim();
|
|
106
115
|
})();
|
|
107
116
|
|
|
108
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* 会话注册表(跨 MCP 服务重启存活)。
|
|
119
|
+
*
|
|
120
|
+
* ★ 默认放在 **$DSH_HOME/.dsh-any-agent/**,不再放在包安装目录里。
|
|
121
|
+
* 全局安装到只读位置(sudo / Program Files / 只读挂载)时,包目录不可写,
|
|
122
|
+
* 注册表、哨兵、锁登记会全部静默失败 —— 调用方等哨兵会永远等不到。
|
|
123
|
+
* DSH_HOME 本身就是用户目录,天然可写,且与 DSH 自己的数据放在一起。
|
|
124
|
+
*
|
|
125
|
+
* 兼容:旧版本的 `<包目录>/.state/conversations.json` 会在首次启动时复制过来
|
|
126
|
+
* (只在没有显式设置 DSH_MCP_STATE 时做,见 LEGACY_STATE_FILE / USING_DEFAULT_STATE)。
|
|
127
|
+
*/
|
|
128
|
+
const DEFAULT_STATE_DIR = join(DSH_HOME, '.dsh-any-agent');
|
|
109
129
|
export const STATE_FILE =
|
|
110
|
-
process.env.DSH_MCP_STATE ?? join(
|
|
130
|
+
process.env.DSH_MCP_STATE ?? join(DEFAULT_STATE_DIR, 'conversations.json');
|
|
131
|
+
/** 旧默认位置;仅用于一次性迁移。 */
|
|
132
|
+
export const LEGACY_STATE_FILE = join(PKG_ROOT, '.state', 'conversations.json');
|
|
133
|
+
/** 是否仍在使用默认状态路径(显式设置 DSH_MCP_STATE 时不做自动迁移)。 */
|
|
134
|
+
export const USING_DEFAULT_STATE = !process.env.DSH_MCP_STATE;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 确认一个目录可写;不可写就抛出可直接展示给用户的错误。
|
|
138
|
+
*
|
|
139
|
+
* 在 Hub.load() 启动时调用:宁可服务明确起不来,也不要"看起来正常、
|
|
140
|
+
* 哨兵永远不出现、锁也无法归因"的静默降级。
|
|
141
|
+
*/
|
|
142
|
+
export function assertWritableDir(dir, label = '目录') {
|
|
143
|
+
try {
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const probe = join(dir, `.write-probe-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
146
|
+
writeFileSync(probe, 'ok', 'utf8');
|
|
147
|
+
rmSync(probe, { force: true });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${label}不可写: ${dir}\n` +
|
|
151
|
+
` ${e.message}\n` +
|
|
152
|
+
' 请用 DSH_MCP_STATE / DSH_MCP_RUNS_DIR / DSH_MCP_LOCKS_DIR 指向可写目录。',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
111
156
|
|
|
112
157
|
/**
|
|
113
158
|
* 异步派活"完成哨兵"文件的根目录。每个 run 终止时在这里写一个独立小文件,
|
|
@@ -134,9 +179,9 @@ export const SENTINEL_INCLUDE_REASONING = process.env.DSH_MCP_SENTINEL_INCLUDE_R
|
|
|
134
179
|
*
|
|
135
180
|
* 为什么要清理:哨兵内含完整正文,长期不清理会无声地堆积占用磁盘。
|
|
136
181
|
*/
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
);
|
|
182
|
+
const rawSentinelTtl = numEnv('DSH_MCP_SENTINEL_TTL_MS', 7 * 24 * 60 * 60 * 1000);
|
|
183
|
+
/** ★ 负数/NaN 一律当作 0(关闭清理),绝不能把 cutoff 推到未来删光全部哨兵。 */
|
|
184
|
+
export const SENTINEL_TTL_MS = Number.isFinite(rawSentinelTtl) && rawSentinelTtl > 0 ? rawSentinelTtl : 0;
|
|
140
185
|
|
|
141
186
|
/**
|
|
142
187
|
* 把 Windows 路径转成 Bash / MSYS 形式(`D:\a\b` → `/d/a/b`),非 Windows 原样返回。
|
|
@@ -234,7 +279,10 @@ export const LIST_PROBE_TTL_MS = numEnv('DSH_MCP_LIST_PROBE_TTL_MS', 10_000);
|
|
|
234
279
|
|
|
235
280
|
/** 权限档 → 该档下的默认审批策略。 */
|
|
236
281
|
export const PERMISSION_TIERS = ['read-only', 'workspace-write', 'danger-full-access'];
|
|
237
|
-
|
|
282
|
+
/** 空串/非法值一律回落到 danger-full-access(保持旧默认),而不是让 Hub.create 每次都失败。 */
|
|
283
|
+
export const DEFAULT_PERMISSION = PERMISSION_TIERS.includes(process.env.DSH_MCP_PERMISSION)
|
|
284
|
+
? process.env.DSH_MCP_PERMISSION
|
|
285
|
+
: 'danger-full-access';
|
|
238
286
|
|
|
239
287
|
/**
|
|
240
288
|
* 审批处理策略:
|
|
@@ -260,7 +308,10 @@ export const DEFAULT_REASONING = 'hide';
|
|
|
260
308
|
* ACP 默认值是空串(= Provider default),不是我们想要的;默认显式设成 max。
|
|
261
309
|
*/
|
|
262
310
|
export const REASONING_EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
263
|
-
|
|
311
|
+
/** 空串/非法值回落到 max;否则 Hub.create 的合法性校验会让**所有** dsh_start 失败。 */
|
|
312
|
+
export const DEFAULT_REASONING_EFFORT = REASONING_EFFORTS.includes(process.env.DSH_MCP_REASONING_EFFORT)
|
|
313
|
+
? process.env.DSH_MCP_REASONING_EFFORT
|
|
314
|
+
: 'max';
|
|
264
315
|
|
|
265
316
|
/**
|
|
266
317
|
* 工作区登记模式:
|