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.
@@ -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 在系统临时目录下" 或 "cwd 在 Desktop\dsh-any-agent-test-*" 的会话,外加 --ids 显式指定的。
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
- const list = listWorkspaces();
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))) {
@@ -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 会话注册表路径(默认 <仓库>/.state/conversations.json)
8
+ * DSH_MCP_STATE 会话注册表路径(默认 $DSH_HOME/.dsh-any-agent/conversations.json)
9
9
  * DSH_MCP_PERMISSION 新建会话的默认权限档(默认 danger-full-access)
10
- * DSH_MCP_IDLE_TTL_MS 空闲多久回收会话进程(默认 5 分钟)
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",
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: '0.1.0' },
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 { dirname, join } from 'node:path';
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
- * 载入 YAML 解析器。自包含导出包从兄弟 runtime 目录取(包内没有 node_modules),
53
- * 普通 npm 安装走正常的模块解析。
54
- */
55
- function loadYaml() {
56
- const anchors = [
57
- join(PKG_ROOT, 'src', 'anchor.mjs'),
58
- join(PKG_ROOT, '..', 'runtime', 'node_modules', 'anchor.cjs'),
59
- join(PKG_ROOT, '..', '..', 'runtime', 'node_modules', 'anchor.cjs'),
60
- ];
61
- for (const anchor of anchors) {
62
- try {
63
- return createRequire(anchor)('js-yaml');
64
- } catch {
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 null;
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
- const clean = {};
100
- for (const [k, v] of Object.entries(profile)) {
101
- // apiKey 是适配器从不读取的字段;headers 里可能夹部署凭据。两者都不复制。
102
- if (k === 'apiKey' || k === 'headers') continue;
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(yaml) {
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 = yaml.load(readFileSync(file, 'utf8'));
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: defaultModel ? { provider: defaultModel.provider, model: defaultModel.model } : {},
264
+ config: { provider: defaultModel.provider, model: defaultModel.model },
264
265
  });
266
+ entries.push({ id: 'agent-default-model', config: defaultModel });
265
267
 
266
- if (defaultModel) entries.push({ id: 'agent-default-model', config: defaultModel });
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(yaml, entries, source) {
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 + yaml.dump(entries, { lineWidth: 200, noRefs: true });
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(yaml);
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
- const entries = buildEntries(found.routing);
353
- const text = renderDocument(yaml, entries, found.source);
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 (yaml && existsSync(patchPath)) {
416
+ if (existsSync(patchPath)) {
408
417
  try {
409
- const routing = extractRouting(yaml.load(readFileSync(patchPath, 'utf8')));
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
- { ...jsonClient('claude-code', 'Claude Code', 'vscode', 'mcpServers', () => join(home, '.claude.json')), verifyCli: 'claude' },
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
- const here = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
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
- const base = {
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
- /** 会话注册表(跨 MCP 服务重启存活)。 */
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(PKG_ROOT, '.state', 'conversations.json');
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
- export const SENTINEL_TTL_MS = Number(
138
- process.env.DSH_MCP_SENTINEL_TTL_MS ?? 7 * 24 * 60 * 60 * 1000,
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
- export const DEFAULT_PERMISSION = process.env.DSH_MCP_PERMISSION ?? 'danger-full-access';
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
- export const DEFAULT_REASONING_EFFORT = process.env.DSH_MCP_REASONING_EFFORT ?? 'max';
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
  * 工作区登记模式: