dsh-any-agent 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.mjs CHANGED
@@ -42,15 +42,60 @@ function findInNpxCache() {
42
42
  } catch {
43
43
  continue;
44
44
  }
45
+ const found = [];
45
46
  for (const e of entries) {
46
47
  if (!e.isDirectory()) continue;
47
48
  const p = join(root, e.name, 'node_modules', DSH_ENTRY);
48
- if (existsSync(p)) return p;
49
+ if (!existsSync(p)) continue;
50
+ let version = '0.0.0';
51
+ try {
52
+ version =
53
+ JSON.parse(readFileSync(join(dirname(p), '..', 'package.json'), 'utf8')).version ?? version;
54
+ } catch {
55
+ // 版本读不到就当 0.0.0,仍可作为候选
56
+ }
57
+ found.push({ p, version });
58
+ }
59
+ if (found.length) {
60
+ // ★ 取**最高版本**,不是目录枚举顺序。本机可以同时缓存 0.1.5 和 0.1.7,
61
+ // 按目录顺序会随机选中旧版,导致实验性能力(如 Agent Teams)根本加载不到。
62
+ found.sort((a, b) => cmpVersion(b.version, a.version));
63
+ return found[0].p;
49
64
  }
50
65
  }
51
66
  return undefined;
52
67
  }
53
68
 
69
+ /** 极简 semver 比较(够用于 npx 缓存里挑最新);返回 a-b 的符号。 */
70
+ export function cmpVersion(a, b) {
71
+ const [ac, ap] = String(a).split('-', 2);
72
+ const [bc, bp] = String(b).split('-', 2);
73
+ const an = ac.split('.').map(Number);
74
+ const bn = bc.split('.').map(Number);
75
+ for (let i = 0; i < 3; i++) {
76
+ const d = (an[i] || 0) - (bn[i] || 0);
77
+ if (d) return d;
78
+ }
79
+ if (!ap && bp) return 1; // release > prerelease
80
+ if (ap && !bp) return -1;
81
+ if (ap && bp) {
82
+ const ar = ap.split('.');
83
+ const br = bp.split('.');
84
+ for (let i = 0; i < Math.max(ar.length, br.length); i++) {
85
+ const x = ar[i] ?? '';
86
+ const y = br[i] ?? '';
87
+ const xn = x !== '' && Number.isFinite(Number(x));
88
+ const yn = y !== '' && Number.isFinite(Number(y));
89
+ if (xn && yn) {
90
+ if (Number(x) !== Number(y)) return Number(x) - Number(y);
91
+ } else if (xn) return -1;
92
+ else if (yn) return 1;
93
+ else if (x !== y) return x < y ? -1 : 1;
94
+ }
95
+ }
96
+ return 0;
97
+ }
98
+
54
99
  /**
55
100
  * 解析 DSH CLI 入口。按顺序尝试:
56
101
  * 1) DSH_BIN 环境变量(最可靠,推荐显式设置)
@@ -83,9 +128,6 @@ export function resolveDshBin() {
83
128
  );
84
129
  }
85
130
 
86
- /** 用于驱动 DSH 的 profile(由 bootstrap 在首启时按 acp 模板创建)。 */
87
- export const DSH_PROFILE = process.env.DSH_MCP_PROFILE ?? 'dsh-mcp';
88
-
89
131
  /**
90
132
  * DSH 主目录。**默认就是本机用户自己的 ~/.dsh** —— 本服务不拥有独立的 home,
91
133
  * 因此凭据(.credentials.yaml)、会话历史、工作区登记与 GUI 完全共用一份。
@@ -95,6 +137,80 @@ export const DSH_PROFILE = process.env.DSH_MCP_PROFILE ?? 'dsh-mcp';
95
137
  */
96
138
  export const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh');
97
139
 
140
+ /**
141
+ * 团队开关(0.5.0 起默认关闭,按需开启)。
142
+ *
143
+ * 优先级:`DSH_MCP_TEAM` 环境变量 > `<DSH_HOME>/.dsh-any-agent/settings.json`
144
+ * 的 `team` 字段 > 默认关闭。用 `dsh-any-agent team on|off` 写这个文件,
145
+ * 不必手改客户端配置。
146
+ */
147
+ export const TEAM_SETTINGS_FILE =
148
+ process.env.DSH_MCP_SETTINGS ?? join(DSH_HOME, '.dsh-any-agent', 'settings.json');
149
+
150
+ function readTeamSetting() {
151
+ try {
152
+ const j = JSON.parse(readFileSync(TEAM_SETTINGS_FILE, 'utf8'));
153
+ return typeof j.team === 'boolean' ? j.team : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ export const TEAM_MODE_SOURCE = (() => {
160
+ const v = process.env.DSH_MCP_TEAM;
161
+ if (v !== undefined && String(v).trim() !== '') return 'env';
162
+ return readTeamSetting() === null ? 'default' : 'file';
163
+ })();
164
+
165
+ export const TEAM_MODE = (() => {
166
+ const v = process.env.DSH_MCP_TEAM;
167
+ if (v !== undefined && String(v).trim() !== '') {
168
+ return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
169
+ }
170
+ return readTeamSetting() ?? false;
171
+ })();
172
+
173
+ /** 运行中的 DSH 版本(读不到为 null)。 */
174
+ export const DSH_VERSION = (() => {
175
+ try {
176
+ const bin = resolveDshBin();
177
+ return (
178
+ JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null
179
+ );
180
+ } catch {
181
+ return null;
182
+ }
183
+ })();
184
+
185
+ // 注意用 `0.1.7-0` 而不是 `0.1.7`:semver 里 `0.1.7-rc.1 < 0.1.7`,
186
+ // 但 team 包正是随 0.1.7-rc.1 发布的。
187
+ const teamVersionOk = DSH_VERSION === null || cmpVersion(DSH_VERSION, '0.1.7-0') >= 0;
188
+
189
+ /** 用于驱动 DSH 的 profile(由 bootstrap 在首启时按 acp 模板创建)。 */
190
+ export const DSH_PROFILE =
191
+ process.env.DSH_MCP_PROFILE ?? (TEAM_MODE && teamVersionOk ? 'dsh-team' : 'dsh-mcp');
192
+
193
+ /**
194
+ * 团队能力是否真正可用:
195
+ * - 用户没有关(TEAM_MODE);
196
+ * - DSH >= 0.1.7(team 包从 0.1.7 才有);
197
+ * - **profile 里真的含 team 包**(显式指定了不含 team 的 profile 时不注入,
198
+ * 否则模型会看到一个不存在的工具)。
199
+ * profile 还不存在时(首启)交给 bootstrap 创建,视为可用。
200
+ */
201
+ const profileTeamText = (() => {
202
+ try {
203
+ return readFileSync(join(DSH_HOME, 'profiles', DSH_PROFILE, 'cordis.patch.yml'), 'utf8');
204
+ } catch {
205
+ return null;
206
+ }
207
+ })();
208
+
209
+ export const TEAM_ACTIVE =
210
+ TEAM_MODE &&
211
+ teamVersionOk &&
212
+ (profileTeamText === null || profileTeamText.includes('@deepseek-ai/dsh-experimental-agent-team'));
213
+
98
214
  /**
99
215
  * 是否允许 bootstrap 自动创建/同步 DSH profile(默认开)。
100
216
  * 关掉(DSH_MCP_BOOTSTRAP=0)后本服务只驱动已存在的 profile,不再写任何文件。
@@ -240,7 +356,10 @@ export function numEnv(name, fallback) {
240
356
  return Number.isFinite(n) ? n : fallback;
241
357
  }
242
358
 
243
- const rawIdle = numEnv('DSH_MCP_IDLE_TTL_MS', 0);
359
+ // 0.4.0:默认从"永不回收"改为 10 分钟。
360
+ // 代价是锁可能空出来被 GUI 拿走;收益是并发一高不会把内存堆到几 GB。
361
+ // 需要"永远握住写锁"的会话用 dsh_start(keep_alive=true) 显式保活。
362
+ const rawIdle = numEnv('DSH_MCP_IDLE_TTL_MS', 10 * 60 * 1000);
244
363
  /** ★ `<= 0` 或非法值一律当作"永不回收"。
245
364
  * 否则 `-1` 会被算成 `cutoff = now + 1` → **立刻回收**(谁会想到负数=马上收 ✗),
246
365
  * `NaN` 更糟:比较永远为假,行为取决于具体分支。宁可把"奇怪的输入"统一压到安全侧。 */
@@ -261,13 +380,54 @@ export const REAP_INTERVAL_MS = Number.isFinite(rawReap) && rawReap >= 200 ? raw
261
380
  * - 窗口外的:进程回收、内存释放 ✓(下次派活自动 resume,无损 ✓)
262
381
  * 即"**内存有界,锁尽量在手**"。
263
382
  *
264
- * **默认 8**(按实测 300-480MB/个,最坏约 2.5-4GB):让"内存有界"成为默认行为,而不是要用户先发现再配 ✗。
383
+ * **默认 4**(按实测 300-480MB/个,最坏约 1.2-1.9GB):让"内存有界"成为默认行为。
265
384
  * 被回收是无损的(会话日志在磁盘上 ✓),代价只是那个会话的写锁会空出来、
266
385
  * 且下次派活要 cold start。想做更多并行就调大;机器大也可以设 0(不限)。
386
+ * `keep_alive=true` 的会话不受本上限约束(由调用方自己负责内存)。
267
387
  */
268
- const rawMaxLive = numEnv('DSH_MCP_MAX_LIVE', 8);
388
+ const rawMaxLive = numEnv('DSH_MCP_MAX_LIVE', 4);
269
389
  export const MAX_LIVE = Number.isFinite(rawMaxLive) && rawMaxLive > 0 ? Math.floor(rawMaxLive) : 0;
270
390
 
391
+ /**
392
+ * 全局子进程内存预算(MB)。`0` = 关闭。
393
+ *
394
+ * 超过预算时按"最久未用"回收空闲会话,直到回到预算内。
395
+ * 与 `MAX_LIVE` 的区别:那个按**会话数**,这个按**实际 RSS**——
396
+ * 会话的上下文大小差异很大,按个数限制经常低估。
397
+ * 读 RSS 失败时静默跳过(不能因为量不到就把会话全收了)。
398
+ */
399
+ const rawMaxRss = numEnv('DSH_MCP_MAX_RSS_MB', 0);
400
+ export const MAX_RSS_MB = Number.isFinite(rawMaxRss) && rawMaxRss > 0 ? Math.floor(rawMaxRss) : 0;
401
+
402
+ /**
403
+ * 给每个 DSH 子进程的 V8 老生代设上限(MB)。`0` = 不设。
404
+ *
405
+ * 只是兜底:DSH 正常不会吃满默认堆,但失控的上下文可以把 RSS 顶到很高。
406
+ * 设太小会让长会话 OOM,所以默认关闭、由用户按机器情况调。
407
+ */
408
+ const rawChildHeap = numEnv('DSH_MCP_CHILD_MAX_OLD_SPACE_MB', 0);
409
+ export const CHILD_MAX_OLD_SPACE_MB =
410
+ Number.isFinite(rawChildHeap) && rawChildHeap > 0 ? Math.floor(rawChildHeap) : 0;
411
+
412
+ /**
413
+ * 同一 `(cwd, permission)` 的多个会话共享一个 ACP 进程(0.4.0 引入,**0.5.0 起默认开启**)。
414
+ *
415
+ * 为什么默认开:DSH 的 ACP 服务端支持一条连接上多会话,且每个 session 有独立 Agent。
416
+ * 实测(DSH 0.1.7):两个会话 → 一个 PID,第二个 `session/new` 约 300ms,独立进程约 4s;
417
+ * 内存也从"每会话 300-480MB"降到"每个 (cwd, 权限) 组一份"。
418
+ *
419
+ * 为什么仍然分组:`sandbox-policy.workspaceRoot` / `mode` 与 `approval.policy` 在
420
+ * `dsh-base` 里是**进程级**配置,不同工作区/权限档混进一个进程会让沙箱边界失真 ——
421
+ * 那两种情况仍各起一个进程(硬约束,不是实现偷懒)。
422
+ *
423
+ * 代价(已写进文档,别当它不存在):共享进程崩溃会连坐同组的**所有**会话。崩溃时统一写
424
+ * error 哨兵并唤醒等待者,下次派活各自 resume —— 会话本体无损,但进行中的回合会断。
425
+ * 所以 `dsh_takeover` 在杀一个还挂着其它会话的共享进程前,会要求显式 `force=true`。
426
+ *
427
+ * 设 `DSH_MCP_SHARED_PROCESS=0` 回到"一会话一进程"(拿内存换隔离性)。
428
+ */
429
+ export const SHARED_PROCESS = numEnv('DSH_MCP_SHARED_PROCESS', 1) === 1;
430
+
271
431
  /**
272
432
  * `dsh_list` 的"磁盘探测"结果缓存多久(毫秒)。
273
433
  *
@@ -279,10 +439,19 @@ export const LIST_PROBE_TTL_MS = numEnv('DSH_MCP_LIST_PROBE_TTL_MS', 10_000);
279
439
 
280
440
  /** 权限档 → 该档下的默认审批策略。 */
281
441
  export const PERMISSION_TIERS = ['read-only', 'workspace-write', 'danger-full-access'];
282
- /** 空串/非法值一律回落到 danger-full-access(保持旧默认),而不是让 Hub.create 每次都失败。 */
442
+ /**
443
+ * 新建会话的默认权限档。0.5.0 起默认 **workspace-write**(沙箱把写入限制在工作区内),
444
+ * 不再是 danger-full-access;空串/非法值也回落到它。
445
+ *
446
+ * `danger-full-access` 需要机器侧显式允许(`DSH_MCP_ALLOW_FULL_ACCESS=1`),
447
+ * 避免"任何 MCP 客户端都能驱动"的服务默认把整机写权限交出去。
448
+ */
283
449
  export const DEFAULT_PERMISSION = PERMISSION_TIERS.includes(process.env.DSH_MCP_PERMISSION)
284
450
  ? process.env.DSH_MCP_PERMISSION
285
- : 'danger-full-access';
451
+ : 'workspace-write';
452
+
453
+ /** 是否允许 `permission=danger-full-access`(默认关)。 */
454
+ export const ALLOW_FULL_ACCESS = process.env.DSH_MCP_ALLOW_FULL_ACCESS === '1';
286
455
 
287
456
  /**
288
457
  * 审批处理策略:
package/src/host.mjs ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * 共享 ACP 进程宿主(0.4.0 引入,0.5.0 起默认开启;`DSH_MCP_SHARED_PROCESS=0` 关闭)。
3
+ *
4
+ * DSH 的 ACP 服务端支持一条连接上多会话,每个 session 有自己的 Agent 与上下文。
5
+ * 但 `dsh-base` 的 `sandbox-policy.workspaceRoot` / `mode` 与 `approval.policy`
6
+ * 都是**进程级**配置,所以只能按 `(cwd, permission)` 分组共享:
7
+ * - 同一工作区、同一权限档的会话可以共用一个 DSH 进程;
8
+ * - 不同工作区/权限档仍然各起一个进程(这是硬约束,不是实现偷懒)。
9
+ *
10
+ * 代价:共享进程崩溃会连坐同组的所有会话。`onExit` 会把它们统一标成失败、
11
+ * 写 error 哨兵并唤醒等待者;下次派活各自重新 resume。
12
+ */
13
+
14
+ import { AcpProcess } from './acp.mjs';
15
+
16
+ const now = () => Date.now();
17
+
18
+ export class SessionHost {
19
+ constructor({ cwd, permission, log, hub, AcpProcessClass = AcpProcess }) {
20
+ this.cwd = cwd;
21
+ this.permission = permission;
22
+ this.log = log;
23
+ this.hub = hub;
24
+ this.AcpProcessClass = AcpProcessClass;
25
+ this.proc = null;
26
+ this.initializing = null;
27
+ this.stopping = null;
28
+ /** sessionId → Conversation */
29
+ this.conversations = new Map();
30
+ this.lastUsedAt = now();
31
+ }
32
+
33
+ get alive() {
34
+ return !!this.proc?.alive;
35
+ }
36
+
37
+ get pid() {
38
+ return this.proc?.pid ?? null;
39
+ }
40
+
41
+ get stderrTail() {
42
+ return this.proc?.stderrTail ?? '';
43
+ }
44
+
45
+ get size() {
46
+ return this.conversations.size;
47
+ }
48
+
49
+ has(id) {
50
+ return this.conversations.has(id);
51
+ }
52
+
53
+ attach(conv) {
54
+ this.conversations.set(conv.id, conv);
55
+ this.lastUsedAt = now();
56
+ }
57
+
58
+ /** 拉起并完成 ACP 握手;并发调用共享同一个 in-flight promise。 */
59
+ async initialize() {
60
+ if (this.proc?.alive) return;
61
+ if (this.initializing) return this.initializing;
62
+ this.initializing = (async () => {
63
+ const proc = new this.AcpProcessClass({
64
+ cwd: this.cwd,
65
+ permission: this.permission,
66
+ log: this.log,
67
+ onNotification: (params) => {
68
+ const conv = this.conversations.get(params.sessionId);
69
+ if (conv) conv.onUpdate(params);
70
+ else this.log?.debug?.(`[host] 丢弃未知会话的通知: ${params.sessionId}`);
71
+ },
72
+ onPermissionRequest: (params) => {
73
+ const conv = this.conversations.get(params.sessionId);
74
+ // 找不到会话时必须拒绝,绝不能自动放行。
75
+ if (!conv) return { outcome: { outcome: 'cancelled' } };
76
+ return conv.onPermissionRequest(params);
77
+ },
78
+ onExit: (reason) => this.#onExit(reason),
79
+ });
80
+ proc.start();
81
+ this.proc = proc;
82
+ await proc.initialize();
83
+ })().finally(() => {
84
+ this.initializing = null;
85
+ });
86
+ return this.initializing;
87
+ }
88
+
89
+ #onExit(reason) {
90
+ const convs = [...this.conversations.values()];
91
+ this.conversations.clear();
92
+ this.proc = null;
93
+ for (const conv of convs) {
94
+ try {
95
+ conv.onProcessExit?.(reason);
96
+ } catch (e) {
97
+ this.log?.info?.(`[host] 处理会话 ${conv.id} 的进程退出失败: ${e.message}`);
98
+ }
99
+ }
100
+ this.hub?.forgetHost?.(this);
101
+ }
102
+
103
+ request(method, params, timeoutMs) {
104
+ if (!this.proc) return Promise.reject(new Error('共享 DSH 进程未运行'));
105
+ return this.proc.request(method, params, timeoutMs);
106
+ }
107
+
108
+ notify(method, params) {
109
+ return this.proc?.notify(method, params) ?? false;
110
+ }
111
+
112
+ /**
113
+ * 让一个会话离开宿主。默认顺手 `session/close` 释放该会话的 Agent 与写锁;
114
+ * 最后一个会话离开时停掉进程。
115
+ */
116
+ async detach(conv, { closeSession = true } = {}) {
117
+ if (closeSession && this.proc?.alive && this.conversations.has(conv.id)) {
118
+ try {
119
+ await this.request('session/close', { sessionId: conv.id }, 10_000);
120
+ } catch {
121
+ // 已经关闭/进程正在退出都无所谓
122
+ }
123
+ }
124
+ this.conversations.delete(conv.id);
125
+ if (this.conversations.size === 0) return this.stop();
126
+ return true;
127
+ }
128
+
129
+ /** 只有在没有会话附加时才真正停进程。 */
130
+ async stop() {
131
+ if (this.conversations.size > 0) return true;
132
+ if (this.stopping) return this.stopping;
133
+ const proc = this.proc;
134
+ this.proc = null;
135
+ if (!proc) {
136
+ this.hub?.forgetHost?.(this);
137
+ return true;
138
+ }
139
+ this.stopping = proc
140
+ .stop()
141
+ .catch(() => false)
142
+ .finally(() => {
143
+ this.stopping = null;
144
+ this.hub?.forgetHost?.(this);
145
+ });
146
+ return this.stopping;
147
+ }
148
+ }