dsh-any-agent 0.3.9 → 0.4.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,8 +128,35 @@ 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';
131
+ /**
132
+ * 团队模式(0.4.0 默认开启)。
133
+ *
134
+ * - 默认 profile 用 `dsh-team`(自动创建,含实验性 Agent Teams);
135
+ * - `dsh_send` 默认在 prompt 前注入一句"需要并行/多角色时先建团队";
136
+ * - 关闭:`DSH_MCP_TEAM=0`(回到 `dsh-mcp` profile、不注入);
137
+ * 单次关闭:`dsh_send(team=false)`。
138
+ */
139
+ export const TEAM_MODE = (() => {
140
+ const v = process.env.DSH_MCP_TEAM;
141
+ if (v === undefined || String(v).trim() === '') return true;
142
+ return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
143
+ })();
144
+
145
+ /** 运行中的 DSH 版本(读不到为 null)。 */
146
+ export const DSH_VERSION = (() => {
147
+ try {
148
+ const bin = resolveDshBin();
149
+ return (
150
+ JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null
151
+ );
152
+ } catch {
153
+ return null;
154
+ }
155
+ })();
156
+
157
+ // 注意用 `0.1.7-0` 而不是 `0.1.7`:semver 里 `0.1.7-rc.1 < 0.1.7`,
158
+ // 但 team 包正是随 0.1.7-rc.1 发布的。
159
+ const teamVersionOk = DSH_VERSION === null || cmpVersion(DSH_VERSION, '0.1.7-0') >= 0;
88
160
 
89
161
  /**
90
162
  * DSH 主目录。**默认就是本机用户自己的 ~/.dsh** —— 本服务不拥有独立的 home,
@@ -95,6 +167,31 @@ export const DSH_PROFILE = process.env.DSH_MCP_PROFILE ?? 'dsh-mcp';
95
167
  */
96
168
  export const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh');
97
169
 
170
+ /** 用于驱动 DSH 的 profile(由 bootstrap 在首启时按 acp 模板创建)。 */
171
+ export const DSH_PROFILE =
172
+ process.env.DSH_MCP_PROFILE ?? (TEAM_MODE && teamVersionOk ? 'dsh-team' : 'dsh-mcp');
173
+
174
+ /**
175
+ * 团队能力是否真正可用:
176
+ * - 用户没有关(TEAM_MODE);
177
+ * - DSH >= 0.1.7(team 包从 0.1.7 才有);
178
+ * - **profile 里真的含 team 包**(显式指定了不含 team 的 profile 时不注入,
179
+ * 否则模型会看到一个不存在的工具)。
180
+ * profile 还不存在时(首启)交给 bootstrap 创建,视为可用。
181
+ */
182
+ const profileTeamText = (() => {
183
+ try {
184
+ return readFileSync(join(DSH_HOME, 'profiles', DSH_PROFILE, 'cordis.patch.yml'), 'utf8');
185
+ } catch {
186
+ return null;
187
+ }
188
+ })();
189
+
190
+ export const TEAM_ACTIVE =
191
+ TEAM_MODE &&
192
+ teamVersionOk &&
193
+ (profileTeamText === null || profileTeamText.includes('@deepseek-ai/dsh-experimental-agent-team'));
194
+
98
195
  /**
99
196
  * 是否允许 bootstrap 自动创建/同步 DSH profile(默认开)。
100
197
  * 关掉(DSH_MCP_BOOTSTRAP=0)后本服务只驱动已存在的 profile,不再写任何文件。
@@ -240,7 +337,10 @@ export function numEnv(name, fallback) {
240
337
  return Number.isFinite(n) ? n : fallback;
241
338
  }
242
339
 
243
- const rawIdle = numEnv('DSH_MCP_IDLE_TTL_MS', 0);
340
+ // 0.4.0:默认从"永不回收"改为 10 分钟。
341
+ // 代价是锁可能空出来被 GUI 拿走;收益是并发一高不会把内存堆到几 GB。
342
+ // 需要"永远握住写锁"的会话用 dsh_start(keep_alive=true) 显式保活。
343
+ const rawIdle = numEnv('DSH_MCP_IDLE_TTL_MS', 10 * 60 * 1000);
244
344
  /** ★ `<= 0` 或非法值一律当作"永不回收"。
245
345
  * 否则 `-1` 会被算成 `cutoff = now + 1` → **立刻回收**(谁会想到负数=马上收 ✗),
246
346
  * `NaN` 更糟:比较永远为假,行为取决于具体分支。宁可把"奇怪的输入"统一压到安全侧。 */
@@ -261,13 +361,44 @@ export const REAP_INTERVAL_MS = Number.isFinite(rawReap) && rawReap >= 200 ? raw
261
361
  * - 窗口外的:进程回收、内存释放 ✓(下次派活自动 resume,无损 ✓)
262
362
  * 即"**内存有界,锁尽量在手**"。
263
363
  *
264
- * **默认 8**(按实测 300-480MB/个,最坏约 2.5-4GB):让"内存有界"成为默认行为,而不是要用户先发现再配 ✗。
364
+ * **默认 4**(按实测 300-480MB/个,最坏约 1.2-1.9GB):让"内存有界"成为默认行为。
265
365
  * 被回收是无损的(会话日志在磁盘上 ✓),代价只是那个会话的写锁会空出来、
266
366
  * 且下次派活要 cold start。想做更多并行就调大;机器大也可以设 0(不限)。
367
+ * `keep_alive=true` 的会话不受本上限约束(由调用方自己负责内存)。
267
368
  */
268
- const rawMaxLive = numEnv('DSH_MCP_MAX_LIVE', 8);
369
+ const rawMaxLive = numEnv('DSH_MCP_MAX_LIVE', 4);
269
370
  export const MAX_LIVE = Number.isFinite(rawMaxLive) && rawMaxLive > 0 ? Math.floor(rawMaxLive) : 0;
270
371
 
372
+ /**
373
+ * 全局子进程内存预算(MB)。`0` = 关闭。
374
+ *
375
+ * 超过预算时按"最久未用"回收空闲会话,直到回到预算内。
376
+ * 与 `MAX_LIVE` 的区别:那个按**会话数**,这个按**实际 RSS**——
377
+ * 会话的上下文大小差异很大,按个数限制经常低估。
378
+ * 读 RSS 失败时静默跳过(不能因为量不到就把会话全收了)。
379
+ */
380
+ const rawMaxRss = numEnv('DSH_MCP_MAX_RSS_MB', 0);
381
+ export const MAX_RSS_MB = Number.isFinite(rawMaxRss) && rawMaxRss > 0 ? Math.floor(rawMaxRss) : 0;
382
+
383
+ /**
384
+ * 给每个 DSH 子进程的 V8 老生代设上限(MB)。`0` = 不设。
385
+ *
386
+ * 只是兜底:DSH 正常不会吃满默认堆,但失控的上下文可以把 RSS 顶到很高。
387
+ * 设太小会让长会话 OOM,所以默认关闭、由用户按机器情况调。
388
+ */
389
+ const rawChildHeap = numEnv('DSH_MCP_CHILD_MAX_OLD_SPACE_MB', 0);
390
+ export const CHILD_MAX_OLD_SPACE_MB =
391
+ Number.isFinite(rawChildHeap) && rawChildHeap > 0 ? Math.floor(rawChildHeap) : 0;
392
+
393
+ /**
394
+ * 实验性:同一 `(cwd, permission)` 的多个会话共享一个 ACP 进程(0.4.0)。
395
+ *
396
+ * DSH 的 ACP 服务端支持一条连接上多会话,且每个 session 有独立 Agent。
397
+ * 但 `sandbox-policy.workspaceRoot` / `mode` 与 `approval.policy` 都是**进程级**配置,
398
+ * 所以只能按 `(cwd, permission)` 分组共享。默认关闭:共享进程的崩溃会连坐同组会话。
399
+ */
400
+ export const SHARED_PROCESS = numEnv('DSH_MCP_SHARED_PROCESS', 0) === 1;
401
+
271
402
  /**
272
403
  * `dsh_list` 的"磁盘探测"结果缓存多久(毫秒)。
273
404
  *
package/src/host.mjs ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * 共享 ACP 进程宿主(0.4.0,实验性,`DSH_MCP_SHARED_PROCESS=1` 开启)。
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
+ }