dsh-any-agent 0.5.2 → 0.6.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/tools.mjs CHANGED
@@ -5,45 +5,30 @@
5
5
  * 因为调用方 agent 已经有那些工具了。
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
- import { dirname, join } from 'node:path';
8
+ import { readFileSync } from 'node:fs';
10
9
  import {
11
- APPROVAL_POLICIES,
12
10
  DEFAULT_PERMISSION,
13
11
  DEFAULT_REASONING,
14
- DEFAULT_REASONING_EFFORT,
15
- DEFAULT_REVIEW,
16
- IDLE_TTL_MS,
17
- MAX_LIVE,
18
- MAX_RSS_MB,
19
- PERMISSION_TIERS,
20
- REASONING_EFFORTS,
21
- REASONING_MODES,
22
- REVIEW_MODES,
23
- REVIEWS_DIR,
24
- RUNS_DIR,
25
- SHARED_PROCESS,
26
- STATE_FILE,
27
12
  TEAM_ACTIVE,
28
- TEAM_MODE,
29
13
  DSH_PROFILE,
30
14
  toPosixPath,
31
- resolveDshBin,
32
- dshVersionStatus,
33
- isNpxCachePath,
34
15
  } from './config.mjs';
35
- import { inspectProfile } from './bootstrap.mjs';
36
16
  import { audit } from './audit.mjs';
37
- import { listOrphans, LOCKS_DIR } from './locks.mjs';
38
- import { rssMb } from './procstat.mjs';
39
- import { REVIEW_VERDICTS, normalizeReviewMode } from './review.mjs';
40
-
41
- const S = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false });
42
-
43
- const CONV_ID = {
44
- type: 'string',
45
- description: '会话 id,来自 dsh_start 或 dsh_list。',
46
- };
17
+ import { listOrphans } from './locks.mjs';
18
+ import { normalizeReviewMode } from './review.mjs';
19
+ import { runDoctor } from './doctor.mjs';
20
+ import {
21
+ backgroundReceipt,
22
+ compactResultForBudget,
23
+ queuedReceipt,
24
+ renderReviewSection,
25
+ renderSendResult,
26
+ replayRun,
27
+ } from './tool-render.mjs';
28
+
29
+ // 外部(mcp.mjs、测试)一直从 tools.mjs 取这些名字;拆出去之后导入路径保持不变。
30
+ export { TOOL_DEFS } from './tool-defs.mjs';
31
+ export { compactResultForBudget, psReadCommand, psWaitCommand } from './tool-render.mjs';
47
32
 
48
33
  /**
49
34
  * timeout_ms 校验:只接受 >= 0 的有限数字(0 = 一直等)。
@@ -65,619 +50,9 @@ const TEAM_PREFIX =
65
50
  '【团队】如果这个任务需要并行或多角色协作,先用 spawn_teammate 创建团队再执行;' +
66
51
  '不需要就单 agent 完成。不要为简单任务组队。\n\n';
67
52
 
68
- /** 工具清单(JSON Schema)。 */
69
- export const TOOL_DEFS = [
70
- {
71
- name: 'dsh_start',
72
- description:
73
- '新建一个 DSH 会话(一个可长期共事的编码 agent)。会话活在磁盘上,进程只是缓存;' +
74
- '进程被回收或崩溃后会自动用 ACP session/resume 复活,对话不丢。' +
75
- '返回 conversation_id 与对话标题。建好后用 dsh_send 派活。',
76
- inputSchema: S(
77
- {
78
- cwd: {
79
- type: 'string',
80
- description:
81
- '工作区绝对路径。决定 agent 的工作目录、AGENTS.md/技能的发现范围、以及沙箱可写根。' +
82
- '**省略时自动用客户端当前打开的项目目录**(MCP roots),这样客户端里看到哪个项目就在这里干活;' +
83
- '客户端不支持 roots、或 roots 为空时才会要求你显式传入。别传用户家目录本身 —— 那里会让 DSH 的 ACL 沙箱起不来。',
84
- },
85
- permission: {
86
- type: 'string',
87
- enum: PERMISSION_TIERS,
88
- description:
89
- `权限档。默认 ${DEFAULT_PERMISSION}。read-only 适合分析不信任样本(如恶意软件);workspace-write 由 DSH 沙箱限制可写范围,越界请求默认 auto-allow,想在调用方挂起审批请传 on_approval=ask。` +
90
- '**档位在创建时定下**:DSH 设计上"resume 的会话保留它自己记录的权限",所以事后改这个参数不会改变已有会话,要换档需新建。' +
91
- '(本档位是**真实生效**的 —— 由启动时传入的 env 决定,并有 test/permission.mjs 对着会话自己的记录逐档核验。)',
92
- },
93
- on_approval: {
94
- type: 'string',
95
- enum: APPROVAL_POLICIES,
96
- description:
97
- `审批处理策略。**read-only 强制 auto-deny**(传 auto-allow/ask 会被直接拒绝,否则会放行 DSH 的 sandbox 提权);` +
98
- `其余档位默认 ${process.env.DSH_MCP_APPROVAL ?? 'auto-allow'}。ask 会把审批挂起,需用 dsh_approval_decide 裁决。`,
99
- },
100
- title_hint: {
101
- type: 'string',
102
- maxLength: 200,
103
- description:
104
- '可读的会话标签(最长 200 字)。注意 DSH 自己的标题是首条消息的截断前缀(约 13–15 字),会优先显示;' +
105
- '这里给的值不会被覆盖,而是在 dsh_get / dsh_start 里作为「标题提示」一并返回。',
106
- },
107
- reasoning_effort: {
108
- type: 'string',
109
- enum: REASONING_EFFORTS,
110
- description: `思考深度,默认 ${DEFAULT_REASONING_EFFORT}。注意 ACP 自身的默认是"Provider default"(不设),本服务会显式设成 max。想省钱可调低。`,
111
- },
112
- review: {
113
- type: 'string',
114
- enum: REVIEW_MODES,
115
- description:
116
- `调用方审核策略,默认 ${DEFAULT_REVIEW}。advisory:每次派活的结果/哨兵都会附审核单(核验清单 + 证据 + dsh_review 入口),` +
117
- '但**不阻塞**后续派活;off:不生成审核单。可被单次 dsh_send 覆盖。',
118
- },
119
- provider: { type: 'string', description: '模型提供方名(如 deepseek-official)。留空用 profile 默认。' },
120
- model: { type: 'string', description: '模型名(如 deepseek-flash)。指定时必须同时给 provider。' },
121
- keep_alive: {
122
- type: 'boolean',
123
- description:
124
- 'true = 这个会话的进程不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。' +
125
- '默认 false:空闲 10 分钟后进程会被回收、内存释放,下次派活自动 resume(对话不丢)。' +
126
- '设 true 时请记得用完 `dsh_release`,否则它会一直占着一个 DSH 进程(约 300–480MB)。',
127
- },
128
- },
129
- // cwd 不再是必填:有 MCP roots 的客户端会自动带上当前项目目录。
130
- [],
131
- ),
132
- },
133
- {
134
- name: 'dsh_send',
135
- description:
136
- '向会话派一个任务。**给目标,不要给命令**:DSH 自己决定用哪些文件/shell/搜索/技能/子代理/团队工具,' +
137
- '本服务不预先加载或代理 DSH 的工具面。' +
138
- '**默认不阻塞(wait=false)**:立即返回 run_id + sentinel_file,' +
139
- '回合在后台跑,跑完自动通知你 —— 你继续做别的事,也永远不用担心任务被"超时"打断。' +
140
- '想原地等结果就显式传 wait=true(可配 timeout_ms;到期只会转后台,不会取消回合)。' +
141
- '【超时不会毁掉任务】wait=true 到达 timeout_ms 仍没跑完时,本工具**不会报错、也不会取消回合**,' +
142
- '而是自动降级为后台:返回 run_id + sentinel_file,回合继续跑完,结果稍后由哨兵文件通知。' +
143
- '(timeout_ms=0 表示一直等。)所以任何时候你都能拿到结果,不用怕"超时了白干"。' +
144
- '等待模式与后台模式都在回合结束时原子落地一个含最终结果的小文件——' +
145
- '**推荐用后台任务(Bash run_in_background + `until [ -f sentinel_file ]`;Windows/PowerShell 用 `Test-Path` 循环,收据里给出现成命令)等它,跑完自动通知你,无需轮询**;' +
146
- '文件出现后读它即得结果,或用 dsh_get(conversation_id, run_id) 验收,dsh_read 可中途看输出。' +
147
- '默认隐藏思考内容(只返回正文 + 思考统计)。' +
148
- '结果与后台收据都会带 `review` 审核单(默认 advisory):调用方核验后用 dsh_review 提交裁决。' +
149
- '若会话正在忙,本工具会报错——改用 dsh_interject 插话、dsh_send(queue=true) 入队,或用 dsh_interrupt 先打断。',
150
- inputSchema: S(
151
- {
152
- conversation_id: CONV_ID,
153
- prompt: { type: 'string', description: '任务描述。可以引用工作区里的文件路径。' },
154
- wait: {
155
- type: 'boolean',
156
- description:
157
- '**默认 false**:立即返回 run_id + sentinel_file,回合在后台跑,跑完由哨兵文件通知(推荐用法)。true = 原地阻塞到回合结束再返回完整结果。',
158
- },
159
- reasoning: {
160
- type: 'string',
161
- enum: REASONING_MODES,
162
- description: `思考内容展示档位。默认 ${DEFAULT_REASONING}(只返回正文,思考只给统计)。full 会占用大量上下文。`,
163
- },
164
- timeout_ms: {
165
- type: 'number',
166
- description:
167
- '仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
168
- },
169
- review: {
170
- type: 'string',
171
- enum: REVIEW_MODES,
172
- description:
173
- '本次派活的审核策略,覆盖会话默认(默认 advisory)。advisory:结果/哨兵附审核单,提示调用方独立核验后用 dsh_review 提交裁决;off:本次不附。',
174
- },
175
- queue: {
176
- type: 'boolean',
177
- description:
178
- '会话正忙时是否入队(默认 false = 直接报错)。true 时立即返回 run_id + queue_position,' +
179
- '本轮结束后自动执行;哨兵只在真正结束时写,入队时不写。队列仅内存、重启丢失,用 dsh_queue 管理。',
180
- },
181
- client_run_id: {
182
- type: 'string',
183
- maxLength: 128,
184
- description:
185
- '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交同一个键不会创建第二个 run,' +
186
- '而是返回首次 run 的结果或收据(replayed=true)。用于"响应在传输层丢了、想安全重发"的场景。',
187
- },
188
- team: {
189
- type: 'boolean',
190
- description:
191
- '是否在本次派发前注入"需要并行/多角色时先建团队"的提示(默认跟随 DSH_MCP_TEAM,0.4.0 起默认开)。' +
192
- '传 false 只对这一次关闭;彻底关闭用 DSH_MCP_TEAM=0。',
193
- },
194
- },
195
- ['conversation_id', 'prompt'],
196
- ),
197
- },
198
- {
199
- name: 'dsh_list',
200
- description:
201
- '列出会话及其运行状态,用来掌控多个会话实例。' +
202
- 'state: running(正在跑回合)/ idle(进程活着但空闲)/ detached(进程没开,可 resume 复活)。' +
203
- 'running 的会话会带上已跑时长、当前正在执行的工具、本轮已产出字数。' +
204
- '数据来自两路合并:本服务持有的 + DSH 磁盘上尚未打开的(DSH 的 session/list 会排除已打开的会话)。' +
205
- '想只看正在跑的,传 only_running=true;想看某个会话当前吐出的内容,接着用 dsh_read。',
206
- inputSchema: S({
207
- cwd: { type: 'string', description: '只看某个工作区下的会话。' },
208
- include_closed: {
209
- type: 'boolean',
210
- description:
211
- '是否包含磁盘上未打开的会话(含别的实例 / GUI 建的),默认 true。**代价:要 spawn 一个 DSH 进程去探测磁盘,实测约 1 秒**(结果缓存 10 秒)。只列本服务已打开的会话就传 false,毫秒级。',
212
- },
213
- only_running: {
214
- type: 'boolean',
215
- description: '只返回正在跑回合的会话,默认 false。**此模式自动跳过磁盘探测,毫秒级**(正在跑的会话必然在内存里)。',
216
- },
217
- }),
218
- },
219
- {
220
- name: 'dsh_read',
221
- description:
222
- '读会话的运行期输出(**游标式增量**)——用它可以看到"正在跑的会话"当前吐出了什么,' +
223
- '不必等回合结束。典型用法:dsh_list(only_running) 找到在跑的会话 → 反复 dsh_read 并回传上次的 cursor,' +
224
- '就能像切换窗口一样轮流观察多个会话。' +
225
- '输出缓冲**只在内存里且有界**(环形,满则丢最旧),服务/进程退出即消失;' +
226
- '**思考内容不进缓冲**——只有 include_reasoning=true 时才会附带"当前正在跑那一轮"的实时思考。' +
227
- 'cursor 是**修订号**:同一条目被追加文本后会再次返回,调用方按 `seq` 用它替换全文(不要重复累加)。',
228
- inputSchema: S(
229
- {
230
- conversation_id: CONV_ID,
231
- cursor: { type: 'number', description: '上次返回的 cursor;首次不传或传 0。' },
232
- include_reasoning: { type: 'boolean', description: '附带当前回合的实时思考内容,默认 false。' },
233
- limit: { type: 'number', description: '单次最多返回多少条,默认 200。' },
234
- },
235
- ['conversation_id'],
236
- ),
237
- },
238
- {
239
- name: 'dsh_get',
240
- description:
241
- '查看单个会话的详情;带 run_id 时**取那一次派活的最终结果**(异步模式的验收入口)。' +
242
- '不传 run_id 则取最近一次回合。返回:标题、工作区、权限档、状态、轮数、token 用量、' +
243
- '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)和 **review 审核单**。' +
244
- '**run 记录只在内存、最多保留最近 20 条**;服务重启或记录被挤掉后会从哨兵文件回读(它落盘且扛重启)。',
245
- inputSchema: S(
246
- {
247
- conversation_id: CONV_ID,
248
- run_id: { type: 'string', maxLength: 200, description: 'dsh_send 返回的 run_id;不传则取最近一次回合。' },
249
- client_run_id: {
250
- type: 'string',
251
- maxLength: 128,
252
- description: '按调用方幂等键取 run(与 run_id 二选一;只对仍在内存里的 run 有效,最多最近 20 条)。',
253
- },
254
- max_chars: {
255
- type: 'number',
256
- description:
257
- '结果预算(默认 0 = 不截断,返回完整结果)。>0 时**同时**作用于 text 与 structured:' +
258
- '正文截到该长度(附 answer_truncated/next_offset),structured.run.result.tools_used 只保留' +
259
- '工具名/状态与 input_chars,不再带完整输入。完整结果始终在 sentinel_file 里。' +
260
- '一个回合用了很多工具时,默认返回可能超过客户端单次工具结果上限,建议传 8000 左右。',
261
- },
262
- },
263
- ['conversation_id'],
264
- ),
265
- },
266
- {
267
- name: 'dsh_review',
268
- description:
269
- '提交对一次回合结果的**调用方审核裁决**。dsh_send / dsh_interject / 哨兵里的 result 会带 `review` 审核单' +
270
- '(核验清单 + 证据摘要 + review_id)。在把结果当作完成之前,调用方应自己核验(读改动的文件、看 git diff、跑测试等),' +
271
- '再调用本工具记录 pass / partial / fail。' +
272
- '**默认是建议式审核**:不阻塞后续 dsh_send,也不会自动把审核意见回灌给 DSH;需要修就再 dsh_send。' +
273
- 'run 记录被内存挤出或服务重启后仍可提交(以哨兵文件为准)。',
274
- inputSchema: S(
275
- {
276
- conversation_id: CONV_ID,
277
- run_id: { type: 'string', maxLength: 200, description: '要审核的那次派活(审核单里的 run_id)。' },
278
- verdict: {
279
- type: 'string',
280
- enum: REVIEW_VERDICTS,
281
- description: 'pass=可直接采用;partial=部分可用、需补充;fail=不可用、需重做。fail/partial 必须说明理由。',
282
- },
283
- notes: { type: 'string', description: '审核理由。fail/partial 必填(或改用 findings),最多 8000 字。' },
284
- findings: {
285
- type: 'array',
286
- items: { type: 'string' },
287
- description: '逐条问题清单,可选;最多 50 条、每条 2000 字。',
288
- },
289
- },
290
- ['conversation_id', 'run_id', 'verdict'],
291
- ),
292
- },
293
- {
294
- name: 'dsh_interject',
295
- description:
296
- '插话:会话正在跑某个回合时往里补一句话。两种模式——' +
297
- 'interject(默认)打断当前回合并立刻改口;queue 不打断,等本轮自然结束后紧接着说。' +
298
- '会话空闲时按 **wait=true 的普通回合**执行(会阻塞到回合结束并返回完整结果,不是收据)。' +
299
- '说明:ACP 明确拒绝并发 prompt,所以做不到"让模型在同一个回合里即时改向";' +
300
- 'interject 是它的实用等价物(取消 + 立刻开新回合,历史完整保留)。',
301
- inputSchema: S(
302
- {
303
- conversation_id: CONV_ID,
304
- message: { type: 'string', description: '要插进去说的话。' },
305
- mode: {
306
- type: 'string',
307
- enum: ['interject', 'queue'],
308
- description: 'interject(默认)= 打断并改口;queue = 等本轮跑完接着说。',
309
- },
310
- reasoning: {
311
- type: 'string',
312
- enum: REASONING_MODES,
313
- description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
314
- },
315
- timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
316
- review: {
317
- type: 'string',
318
- enum: REVIEW_MODES,
319
- description: '本次插话回合的审核策略,覆盖会话默认(默认 advisory);off 可关闭本次审核单。',
320
- },
321
- client_run_id: {
322
- type: 'string',
323
- maxLength: 128,
324
- description: '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交返回首次的 run,不会重复插话。',
325
- },
326
- },
327
- ['conversation_id', 'message'],
328
- ),
329
- },
330
- {
331
- name: 'dsh_interrupt',
332
- description:
333
- '中断会话当前正在进行的回合(ACP 原生 session/cancel,走的就是用户手动按停止那条路径)。' +
334
- '会话本身保持完好,可以立刻继续派新任务。',
335
- inputSchema: S({ conversation_id: CONV_ID }, ['conversation_id']),
336
- },
337
- {
338
- name: 'dsh_takeover',
339
- description:
340
- '**抢占会话的写锁**(DSH 的写锁是跨进程内核信号量,持有者活着就不过期,没有 API 能直接夺走——' +
341
- '唯一手段是杀掉持有者进程,所以本工具会先判定持有者是谁):' +
342
- '① 若锁其实空着,正常 resume 即可,不会杀任何东西;' +
343
- '② 若持有者是**本服务自己留下的孤儿进程**(MCP 崩了/重启了,子进程还活着握着锁)→ **自动杀掉并接管**,安全;' +
344
- '③ 若持有者是**另一个活着的 dsh-any-agent 实例** → 默认拒绝,传 force=true 才夺;' +
345
- '④ 若持有者无法识别(**多半是你自己的 DSH GUI 正开着这个会话**)→ **绝不杀**(那会连你的界面和正在看的对话一起杀掉),' +
346
- '只报告,请你在那个窗口里关掉该会话后重试。' +
347
- '注意:杀掉正在跑回合的持有者会中断那一轮(会话日志记为 interrupted),会话本体不受损。',
348
- inputSchema: S(
349
- {
350
- conversation_id: CONV_ID,
351
- force: {
352
- type: 'boolean',
353
- description: '仅用于"另一个活着的 dsh-any-agent 实例持有"这一种情况;对 GUI/未知持有者无效(永远不杀)。',
354
- },
355
- },
356
- ['conversation_id'],
357
- ),
358
- },
359
- {
360
- name: 'dsh_release',
361
- description:
362
- '释放会话占用的 DSH 进程(释放写锁,让用户能在自己的 DSH GUI 里打开同一个会话)。' +
363
- '会话本体留在磁盘上,之后仍可 resume;**forget=true 会写墓碑**:从注册表移除,此后按 id 查询明确报“已被 forget”' +
364
- '(想恢复可按错误里的路径删除墓碑文件,默认在 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json`)。' +
365
- '★ 用完了想一次性腾内存就传 all=true(每个会话约 300-480MB;默认 `MAX_LIVE=8` 会回收最久未用者,' +
366
- 'all=true 会释放**空闲**会话,正在跑回合的默认跳过;确实要连跑着的也停掉请传 force=true)。' +
367
- '对单个会话 release 会停掉其 DSH 进程;若它正在跑回合,该回合会被中断。',
368
- inputSchema: S(
369
- {
370
- conversation_id: CONV_ID,
371
- forget: { type: 'boolean', description: '同时从本服务的注册表里移除,默认 false。' },
372
- all: {
373
- type: 'boolean',
374
- description: '忽略 conversation_id,释放所有**空闲**会话进程(腾内存用)。正在跑回合的默认跳过。默认 false。',
375
- },
376
- force: {
377
- type: 'boolean',
378
- description: '仅配合 all=true:连正在跑回合的会话也停掉(会中断那些回合)。默认 false。',
379
- },
380
- },
381
- [],
382
- ),
383
- },
384
- {
385
- name: 'dsh_approval_decide',
386
- description:
387
- '裁决一个待决审批(仅当会话的 on_approval=ask 时会出现)。用于把 DSH 的越权/提权请求交给调用方决定,' +
388
- '既不一律放行也不一律拒绝。',
389
- inputSchema: S(
390
- {
391
- conversation_id: CONV_ID,
392
- approval_id: { type: 'string', description: '来自 dsh_get 的 pending_approvals。' },
393
- decision: { type: 'string', enum: ['allow', 'deny'], description: '放行或拒绝。' },
394
- },
395
- ['conversation_id', 'approval_id', 'decision'],
396
- ),
397
- },
398
- {
399
- name: 'dsh_status',
400
- description: '轻量状态查询:会话是否活着、是否在忙、待决审批数、最近一次回合的结果。',
401
- inputSchema: S({ conversation_id: CONV_ID }, ['conversation_id']),
402
- },
403
- {
404
- name: 'dsh_inbox',
405
- description:
406
- '**跨会话的待决审批收件箱**(dsh_status 只看单个会话)。' +
407
- '一次列出所有会话里等你裁决的审批:会话 id/标题、审批 id、工具、原因、命令/路径摘要、等待时长。' +
408
- '裁决用 dsh_approval_decide。没有待决审批时返回空列表。',
409
- inputSchema: S(
410
- {
411
- conversation_id: { type: 'string', description: '只看某个会话(省略 = 全部)' },
412
- include_input: { type: 'boolean', description: '是否带操作输入摘要(默认 true)' },
413
- },
414
- [],
415
- ),
416
- },
417
- {
418
- name: 'dsh_queue',
419
- description:
420
- '查看/修改会话的待执行队列(由 dsh_send(queue=true) 产生;**仅内存**,重启丢失)。' +
421
- 'action=list 列出;remove 取消并写 cancelled 哨兵;update 改写排队文本;promote 提到队首。',
422
- inputSchema: S(
423
- {
424
- conversation_id: CONV_ID,
425
- action: { type: 'string', enum: ['list', 'remove', 'update', 'promote'] },
426
- run_id: { type: 'string', maxLength: 200, description: 'remove / update / promote 必填' },
427
- text: { type: 'string', description: 'update 时的新文本' },
428
- },
429
- ['conversation_id', 'action'],
430
- ),
431
- },
432
- {
433
- name: 'dsh_doctor',
434
- description:
435
- '只读体检:DSH 可执行文件与版本、profile 是否存在/漂移、状态文件、runs/locks 目录可写、孤儿锁、' +
436
- '当前会话数与 RSS、生效配置。只报告不修改;ok=false 表示存在 fail 项。',
437
- inputSchema: S({}, []),
438
- },
439
- ];
440
-
441
- /**
442
- * 把审核单渲染成调用方一眼能看到的文本块。
443
- *
444
- * 为什么放在正文后面而不是单独一次工具调用:MCP 调用方拿到结果后往往直接转述,
445
- * 审核要求必须和结果出现在同一条消息里,才有机会改变它的下一步动作。
446
- */
447
- function renderReviewSection(review) {
448
- if (!review || typeof review !== 'object') return '';
449
- const lines = [];
450
- const pending = review.status === 'pending';
451
- lines.push(`【调用方审核${pending ? ' · 待提交' : ` · ${review.status}`}】`);
452
- if (review.prompt_preview) lines.push(`原任务: ${review.prompt_preview}`);
453
- const ev = review.evidence ?? {};
454
- const bits = [];
455
- if (ev.stop_reason) bits.push(`stop=${ev.stop_reason}`);
456
- if (ev.workspace_changed != null) bits.push(`工作区变化=${ev.workspace_changed ? '是' : '否'}`);
457
- if (ev.changed_files != null) bits.push(`改动文件=${ev.changed_files}`);
458
- if (Array.isArray(ev.tools) && ev.tools.length) bits.push(`工具=[${ev.tools.join(', ')}]`);
459
- if (ev.auto_approvals) {
460
- bits.push(`⚠ 自动放行审批=${ev.auto_approvals}${ev.auto_escalations ? `(含提权 ${ev.auto_escalations})` : ''}`);
461
- }
462
- if (bits.length) lines.push(`证据: ${bits.join(' · ')}`);
463
- if (pending) {
464
- lines.push('在把这次结果当作完成之前,请独立核验:');
465
- (review.checklist ?? []).forEach((item, i) => lines.push(`${i + 1}. ${item}`));
466
- if (review.instructions) lines.push(review.instructions);
467
- lines.push(
468
- `提交裁决: dsh_review(conversation_id="${review.conversation_id}", run_id="${review.run_id}", ` +
469
- 'verdict="pass|partial|fail", notes="审核理由")',
470
- );
471
- } else {
472
- if (review.notes) lines.push(`审核理由: ${review.notes}`);
473
- if (Array.isArray(review.findings) && review.findings.length) {
474
- lines.push(`审核问题: ${review.findings.join(';')}`);
475
- }
476
- if (review.review_file) lines.push(`审核记录: ${review.review_file}`);
477
- }
478
- return lines.join('\n');
479
- }
480
-
481
53
  /**
482
- * 按 `max_chars` 预算压缩一份回合结果(只用于返回给调用方,不改原对象)。
54
+ * 工具处理器表。
483
55
  *
484
- * 体积大头是 tools_used[].input:edit/write 会带完整的新旧文本,几十次工具调用
485
- * 轻松几万字。压缩后每个工具只留 id/title/name/status/kind 与 input_chars;
486
- * 正文与思考截到预算长度。完整版本仍在哨兵文件里。
487
- */
488
- export function compactResultForBudget(result, cap) {
489
- if (!result || typeof result !== 'object' || !(cap > 0)) return result ?? null;
490
- const out = { ...result };
491
- if (typeof out.answer === 'string' && out.answer.length > cap) out.answer = out.answer.slice(0, cap);
492
- if (typeof out.thinking === 'string' && out.thinking.length > cap) out.thinking = out.thinking.slice(0, cap);
493
- if (Array.isArray(out.tools_used)) {
494
- out.tools_used = out.tools_used.map((t) => {
495
- if (!t || typeof t !== 'object') return t;
496
- const { input, ...meta } = t;
497
- if (input === undefined) return meta;
498
- let chars = 0;
499
- try {
500
- chars = typeof input === 'string' ? input.length : JSON.stringify(input)?.length ?? 0;
501
- } catch {
502
- chars = 0;
503
- }
504
- return { ...meta, input_chars: chars };
505
- });
506
- }
507
- return out;
508
- }
509
-
510
- /**
511
- * PowerShell 版的哨兵等待命令(Windows 宿主上 Git Bash 可能不可用或挂住)。
512
- * 路径放进单引号字面量:PowerShell 单引号里只有 `'` 需要转义(写成 `''`),
513
- * `$`、反引号都不展开,所以不会被路径内容注入。
514
- */
515
- export function psWaitCommand(sentinelPath, timeoutSec = 2100) {
516
- const lit = `'${String(sentinelPath).replace(/'/g, "''")}'`;
517
- return (
518
- `$f=${lit}; $dl=(Get-Date).AddSeconds(${Math.max(1, Math.floor(timeoutSec))}); ` +
519
- `while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }; 'DONE'`
520
- );
521
- }
522
-
523
- /** 把结构化结果渲染成给模型看的文本。 */
524
- function renderSendResult(r) {
525
- const lines = [];
526
- if (r.answer?.trim()) lines.push(r.answer.trim());
527
- else lines.push('(DSH 本次没有产生正文输出)');
528
-
529
- if (r.thinking && r.thinking.trim()) {
530
- lines.push('');
531
- lines.push('--- 思考内容 ---');
532
- lines.push(r.thinking.trim());
533
- }
534
-
535
- const meta = [];
536
- meta.push(`stop=${r.stop_reason}`);
537
- if (typeof r.elapsed_ms === 'number') meta.push(`耗时=${(r.elapsed_ms / 1000).toFixed(1)}s`);
538
- if (r.thinking_stats?.chars) {
539
- meta.push(
540
- r.thinking_stats.hidden
541
- ? `思考已隐藏(${r.thinking_stats.chars}字/${(r.thinking_stats.ms / 1000).toFixed(1)}s)`
542
- : `思考=${r.thinking_stats.chars}字`,
543
- );
544
- }
545
- const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
546
- if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
547
- if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
548
- if (r.auto_approvals_total) {
549
- meta.push(
550
- `自动放行审批 ${r.auto_approvals_total} 次` +
551
- (r.auto_escalations_total ? `(含沙箱提权 ${r.auto_escalations_total} 次)` : ''),
552
- );
553
- }
554
- lines.push('');
555
- lines.push(`[${meta.join(' · ')}]`);
556
-
557
- if (r.review) {
558
- lines.push('');
559
- lines.push(renderReviewSection(r.review));
560
- }
561
-
562
- if (r.error) {
563
- lines.push('');
564
- lines.push(`⚠️ 回合失败: ${r.error}`);
565
- }
566
- return lines.join('\n');
567
- }
568
-
569
- /** 工具处理器表。 */
570
- /**
571
- * 后台收据的渲染:wait=false 直接走这里;wait=true 等超时会自动降级到这里。
572
- * 关键语义:**回合没有被取消**,只是我们不再原地等 —— 结果稍后由哨兵文件通知。
573
- */
574
- function backgroundReceipt(hub, conv, res) {
575
- const sentinel = res.sentinel_file ?? hub.runSentinelPath(conv.id, res.run_id);
576
- const sentinelPosix = toPosixPath(sentinel);
577
- const review = conv.pendingReviewTicket?.(res.run_id) ?? null;
578
- const waited = res.waited_ms
579
- ? `已等待 ${(res.waited_ms / 1000).toFixed(0)}s 仍未结束,改为后台继续(**回合没有被取消**)。`
580
- : '已在后台执行(**回合没有被取消**)。';
581
- return {
582
- structured: {
583
- conversation_id: conv.id,
584
- run_id: res.run_id,
585
- accepted: true,
586
- background: true,
587
- still_running: true,
588
- sentinel_file: sentinel,
589
- /** Bash/MSYS 形式(D:\a\b → /d/a/b);非 Windows 等于 sentinel_file。 */
590
- sentinel_file_posix: sentinelPosix,
591
- /** Windows 宿主:现成的 PowerShell 等待命令(路径已按单引号字面量转义)。 */
592
- ...(process.platform === 'win32' ? { sentinel_wait_powershell: psWaitCommand(sentinel) } : {}),
593
- ...(review ? { review } : {}),
594
- },
595
- text:
596
- `${waited}(run_id=${res.run_id})\n\n` +
597
- `【跑完自动通知,无需轮询】完成时(成功/失败/被取消)会原子写出这个文件(含最终结果,但**不含思考内容**):\n` +
598
- ` ${sentinel}\n` +
599
- `直接用下面这个 Bash 形式路径,**不要手工转换**(写错会一直等到超时):\n` +
600
- ` ${sentinelPosix}\n` +
601
- `推荐用后台任务等它出现,文件一到就会唤醒你:\n` +
602
- ` Bash(run_in_background): sent="${sentinelPosix}"; \\\n` +
603
- ` dl=$(( $(date +%s)+2100 )); until [ -f "$sent" ]; do [ $(date +%s) -ge $dl ] && { echo TIMEOUT; exit 1; }; sleep 2; done; echo DONE\n` +
604
- (process.platform === 'win32'
605
- ? `Windows 上 Bash 不可用(或会挂住)时,用 PowerShell 版本(原生路径,已转义好):\n` +
606
- ` PowerShell(run_in_background): ${psWaitCommand(sentinel)}\n`
607
- : '') +
608
- `文件出现后:直接读它拿结果(推荐——不受内存窗口限制、还扛服务重启),或 dsh_get(conversation_id, run_id) 验收。\n` +
609
- `消费完可以 rm 掉它。其它随时可用:dsh_status(是否在跑)、dsh_read(增量看输出)。` +
610
- (review ? `\n\n${renderReviewSection(review)}` : '') +
611
- (res.waited_ms
612
- ? `\n\n下次想立刻返回就传 wait=false;想等更久就调大 timeout_ms(**0 = 一直等**)。`
613
- : ''),
614
- };
615
- }
616
-
617
- /**
618
- * 幂等命中:返回同一个 client_run_id 首次对应 run 的结果或收据。
619
- * 绝不创建第二个 run —— 这正是"响应在传输层丢了、想安全重发"需要的语义。
620
- */
621
- function replayRun(hub, conv, run, wantResult) {
622
- const sentinel = hub.runSentinelPath(conv.id, run.run_id);
623
- const sentinelPosix = toPosixPath(sentinel);
624
- const terminal = run.status === 'done' || run.status === 'error' || run.status === 'cancelled';
625
- if (terminal && run.result) {
626
- return {
627
- structured: { ...run.result, replayed: true, ...(wantResult ? {} : { background: true }) },
628
- text:
629
- `(幂等命中:client_run_id 已对应一个已结束的回合,未重复派活)\n` +
630
- renderSendResult(run.result) +
631
- `\n哨兵文件: ${sentinel}`,
632
- };
633
- }
634
- const review = conv.pendingReviewTicket?.(run.run_id) ?? null;
635
- return {
636
- structured: {
637
- conversation_id: conv.id,
638
- run_id: run.run_id,
639
- status: run.status,
640
- accepted: true,
641
- background: true,
642
- still_running: true,
643
- replayed: true,
644
- sentinel_file: sentinel,
645
- sentinel_file_posix: sentinelPosix,
646
- ...(review ? { review } : {}),
647
- },
648
- text:
649
- `(幂等命中:同一个 client_run_id 已在跑或排队,未重复派活)run_id=${run.run_id} status=${run.status}\n` +
650
- `哨兵文件: ${sentinel}` +
651
- (review ? `\n\n${renderReviewSection(review)}` : ''),
652
- };
653
- }
654
-
655
- /** 入队回执:入队时不写哨兵,真正结束才写。 */
656
- function queuedReceipt(hub, conv, run, position) {
657
- const sentinel = hub.runSentinelPath(conv.id, run.run_id);
658
- const sentinelPosix = toPosixPath(sentinel);
659
- const review = conv.pendingReviewTicket?.(run.run_id) ?? null;
660
- return {
661
- structured: {
662
- conversation_id: conv.id,
663
- run_id: run.run_id,
664
- accepted: true,
665
- queued: true,
666
- queue_position: position,
667
- status: 'queued',
668
- sentinel_file: sentinel,
669
- sentinel_file_posix: sentinelPosix,
670
- ...(review ? { review } : {}),
671
- },
672
- text:
673
- `已入队(第 ${position} 位)。当前回合结束后自动执行;**入队时不会写哨兵**,真正结束时才写:\n` +
674
- ` ${sentinel}\n` +
675
- `用 dsh_queue list 查看/修改/删除;取消:dsh_queue(conversation_id, action="remove", run_id="${run.run_id}")。` +
676
- (review ? `\n\n${renderReviewSection(review)}` : ''),
677
- };
678
- }
679
-
680
- /**
681
56
  * @param {object} hub
682
57
  * @param {{projectDir?: () => Promise<string|null>}} [opts]
683
58
  * projectDir:客户端当前打开的项目目录(MCP roots)。不传 cwd 时用它兜底 ——
@@ -1353,131 +728,7 @@ export function createHandlers(hub, opts = {}) {
1353
728
  },
1354
729
 
1355
730
  async dsh_doctor() {
1356
- const checks = [];
1357
- const add = (id, status, detail, fix = null) => checks.push({ id, status, detail, fix });
1358
-
1359
- try {
1360
- const bin = resolveDshBin();
1361
- let version = null;
1362
- try {
1363
- version =
1364
- JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null;
1365
- } catch {}
1366
- const fromNpx = isNpxCachePath(bin);
1367
- add(
1368
- 'dsh_bin',
1369
- fromNpx ? 'warn' : 'ok',
1370
- `${bin}${version ? ` (DSH ${version})` : ''}${fromNpx ? ' —— 位于 npx 缓存,清缓存后会失效' : ''}`,
1371
- fromNpx
1372
- ? '全局安装 DSH(npm i -g @deepseek-ai/dsh)或把 DSH_BIN 指向一个固定位置的 lib/bin.js'
1373
- : null,
1374
- );
1375
- const vs = dshVersionStatus(version);
1376
- add(
1377
- 'dsh_version',
1378
- vs.status,
1379
- `${vs.level}: ${vs.note}`,
1380
- vs.status === 'ok'
1381
- ? null
1382
- : vs.level === 'unverified'
1383
- ? '对照 docs/zh/compatibility.md;升级后跑一遍 npm test,必要时 npm run test:all'
1384
- : '升级到 DSH 0.1.7-rc.1 及以上',
1385
- );
1386
- } catch (e) {
1387
- add('dsh_bin', 'fail', e.message, '设置 DSH_BIN 指向 @deepseek-ai/dsh/lib/bin.js');
1388
- }
1389
-
1390
- try {
1391
- const p = inspectProfile();
1392
- add(
1393
- 'profile',
1394
- p.exists ? (p.drifted ? 'warn' : 'ok') : 'warn',
1395
- `${p.dir} exists=${p.exists} managed=${p.managed} drifted=${p.drifted} routes=${p.routes.join(',') || 'none'}`,
1396
- p.exists ? null : '让服务首启自动创建,或运行 dsh-any-agent-setup',
1397
- );
1398
- } catch (e) {
1399
- add('profile', 'fail', e.message);
1400
- }
1401
-
1402
- try {
1403
- const raw = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
1404
- add(
1405
- 'state',
1406
- raw.version === 1 ? 'ok' : 'warn',
1407
- `${STATE_FILE} version=${raw.version} conversations=${raw.conversations?.length ?? 0}`,
1408
- );
1409
- } catch (e) {
1410
- add('state', 'warn', `${STATE_FILE}: ${e.message}`, '首次启动会自动创建');
1411
- }
1412
-
1413
- for (const [id, dir] of [
1414
- ['runs_dir', RUNS_DIR],
1415
- ['reviews_dir', REVIEWS_DIR],
1416
- ['locks_dir', LOCKS_DIR],
1417
- ]) {
1418
- try {
1419
- mkdirSync(dir, { recursive: true });
1420
- const probe = join(dir, `.doctor-${process.pid}`);
1421
- writeFileSync(probe, '');
1422
- rmSync(probe, { force: true });
1423
- add(id, 'ok', dir);
1424
- } catch (e) {
1425
- add(id, 'fail', `${dir}: ${e.message}`, '检查权限,或用对应环境变量指向可写目录');
1426
- }
1427
- }
1428
-
1429
- try {
1430
- const orphans = listOrphans();
1431
- add(
1432
- 'orphan_locks',
1433
- orphans.length ? 'warn' : 'ok',
1434
- orphans.length ? `${orphans.length} 个孤儿锁持有者(可 dsh_takeover)` : '没有孤儿锁',
1435
- );
1436
- } catch (e) {
1437
- add('orphan_locks', 'warn', e.message);
1438
- }
1439
-
1440
- const all = [...hub.conversations.values()];
1441
- const alive = all.filter((c) => c.proc?.alive);
1442
- // ★ 共享进程下多个会话共用一个 PID:必须按 PID 去重,否则同一份 RSS 会被按会话
1443
- // 重复累加,"RSS 合计"就会凭空翻几倍,把这行诊断变成误导。
1444
- const livePids = new Set(alive.map((c) => c.proc?.pid).filter(Boolean));
1445
- let totalRss = 0;
1446
- for (const pid of livePids) totalRss += rssMb(pid) ?? 0;
1447
- add(
1448
- 'sessions',
1449
- 'ok',
1450
- `共 ${all.length} 个会话,活进程 ${livePids.size} 个` +
1451
- (SHARED_PROCESS ? `(共享宿主 ${hub.hosts?.size ?? 0} 个)` : '(一会话一进程)') +
1452
- `,RSS 合计 ${totalRss}MB`,
1453
- );
1454
- add(
1455
- 'config',
1456
- 'ok',
1457
- `profile=${DSH_PROFILE} team=${TEAM_ACTIVE ? 'on' : 'off'} permission=${DEFAULT_PERMISSION} ` +
1458
- `idle_ttl=${IDLE_TTL_MS}ms max_live=${MAX_LIVE} max_rss=${MAX_RSS_MB}MB shared=${SHARED_PROCESS}`,
1459
- );
1460
- add(
1461
- 'team',
1462
- TEAM_ACTIVE ? 'ok' : TEAM_MODE ? 'warn' : 'ok',
1463
- TEAM_ACTIVE
1464
- ? `on(profile=${DSH_PROFILE})`
1465
- : TEAM_MODE
1466
- ? 'requested but unavailable:DSH < 0.1.7-0,或该 profile 不含 team 包'
1467
- : 'off(opt-in:dsh-any-agent team on)',
1468
- TEAM_ACTIVE ? null : TEAM_MODE ? '检查 DSH 版本 / profile,或 dsh-any-agent team off' : null,
1469
- );
1470
-
1471
- const ok = !checks.some((c) => c.status === 'fail');
1472
- const lines = checks.map(
1473
- (c) =>
1474
- `${c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : '✗'} ${c.id}: ${c.detail}` +
1475
- (c.fix ? ` → ${c.fix}` : ''),
1476
- );
1477
- return {
1478
- structured: { ok, verdict: ok ? 'ok' : 'fail', checks },
1479
- text: `${ok ? '体检通过' : '体检发现失败项'}\n${lines.join('\n')}`,
1480
- };
731
+ return runDoctor(hub);
1481
732
  },
1482
733
  };
1483
734
  }