dsh-any-agent 0.3.8 → 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/CHANGELOG.md +175 -2
- package/DESIGN.md +6 -6
- package/README.md +105 -27
- package/README.zh.md +90 -27
- package/USAGE-MIGRATION.en.md +7 -5
- package/USAGE-MIGRATION.md +3 -3
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +9 -9
- package/bin/restore.mjs +6 -2
- package/bin/setup.mjs +6 -0
- package/package.json +1 -1
- package/src/acp.mjs +39 -13
- package/src/audit.mjs +32 -0
- package/src/bootstrap.mjs +26 -1
- package/src/clients.mjs +13 -2
- package/src/config.mjs +137 -6
- package/src/host.mjs +148 -0
- package/src/hub.mjs +508 -92
- package/src/line-decoder.mjs +41 -0
- package/src/locks.mjs +6 -1
- package/src/mcp.mjs +3 -6
- package/src/procstat.mjs +72 -0
- package/src/tools.mjs +546 -29
- package/src/workspace.mjs +18 -7
package/src/tools.mjs
CHANGED
|
@@ -5,17 +5,32 @@
|
|
|
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
10
|
import {
|
|
9
11
|
APPROVAL_POLICIES,
|
|
10
12
|
DEFAULT_PERMISSION,
|
|
11
13
|
DEFAULT_REASONING,
|
|
12
14
|
DEFAULT_REASONING_EFFORT,
|
|
15
|
+
IDLE_TTL_MS,
|
|
16
|
+
MAX_LIVE,
|
|
17
|
+
MAX_RSS_MB,
|
|
13
18
|
PERMISSION_TIERS,
|
|
14
19
|
REASONING_EFFORTS,
|
|
15
20
|
REASONING_MODES,
|
|
21
|
+
RUNS_DIR,
|
|
22
|
+
SHARED_PROCESS,
|
|
23
|
+
STATE_FILE,
|
|
24
|
+
TEAM_ACTIVE,
|
|
25
|
+
TEAM_MODE,
|
|
26
|
+
DSH_PROFILE,
|
|
16
27
|
toPosixPath,
|
|
28
|
+
resolveDshBin,
|
|
17
29
|
} from './config.mjs';
|
|
18
|
-
import {
|
|
30
|
+
import { inspectProfile } from './bootstrap.mjs';
|
|
31
|
+
import { audit } from './audit.mjs';
|
|
32
|
+
import { listOrphans, LOCKS_DIR } from './locks.mjs';
|
|
33
|
+
import { rssMb } from './procstat.mjs';
|
|
19
34
|
|
|
20
35
|
const S = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false });
|
|
21
36
|
|
|
@@ -24,6 +39,26 @@ const CONV_ID = {
|
|
|
24
39
|
description: '会话 id,来自 dsh_start 或 dsh_list。',
|
|
25
40
|
};
|
|
26
41
|
|
|
42
|
+
/**
|
|
43
|
+
* timeout_ms 校验:只接受 >= 0 的有限数字(0 = 一直等)。
|
|
44
|
+
*
|
|
45
|
+
* 之前直接透传:'abc' → NaN → prompt 里 `NaN <= 0` 为假、setTimeout(NaN) 立即触发,
|
|
46
|
+
* 静默降级成后台收据;非有限值或负数一律报错,别让调用方以为自己在原地等。
|
|
47
|
+
*/
|
|
48
|
+
function parseTimeoutMs(raw) {
|
|
49
|
+
const n = Number(raw);
|
|
50
|
+
if (!Number.isFinite(n) || n < 0) throw new Error('timeout_ms 必须是 >= 0 的有限数字');
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 团队模式下注入到派发 prompt 前的一句策略。
|
|
56
|
+
* 只描述"需要时怎么做",不改变任务本身;简单任务不会因此组队。
|
|
57
|
+
*/
|
|
58
|
+
const TEAM_PREFIX =
|
|
59
|
+
'【团队】如果这个任务需要并行或多角色协作,先用 spawn_teammate 创建团队再执行;' +
|
|
60
|
+
'不需要就单 agent 完成。不要为简单任务组队。\n\n';
|
|
61
|
+
|
|
27
62
|
/** 工具清单(JSON Schema)。 */
|
|
28
63
|
export const TOOL_DEFS = [
|
|
29
64
|
{
|
|
@@ -69,6 +104,13 @@ export const TOOL_DEFS = [
|
|
|
69
104
|
},
|
|
70
105
|
provider: { type: 'string', description: '模型提供方名(如 deepseek-official)。留空用 profile 默认。' },
|
|
71
106
|
model: { type: 'string', description: '模型名(如 deepseek-flash)。指定时必须同时给 provider。' },
|
|
107
|
+
keep_alive: {
|
|
108
|
+
type: 'boolean',
|
|
109
|
+
description:
|
|
110
|
+
'true = 这个会话的进程不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。' +
|
|
111
|
+
'默认 false:空闲 10 分钟后进程会被回收、内存释放,下次派活自动 resume(对话不丢)。' +
|
|
112
|
+
'设 true 时请记得用完 `dsh_release`,否则它会一直占着一个 DSH 进程(约 300–480MB)。',
|
|
113
|
+
},
|
|
72
114
|
},
|
|
73
115
|
// cwd 不再是必填:有 MCP roots 的客户端会自动带上当前项目目录。
|
|
74
116
|
[],
|
|
@@ -77,7 +119,9 @@ export const TOOL_DEFS = [
|
|
|
77
119
|
{
|
|
78
120
|
name: 'dsh_send',
|
|
79
121
|
description:
|
|
80
|
-
'
|
|
122
|
+
'向会话派一个任务。**给目标,不要给命令**:DSH 自己决定用哪些文件/shell/搜索/技能/子代理/团队工具,' +
|
|
123
|
+
'本服务不预先加载或代理 DSH 的工具面。' +
|
|
124
|
+
'**默认不阻塞(wait=false)**:立即返回 run_id + sentinel_file,' +
|
|
81
125
|
'回合在后台跑,跑完自动通知你 —— 你继续做别的事,也永远不用担心任务被"超时"打断。' +
|
|
82
126
|
'想原地等结果就显式传 wait=true(可配 timeout_ms;到期只会转后台,不会取消回合)。' +
|
|
83
127
|
'【超时不会毁掉任务】wait=true 到达 timeout_ms 仍没跑完时,本工具**不会报错、也不会取消回合**,' +
|
|
@@ -107,6 +151,24 @@ export const TOOL_DEFS = [
|
|
|
107
151
|
description:
|
|
108
152
|
'仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
|
|
109
153
|
},
|
|
154
|
+
queue: {
|
|
155
|
+
type: 'boolean',
|
|
156
|
+
description:
|
|
157
|
+
'会话正忙时是否入队(默认 false = 直接报错)。true 时立即返回 run_id + queue_position,' +
|
|
158
|
+
'本轮结束后自动执行;哨兵只在真正结束时写,入队时不写。队列仅内存、重启丢失,用 dsh_queue 管理。',
|
|
159
|
+
},
|
|
160
|
+
client_run_id: {
|
|
161
|
+
type: 'string',
|
|
162
|
+
description:
|
|
163
|
+
'调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交同一个键不会创建第二个 run,' +
|
|
164
|
+
'而是返回首次 run 的结果或收据(replayed=true)。用于"响应在传输层丢了、想安全重发"的场景。',
|
|
165
|
+
},
|
|
166
|
+
team: {
|
|
167
|
+
type: 'boolean',
|
|
168
|
+
description:
|
|
169
|
+
'是否在本次派发前注入"需要并行/多角色时先建团队"的提示(默认跟随 DSH_MCP_TEAM,0.4.0 起默认开)。' +
|
|
170
|
+
'传 false 只对这一次关闭;彻底关闭用 DSH_MCP_TEAM=0。',
|
|
171
|
+
},
|
|
110
172
|
},
|
|
111
173
|
['conversation_id', 'prompt'],
|
|
112
174
|
),
|
|
@@ -162,6 +224,16 @@ export const TOOL_DEFS = [
|
|
|
162
224
|
{
|
|
163
225
|
conversation_id: CONV_ID,
|
|
164
226
|
run_id: { type: 'string', description: 'dsh_send 返回的 run_id;不传则取最近一次回合。' },
|
|
227
|
+
client_run_id: {
|
|
228
|
+
type: 'string',
|
|
229
|
+
description: '按调用方幂等键取 run(与 run_id 二选一;只对仍在内存里的 run 有效,最多最近 20 条)。',
|
|
230
|
+
},
|
|
231
|
+
max_chars: {
|
|
232
|
+
type: 'number',
|
|
233
|
+
description:
|
|
234
|
+
'正文在 **text** 里的最大字符数(默认 0 = 不截断)。' +
|
|
235
|
+
'structured.run.result 仍保留完整内容;截断时附带 answer_truncated/next_offset,可自行分页。',
|
|
236
|
+
},
|
|
165
237
|
},
|
|
166
238
|
['conversation_id'],
|
|
167
239
|
),
|
|
@@ -189,6 +261,10 @@ export const TOOL_DEFS = [
|
|
|
189
261
|
description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
|
|
190
262
|
},
|
|
191
263
|
timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
|
|
264
|
+
client_run_id: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
description: '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交返回首次的 run,不会重复插话。',
|
|
267
|
+
},
|
|
192
268
|
},
|
|
193
269
|
['conversation_id', 'message'],
|
|
194
270
|
),
|
|
@@ -266,6 +342,42 @@ export const TOOL_DEFS = [
|
|
|
266
342
|
description: '轻量状态查询:会话是否活着、是否在忙、待决审批数、最近一次回合的结果。',
|
|
267
343
|
inputSchema: S({ conversation_id: CONV_ID }, ['conversation_id']),
|
|
268
344
|
},
|
|
345
|
+
{
|
|
346
|
+
name: 'dsh_inbox',
|
|
347
|
+
description:
|
|
348
|
+
'**跨会话的待决审批收件箱**(dsh_status 只看单个会话)。' +
|
|
349
|
+
'一次列出所有会话里等你裁决的审批:会话 id/标题、审批 id、工具、原因、命令/路径摘要、等待时长。' +
|
|
350
|
+
'裁决用 dsh_approval_decide。没有待决审批时返回空列表。',
|
|
351
|
+
inputSchema: S(
|
|
352
|
+
{
|
|
353
|
+
conversation_id: { type: 'string', description: '只看某个会话(省略 = 全部)' },
|
|
354
|
+
include_input: { type: 'boolean', description: '是否带操作输入摘要(默认 true)' },
|
|
355
|
+
},
|
|
356
|
+
[],
|
|
357
|
+
),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: 'dsh_queue',
|
|
361
|
+
description:
|
|
362
|
+
'查看/修改会话的待执行队列(由 dsh_send(queue=true) 产生;**仅内存**,重启丢失)。' +
|
|
363
|
+
'action=list 列出;remove 取消并写 cancelled 哨兵;update 改写排队文本;promote 提到队首。',
|
|
364
|
+
inputSchema: S(
|
|
365
|
+
{
|
|
366
|
+
conversation_id: CONV_ID,
|
|
367
|
+
action: { type: 'string', enum: ['list', 'remove', 'update', 'promote'] },
|
|
368
|
+
run_id: { type: 'string', description: 'remove / update / promote 必填' },
|
|
369
|
+
text: { type: 'string', description: 'update 时的新文本' },
|
|
370
|
+
},
|
|
371
|
+
['conversation_id', 'action'],
|
|
372
|
+
),
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'dsh_doctor',
|
|
376
|
+
description:
|
|
377
|
+
'只读体检:DSH 可执行文件与版本、profile 是否存在/漂移、状态文件、runs/locks 目录可写、孤儿锁、' +
|
|
378
|
+
'当前会话数与 RSS、生效配置。只报告不修改;ok=false 表示存在 fail 项。',
|
|
379
|
+
inputSchema: S({}, []),
|
|
380
|
+
},
|
|
269
381
|
];
|
|
270
382
|
|
|
271
383
|
/** 把结构化结果渲染成给模型看的文本。 */
|
|
@@ -292,7 +404,7 @@ function renderSendResult(r) {
|
|
|
292
404
|
}
|
|
293
405
|
const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
|
|
294
406
|
if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
|
|
295
|
-
if (r.workspace_changed) meta.push(
|
|
407
|
+
if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
|
|
296
408
|
lines.push('');
|
|
297
409
|
lines.push(`[${meta.join(' · ')}]`);
|
|
298
410
|
|
|
@@ -342,6 +454,63 @@ function backgroundReceipt(hub, conv, res) {
|
|
|
342
454
|
};
|
|
343
455
|
}
|
|
344
456
|
|
|
457
|
+
/**
|
|
458
|
+
* 幂等命中:返回同一个 client_run_id 首次对应 run 的结果或收据。
|
|
459
|
+
* 绝不创建第二个 run —— 这正是"响应在传输层丢了、想安全重发"需要的语义。
|
|
460
|
+
*/
|
|
461
|
+
function replayRun(hub, conv, run, wantResult) {
|
|
462
|
+
const sentinel = hub.runSentinelPath(conv.id, run.run_id);
|
|
463
|
+
const sentinelPosix = toPosixPath(sentinel);
|
|
464
|
+
const terminal = run.status === 'done' || run.status === 'error' || run.status === 'cancelled';
|
|
465
|
+
if (terminal && run.result) {
|
|
466
|
+
return {
|
|
467
|
+
structured: { ...run.result, replayed: true, ...(wantResult ? {} : { background: true }) },
|
|
468
|
+
text:
|
|
469
|
+
`(幂等命中:client_run_id 已对应一个已结束的回合,未重复派活)\n` +
|
|
470
|
+
renderSendResult(run.result) +
|
|
471
|
+
`\n哨兵文件: ${sentinel}`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
structured: {
|
|
476
|
+
conversation_id: conv.id,
|
|
477
|
+
run_id: run.run_id,
|
|
478
|
+
status: run.status,
|
|
479
|
+
accepted: true,
|
|
480
|
+
background: true,
|
|
481
|
+
still_running: true,
|
|
482
|
+
replayed: true,
|
|
483
|
+
sentinel_file: sentinel,
|
|
484
|
+
sentinel_file_posix: sentinelPosix,
|
|
485
|
+
},
|
|
486
|
+
text:
|
|
487
|
+
`(幂等命中:同一个 client_run_id 已在跑或排队,未重复派活)run_id=${run.run_id} status=${run.status}\n` +
|
|
488
|
+
`哨兵文件: ${sentinel}`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 入队回执:入队时不写哨兵,真正结束才写。 */
|
|
493
|
+
function queuedReceipt(hub, conv, run, position) {
|
|
494
|
+
const sentinel = hub.runSentinelPath(conv.id, run.run_id);
|
|
495
|
+
const sentinelPosix = toPosixPath(sentinel);
|
|
496
|
+
return {
|
|
497
|
+
structured: {
|
|
498
|
+
conversation_id: conv.id,
|
|
499
|
+
run_id: run.run_id,
|
|
500
|
+
accepted: true,
|
|
501
|
+
queued: true,
|
|
502
|
+
queue_position: position,
|
|
503
|
+
status: 'queued',
|
|
504
|
+
sentinel_file: sentinel,
|
|
505
|
+
sentinel_file_posix: sentinelPosix,
|
|
506
|
+
},
|
|
507
|
+
text:
|
|
508
|
+
`已入队(第 ${position} 位)。当前回合结束后自动执行;**入队时不会写哨兵**,真正结束时才写:\n` +
|
|
509
|
+
` ${sentinel}\n` +
|
|
510
|
+
`用 dsh_queue list 查看/修改/删除;取消:dsh_queue(conversation_id, action="remove", run_id="${run.run_id}")。`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
345
514
|
/**
|
|
346
515
|
* @param {object} hub
|
|
347
516
|
* @param {{projectDir?: () => Promise<string|null>}} [opts]
|
|
@@ -372,8 +541,14 @@ export function createHandlers(hub, opts = {}) {
|
|
|
372
541
|
reasoningEffort: args.reasoning_effort,
|
|
373
542
|
provider: args.provider,
|
|
374
543
|
model: args.model,
|
|
544
|
+
keepAlive: args.keep_alive === true,
|
|
375
545
|
});
|
|
376
546
|
if (args.title_hint) conv.titleHint = args.title_hint;
|
|
547
|
+
audit('start', {
|
|
548
|
+
conversation_id: conv.id,
|
|
549
|
+
permission: conv.permission,
|
|
550
|
+
keep_alive: conv.keepAlive === true,
|
|
551
|
+
});
|
|
377
552
|
const snap = conv.snapshot();
|
|
378
553
|
const lines = [
|
|
379
554
|
'已创建会话。',
|
|
@@ -381,6 +556,14 @@ export function createHandlers(hub, opts = {}) {
|
|
|
381
556
|
`工作区: ${snap.cwd}` + (cwdSource === 'client-root' ? '(取自客户端当前打开的项目)' : ''),
|
|
382
557
|
`权限档: ${snap.permission}(审批策略 ${snap.on_approval})`,
|
|
383
558
|
`思考深度: ${snap.reasoning_effort}`,
|
|
559
|
+
snap.keep_alive
|
|
560
|
+
? '保活: 是(不受空闲 TTL / MAX_LIVE 回收;用完请 dsh_release)'
|
|
561
|
+
: '保活: 否(空闲后可被回收,下次派活自动 resume)',
|
|
562
|
+
`团队模式: ${
|
|
563
|
+
TEAM_ACTIVE
|
|
564
|
+
? `开(profile=${DSH_PROFILE};派发时会提示"需要时先建团队",简单任务不会组队)`
|
|
565
|
+
: '关(DSH_MCP_TEAM=0 或 DSH < 0.1.7)'
|
|
566
|
+
}`,
|
|
384
567
|
`对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}` +
|
|
385
568
|
(snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
|
|
386
569
|
`工作区登记: ${snap.workspace_registered ? '已写入 DSH 工作区注册表(GUI 里能看到)' : '未登记'}`,
|
|
@@ -405,28 +588,63 @@ export function createHandlers(hub, opts = {}) {
|
|
|
405
588
|
}
|
|
406
589
|
const conv = await hub.get(args.conversation_id);
|
|
407
590
|
const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
|
|
408
|
-
if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
|
|
591
|
+
if (args.timeout_ms !== undefined && args.timeout_ms !== null) opts.timeoutMs = parseTimeoutMs(args.timeout_ms);
|
|
409
592
|
|
|
410
|
-
// ──
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
593
|
+
// ── 幂等:同一个 client_run_id 直接返回首次的 run,不重复执行 ──
|
|
594
|
+
if (args.client_run_id) {
|
|
595
|
+
if (String(args.client_run_id).length > 128) throw new Error('client_run_id 最长 128 字符');
|
|
596
|
+
const existing = conv.getRunByClientId(args.client_run_id);
|
|
597
|
+
if (existing) {
|
|
598
|
+
audit('send_replay', {
|
|
599
|
+
conversation_id: conv.id,
|
|
600
|
+
run_id: existing.run_id,
|
|
601
|
+
client_run_id: args.client_run_id,
|
|
602
|
+
});
|
|
603
|
+
return replayRun(hub, conv, existing, args.wait === true);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 团队提示:只描述"需要时怎么做",不改变任务本身;简单任务不会因此组队。
|
|
608
|
+
const teamOn = TEAM_ACTIVE && args.team !== false;
|
|
609
|
+
const promptText = teamOn ? TEAM_PREFIX + args.prompt : args.prompt;
|
|
610
|
+
|
|
611
|
+
// ── 忙时:显式 queue=true 才入队;否则保持"直接报错"的旧契约 ──
|
|
612
|
+
if (conv.busy) {
|
|
613
|
+
if (args.queue === true && args.wait !== true) {
|
|
614
|
+
const { run, position, deduped } = conv.enqueue(promptText, {
|
|
615
|
+
reasoning: opts.reasoning,
|
|
616
|
+
clientRunId: args.client_run_id ?? null,
|
|
617
|
+
preview: args.prompt,
|
|
618
|
+
});
|
|
619
|
+
if (deduped) return replayRun(hub, conv, run, false);
|
|
620
|
+
audit('send_queued', {
|
|
621
|
+
conversation_id: conv.id,
|
|
622
|
+
run_id: run.run_id,
|
|
623
|
+
position,
|
|
624
|
+
client_run_id: args.client_run_id ?? null,
|
|
625
|
+
});
|
|
626
|
+
return queuedReceipt(hub, conv, run, position);
|
|
417
627
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const res = await conv.prompt(args.prompt, { ...opts, run, wait: false });
|
|
423
|
-
conv.lastResult = res;
|
|
424
|
-
return res.still_running ? backgroundReceipt(hub, conv, res) : { structured: res, text: renderSendResult(res) };
|
|
628
|
+
throw new Error(
|
|
629
|
+
'会话正在跑另一个回合,无法再排一个。可以:dsh_interject(插话)、dsh_interrupt(打断)后重试,' +
|
|
630
|
+
'或 dsh_send(queue=true) 入队。',
|
|
631
|
+
);
|
|
425
632
|
}
|
|
426
633
|
|
|
427
|
-
|
|
428
|
-
|
|
634
|
+
const wait = args.wait === true;
|
|
635
|
+
// 记录与预览保留调用方原文;实际派发用 promptText(可能带团队提示)。
|
|
636
|
+
const run = conv.createRun(args.prompt, { clientRunId: args.client_run_id ?? null });
|
|
637
|
+
// prompt() 自己负责收尾与写哨兵。这里 await 是为了**同步暴露 resume/启动失败** ——
|
|
638
|
+
// 否则调用方会拿到一个 accepted 但注定失败的收据(冷启动要几秒,热会话瞬时)。
|
|
639
|
+
const r = await conv.prompt(promptText, { ...opts, run, wait });
|
|
429
640
|
conv.lastResult = r;
|
|
641
|
+
audit('send', {
|
|
642
|
+
conversation_id: conv.id,
|
|
643
|
+
run_id: r?.run_id ?? run.run_id,
|
|
644
|
+
wait,
|
|
645
|
+
still_running: Boolean(r?.still_running),
|
|
646
|
+
client_run_id: args.client_run_id ?? null,
|
|
647
|
+
});
|
|
430
648
|
if (r?.still_running) return backgroundReceipt(hub, conv, r);
|
|
431
649
|
return { structured: r, text: renderSendResult(r) };
|
|
432
650
|
},
|
|
@@ -457,6 +675,9 @@ export function createHandlers(hub, opts = {}) {
|
|
|
457
675
|
bits.push(`cwd=${c.cwd}`);
|
|
458
676
|
if (c.permission) bits.push(`权限=${c.permission}`);
|
|
459
677
|
if (c.turns != null) bits.push(`轮数=${c.turns}`);
|
|
678
|
+
if (c.rss_mb != null) bits.push(`rss=${c.rss_mb}MB`);
|
|
679
|
+
if (c.keep_alive) bits.push('保活');
|
|
680
|
+
if (c.queue_length) bits.push(`队列=${c.queue_length}`);
|
|
460
681
|
return `- ${bits.join(' | ')}`;
|
|
461
682
|
};
|
|
462
683
|
const probe = hub.lastProbe ?? { ok: null, error: null, cached: false };
|
|
@@ -518,6 +739,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
518
739
|
const conv = await hub.get(args.conversation_id);
|
|
519
740
|
const snap = conv.snapshot();
|
|
520
741
|
let run = null;
|
|
742
|
+
let runSource = null;
|
|
521
743
|
const meta = [];
|
|
522
744
|
// DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
|
|
523
745
|
const titleKind = snap.title_source === 'native'
|
|
@@ -535,40 +757,111 @@ export function createHandlers(hub, opts = {}) {
|
|
|
535
757
|
if (snap.usage) meta.push(`token: in=${snap.usage.uncachedInputTokens ?? 0} out=${snap.usage.outputTokens ?? 0} cache=${snap.usage.cacheReadTokens ?? 0}`);
|
|
536
758
|
if (snap.context_pressure) meta.push(`上下文压力: ${snap.context_pressure.pressureTokens}/${snap.context_pressure.contextWindow}`);
|
|
537
759
|
if (snap.pending_approvals?.length) meta.push(`待决审批: ${snap.pending_approvals.join(', ')}`);
|
|
760
|
+
if (snap.queue_length) meta.push(`队列: ${snap.queue_length} 条待执行(dsh_queue list)`);
|
|
538
761
|
for (const detail of snap.pending_approval_details ?? []) {
|
|
539
762
|
const summary = [detail.title ?? detail.tool, detail.reason ?? detail.description].filter(Boolean).join(' — ');
|
|
540
763
|
meta.push(` ${detail.approval_id}: ${summary.slice(0, 300)}(操作输入见 pending_approval_details)`);
|
|
541
764
|
}
|
|
542
|
-
|
|
765
|
+
if (args.client_run_id) {
|
|
766
|
+
run = conv.getRunByClientId(args.client_run_id);
|
|
767
|
+
if (!run) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`未知 client_run_id: ${args.client_run_id}(只在内存里保留最近 20 条;已淘汰/服务重启后请用 run_id 或直接读哨兵文件)`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
runSource = 'client_run_id';
|
|
773
|
+
} else if (args.run_id) {
|
|
774
|
+
try {
|
|
775
|
+
run = conv.getRun(args.run_id);
|
|
776
|
+
} catch {
|
|
777
|
+
run = null;
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
run = conv.getRun();
|
|
781
|
+
}
|
|
782
|
+
if (run && !runSource) runSource = 'memory';
|
|
783
|
+
// ★ 内存只留最近 MAX_RUNS 条;服务重启或被挤出后,run_id 只能从哨兵文件回读。
|
|
784
|
+
// 哨兵是"一个回合 = 一个文件"的落盘权威,把它接进入口,调用方不必自己拼路径。
|
|
785
|
+
if (!run && args.run_id) {
|
|
786
|
+
const file = hub.runSentinelPath(args.conversation_id, args.run_id);
|
|
787
|
+
try {
|
|
788
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
789
|
+
run = {
|
|
790
|
+
run_id: raw.run_id ?? args.run_id,
|
|
791
|
+
status: raw.status ?? 'unknown',
|
|
792
|
+
started_at: null,
|
|
793
|
+
ended_at: raw.ended_at ?? null,
|
|
794
|
+
elapsed_ms: null,
|
|
795
|
+
prompt_preview: null,
|
|
796
|
+
result: raw.result ?? null,
|
|
797
|
+
};
|
|
798
|
+
runSource = 'sentinel';
|
|
799
|
+
} catch {
|
|
800
|
+
// 文件不存在或不可解析:保持 run=null,下面给明确报错
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (args.run_id && !run) {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`未知 run_id: ${args.run_id}(内存只留最近 20 条;哨兵文件 ${hub.runSentinelPath(args.conversation_id, args.run_id)} 也不存在或不可读)`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
// 正文预算只作用于 text;structured 里保留完整 result,调用方要做程序化处理时仍然拿得到。
|
|
809
|
+
const answerFull = String(run?.result?.answer ?? '');
|
|
810
|
+
const capRaw = Number(args.max_chars);
|
|
811
|
+
const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 0;
|
|
812
|
+
const truncated = cap > 0 && answerFull.length > cap;
|
|
543
813
|
if (run) {
|
|
544
814
|
meta.push('');
|
|
545
815
|
meta.push(
|
|
546
816
|
`回合 ${run.run_id}: 状态=${run.status}` +
|
|
817
|
+
(runSource === 'sentinel' ? '(来自哨兵文件;内存记录已过期或服务重启过)' : '') +
|
|
547
818
|
(run.elapsed_ms != null ? ` 耗时=${(run.elapsed_ms / 1000).toFixed(1)}s` : '') +
|
|
548
819
|
(run.status === 'running' ? '(还在跑,可继续用 dsh_read 看输出)' : ''),
|
|
549
820
|
);
|
|
550
|
-
meta.push(` 指令: ${run.prompt_preview}`);
|
|
821
|
+
if (run.prompt_preview) meta.push(` 指令: ${run.prompt_preview}`);
|
|
551
822
|
if (run.result?.answer) {
|
|
552
|
-
|
|
553
|
-
|
|
823
|
+
if (truncated) {
|
|
824
|
+
meta.push(` 最终答复(已截断,共 ${answerFull.length} 字):`);
|
|
825
|
+
meta.push(answerFull.slice(0, cap));
|
|
826
|
+
meta.push(` …(余下 ${answerFull.length - cap} 字;max_chars=0 取全文)`);
|
|
827
|
+
} else {
|
|
828
|
+
meta.push(' 最终答复:');
|
|
829
|
+
meta.push(answerFull);
|
|
830
|
+
}
|
|
554
831
|
}
|
|
555
832
|
if (run.result?.thinking_stats?.chars) {
|
|
556
833
|
meta.push(` (思考 ${run.result.thinking_stats.chars} 字已隐藏)`);
|
|
557
834
|
}
|
|
558
835
|
if (run.result?.error) meta.push(` ⚠️ 失败: ${run.result.error}`);
|
|
559
836
|
}
|
|
837
|
+
const sentinelFile = run ? hub.runSentinelPath(args.conversation_id, run.run_id) : null;
|
|
560
838
|
const runView = run
|
|
561
839
|
? {
|
|
562
840
|
run_id: run.run_id,
|
|
841
|
+
client_run_id: run.client_run_id ?? null,
|
|
842
|
+
source: runSource,
|
|
563
843
|
status: run.status,
|
|
564
844
|
started_at: run.started_at ? new Date(run.started_at).toISOString() : null,
|
|
565
845
|
ended_at: run.ended_at ? new Date(run.ended_at).toISOString() : null,
|
|
566
846
|
elapsed_ms: run.elapsed_ms,
|
|
567
847
|
prompt_preview: run.prompt_preview,
|
|
848
|
+
answer_chars: answerFull.length,
|
|
849
|
+
answer_truncated: truncated,
|
|
850
|
+
next_offset: truncated ? cap : null,
|
|
568
851
|
result: run.result,
|
|
569
852
|
}
|
|
570
853
|
: null;
|
|
571
|
-
|
|
854
|
+
if (sentinelFile) meta.push(`\n哨兵文件: ${sentinelFile}`);
|
|
855
|
+
return {
|
|
856
|
+
structured: {
|
|
857
|
+
...snap,
|
|
858
|
+
run: runView,
|
|
859
|
+
...(sentinelFile
|
|
860
|
+
? { sentinel_file: sentinelFile, sentinel_file_posix: toPosixPath(sentinelFile) }
|
|
861
|
+
: {}),
|
|
862
|
+
},
|
|
863
|
+
text: meta.join('\n'),
|
|
864
|
+
};
|
|
572
865
|
},
|
|
573
866
|
|
|
574
867
|
async dsh_interject(args) {
|
|
@@ -576,10 +869,32 @@ export function createHandlers(hub, opts = {}) {
|
|
|
576
869
|
throw new Error('message 不能为空:至少包含一个非空白字符');
|
|
577
870
|
}
|
|
578
871
|
const conv = await hub.get(args.conversation_id);
|
|
579
|
-
|
|
580
|
-
|
|
872
|
+
if (args.client_run_id) {
|
|
873
|
+
if (String(args.client_run_id).length > 128) throw new Error('client_run_id 最长 128 字符');
|
|
874
|
+
const existing = conv.getRunByClientId(args.client_run_id);
|
|
875
|
+
if (existing) {
|
|
876
|
+
audit('interject_replay', {
|
|
877
|
+
conversation_id: conv.id,
|
|
878
|
+
run_id: existing.run_id,
|
|
879
|
+
client_run_id: args.client_run_id,
|
|
880
|
+
});
|
|
881
|
+
return replayRun(hub, conv, existing, true);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const opts = {
|
|
885
|
+
mode: args.mode ?? 'interject',
|
|
886
|
+
reasoning: args.reasoning ?? DEFAULT_REASONING,
|
|
887
|
+
clientRunId: args.client_run_id ?? null,
|
|
888
|
+
};
|
|
889
|
+
if (args.timeout_ms !== undefined && args.timeout_ms !== null) opts.timeoutMs = parseTimeoutMs(args.timeout_ms);
|
|
581
890
|
const r = await conv.interject(args.message, opts);
|
|
582
891
|
conv.lastResult = r;
|
|
892
|
+
audit('interject', {
|
|
893
|
+
conversation_id: conv.id,
|
|
894
|
+
run_id: r?.run_id ?? null,
|
|
895
|
+
mode: r?.interjected ?? opts.mode,
|
|
896
|
+
client_run_id: args.client_run_id ?? null,
|
|
897
|
+
});
|
|
583
898
|
const head =
|
|
584
899
|
r.interjected === 'idle'
|
|
585
900
|
? '会话当时空闲,已按普通回合执行。'
|
|
@@ -602,6 +917,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
602
917
|
async dsh_interrupt(args) {
|
|
603
918
|
const conv = await hub.get(args.conversation_id);
|
|
604
919
|
const r = await conv.interrupt();
|
|
920
|
+
audit('interrupt', { conversation_id: conv.id, interrupted: Boolean(r.interrupted) });
|
|
605
921
|
return { structured: r, text: r.interrupted ? '已发送中断请求。' : `未中断:${r.reason}` };
|
|
606
922
|
},
|
|
607
923
|
|
|
@@ -610,18 +926,27 @@ export function createHandlers(hub, opts = {}) {
|
|
|
610
926
|
// 无损:会话日志都在磁盘上,之后照样 resume。
|
|
611
927
|
if (args.all === true) {
|
|
612
928
|
const r = await hub.releaseAll({ force: args.force === true });
|
|
929
|
+
audit('release_all', { released: r.released, failed: r.failed.length, skipped_busy: r.skipped_busy.length });
|
|
613
930
|
return {
|
|
614
|
-
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
|
|
931
|
+
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy, failed: r.failed },
|
|
615
932
|
text:
|
|
616
933
|
`已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
|
|
617
934
|
(r.skipped_busy.length
|
|
618
935
|
? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
|
|
619
936
|
: '') +
|
|
937
|
+
(r.failed.length
|
|
938
|
+
? `\n⚠️ ${r.failed.length} 个会话的进程未确认退出,写锁登记已保留;可稍后用 dsh_status/dsh_takeover 处理。`
|
|
939
|
+
: '') +
|
|
620
940
|
`\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
|
|
621
941
|
};
|
|
622
942
|
}
|
|
623
943
|
const conv = await hub.get(args.conversation_id);
|
|
624
944
|
const r = await conv.close({ forget: args.forget === true });
|
|
945
|
+
audit('release', {
|
|
946
|
+
conversation_id: conv.id,
|
|
947
|
+
forgotten: Boolean(r.forgotten),
|
|
948
|
+
deleted: Boolean(r.deleted),
|
|
949
|
+
});
|
|
625
950
|
return {
|
|
626
951
|
structured: { conversation_id: args.conversation_id, ...r },
|
|
627
952
|
text: r.forgotten
|
|
@@ -633,13 +958,28 @@ export function createHandlers(hub, opts = {}) {
|
|
|
633
958
|
async dsh_takeover(args) {
|
|
634
959
|
const conv = await hub.get(args.conversation_id);
|
|
635
960
|
const r = await conv.takeover({ force: args.force === true });
|
|
961
|
+
audit('takeover', { conversation_id: conv.id, ok: Boolean(r.ok), forced: args.force === true });
|
|
636
962
|
return { structured: r, text: `${r.ok ? '✅' : '❌'} ${r.message}` };
|
|
637
963
|
},
|
|
638
964
|
|
|
639
965
|
async dsh_approval_decide(args) {
|
|
640
966
|
const conv = await hub.get(args.conversation_id);
|
|
641
967
|
const r = conv.decideApproval(args.approval_id, args.decision);
|
|
642
|
-
|
|
968
|
+
audit('approval_decide', {
|
|
969
|
+
conversation_id: conv.id,
|
|
970
|
+
approval_id: r.approval_id,
|
|
971
|
+
decision: r.decision,
|
|
972
|
+
effective: r.effective,
|
|
973
|
+
});
|
|
974
|
+
// ★ 只有 effective=selected 才是真的把决定送达了 DSH。
|
|
975
|
+
// 没有对应 option 时 ACP 收到的是 cancelled,绝不能渲染成"已裁决为 allow"。
|
|
976
|
+
const ok = r.effective === 'selected';
|
|
977
|
+
return {
|
|
978
|
+
structured: r,
|
|
979
|
+
text: ok
|
|
980
|
+
? `审批 ${r.approval_id} 已裁决为 ${r.decision}。`
|
|
981
|
+
: `审批 ${r.approval_id} 未生效:DSH 没有提供 ${r.decision} 对应的选项,已按取消处理(effective=cancelled)。`,
|
|
982
|
+
};
|
|
643
983
|
},
|
|
644
984
|
|
|
645
985
|
async dsh_status(args) {
|
|
@@ -654,6 +994,9 @@ export function createHandlers(hub, opts = {}) {
|
|
|
654
994
|
}
|
|
655
995
|
if (run) bits.push(`最近回合=${run.run_id}(${run.status})`);
|
|
656
996
|
bits.push(`待决审批=${snap.pending_approvals.length}`);
|
|
997
|
+
if (snap.rss_mb != null) bits.push(`rss=${snap.rss_mb}MB`);
|
|
998
|
+
if (snap.keep_alive) bits.push('保活=是');
|
|
999
|
+
if (snap.queue_length) bits.push(`队列=${snap.queue_length}`);
|
|
657
1000
|
// 写锁在谁手上:区分"自己握着"和"被别人占着",后者直接给出可执行动作
|
|
658
1001
|
if (snap.lock_holder && snap.lock_holder !== 'self') {
|
|
659
1002
|
const hint =
|
|
@@ -688,6 +1031,9 @@ export function createHandlers(hub, opts = {}) {
|
|
|
688
1031
|
pending_approvals: snap.pending_approvals,
|
|
689
1032
|
pending_approval_details: snap.pending_approval_details,
|
|
690
1033
|
turns: snap.turns,
|
|
1034
|
+
rss_mb: snap.rss_mb,
|
|
1035
|
+
keep_alive: snap.keep_alive,
|
|
1036
|
+
queue_length: snap.queue_length,
|
|
691
1037
|
lock_holder: snap.lock_holder,
|
|
692
1038
|
lock_holder_reason: snap.lock_holder_reason,
|
|
693
1039
|
lock_conflict: snap.lock_conflict,
|
|
@@ -704,5 +1050,176 @@ export function createHandlers(hub, opts = {}) {
|
|
|
704
1050
|
text: bits.join(' | '),
|
|
705
1051
|
};
|
|
706
1052
|
},
|
|
1053
|
+
|
|
1054
|
+
async dsh_inbox(args) {
|
|
1055
|
+
const convs = args.conversation_id
|
|
1056
|
+
? [await hub.get(args.conversation_id)]
|
|
1057
|
+
: [...hub.conversations.values()];
|
|
1058
|
+
const items = [];
|
|
1059
|
+
for (const conv of convs) {
|
|
1060
|
+
const snap = conv.snapshot();
|
|
1061
|
+
for (const d of snap.pending_approval_details ?? []) {
|
|
1062
|
+
items.push({
|
|
1063
|
+
conversation_id: snap.conversation_id,
|
|
1064
|
+
title: snap.title ?? null,
|
|
1065
|
+
cwd: snap.cwd,
|
|
1066
|
+
approval_id: d.approval_id,
|
|
1067
|
+
tool: d.tool ?? null,
|
|
1068
|
+
reason: d.reason ?? d.description ?? null,
|
|
1069
|
+
created_at: d.created_at ?? null,
|
|
1070
|
+
age_ms: d.created_at ? Date.now() - d.created_at : null,
|
|
1071
|
+
options: (d.options ?? []).map((o) => ({
|
|
1072
|
+
option_id: o.optionId,
|
|
1073
|
+
kind: o.kind,
|
|
1074
|
+
name: o.name,
|
|
1075
|
+
})),
|
|
1076
|
+
...(args.include_input === false ? {} : { input: d.input ?? null }),
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
items.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
|
1081
|
+
const lines = items.map(
|
|
1082
|
+
(i) =>
|
|
1083
|
+
`- [${i.conversation_id}] ${i.title ?? '(无标题)'} | ${i.approval_id} | ${i.tool ?? '?'} | ` +
|
|
1084
|
+
`${i.reason ?? ''} | 等待=${i.age_ms == null ? '?' : `${Math.round(i.age_ms / 1000)}s`}`,
|
|
1085
|
+
);
|
|
1086
|
+
return {
|
|
1087
|
+
structured: { count: items.length, approvals: items },
|
|
1088
|
+
text: items.length
|
|
1089
|
+
? `${items.length} 个待决审批:\n${lines.join('\n')}\n\n` +
|
|
1090
|
+
`裁决:dsh_approval_decide(conversation_id, approval_id, decision)`
|
|
1091
|
+
: '(没有待决审批)',
|
|
1092
|
+
};
|
|
1093
|
+
},
|
|
1094
|
+
|
|
1095
|
+
async dsh_queue(args) {
|
|
1096
|
+
const conv = await hub.get(args.conversation_id);
|
|
1097
|
+
switch (args.action) {
|
|
1098
|
+
case 'list': {
|
|
1099
|
+
const items = conv.queueList();
|
|
1100
|
+
return {
|
|
1101
|
+
structured: { count: items.length, queue: items },
|
|
1102
|
+
text: items.length
|
|
1103
|
+
? `${items.length} 条排队:\n` +
|
|
1104
|
+
items.map((i) => `- #${i.position} ${i.run_id} | ${i.preview}`).join('\n')
|
|
1105
|
+
: '(队列为空)',
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
case 'remove': {
|
|
1109
|
+
if (!args.run_id) throw new Error('remove 需要 run_id');
|
|
1110
|
+
const r = conv.queueRemove(args.run_id);
|
|
1111
|
+
audit('queue_remove', { conversation_id: conv.id, run_id: args.run_id });
|
|
1112
|
+
return {
|
|
1113
|
+
structured: r,
|
|
1114
|
+
text: `已从队列移除 ${args.run_id}(已写 cancelled 哨兵,等待方不会空等)`,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
case 'update': {
|
|
1118
|
+
if (!args.run_id) throw new Error('update 需要 run_id');
|
|
1119
|
+
if (typeof args.text !== 'string' || args.text.trim() === '') {
|
|
1120
|
+
throw new Error('update 需要非空 text');
|
|
1121
|
+
}
|
|
1122
|
+
const r = conv.queueUpdate(args.run_id, args.text);
|
|
1123
|
+
audit('queue_update', { conversation_id: conv.id, run_id: args.run_id });
|
|
1124
|
+
return { structured: r, text: `已更新 ${args.run_id} 的排队文本` };
|
|
1125
|
+
}
|
|
1126
|
+
case 'promote': {
|
|
1127
|
+
if (!args.run_id) throw new Error('promote 需要 run_id');
|
|
1128
|
+
const r = conv.queuePromote(args.run_id);
|
|
1129
|
+
audit('queue_promote', { conversation_id: conv.id, run_id: args.run_id });
|
|
1130
|
+
return { structured: r, text: `已把 ${args.run_id} 提到队首` };
|
|
1131
|
+
}
|
|
1132
|
+
default:
|
|
1133
|
+
throw new Error(`未知 action: ${args.action}`);
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1137
|
+
async dsh_doctor() {
|
|
1138
|
+
const checks = [];
|
|
1139
|
+
const add = (id, status, detail, fix = null) => checks.push({ id, status, detail, fix });
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
const bin = resolveDshBin();
|
|
1143
|
+
let version = null;
|
|
1144
|
+
try {
|
|
1145
|
+
version =
|
|
1146
|
+
JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null;
|
|
1147
|
+
} catch {}
|
|
1148
|
+
add('dsh_bin', 'ok', `${bin}${version ? ` (DSH ${version})` : ''}`);
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
add('dsh_bin', 'fail', e.message, '设置 DSH_BIN 指向 @deepseek-ai/dsh/lib/bin.js');
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
try {
|
|
1154
|
+
const p = inspectProfile();
|
|
1155
|
+
add(
|
|
1156
|
+
'profile',
|
|
1157
|
+
p.exists ? (p.drifted ? 'warn' : 'ok') : 'warn',
|
|
1158
|
+
`${p.dir} exists=${p.exists} managed=${p.managed} drifted=${p.drifted} routes=${p.routes.join(',') || 'none'}`,
|
|
1159
|
+
p.exists ? null : '让服务首启自动创建,或运行 dsh-any-agent-setup',
|
|
1160
|
+
);
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
add('profile', 'fail', e.message);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
const raw = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
1167
|
+
add(
|
|
1168
|
+
'state',
|
|
1169
|
+
raw.version === 1 ? 'ok' : 'warn',
|
|
1170
|
+
`${STATE_FILE} version=${raw.version} conversations=${raw.conversations?.length ?? 0}`,
|
|
1171
|
+
);
|
|
1172
|
+
} catch (e) {
|
|
1173
|
+
add('state', 'warn', `${STATE_FILE}: ${e.message}`, '首次启动会自动创建');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
for (const [id, dir] of [
|
|
1177
|
+
['runs_dir', RUNS_DIR],
|
|
1178
|
+
['locks_dir', LOCKS_DIR],
|
|
1179
|
+
]) {
|
|
1180
|
+
try {
|
|
1181
|
+
mkdirSync(dir, { recursive: true });
|
|
1182
|
+
const probe = join(dir, `.doctor-${process.pid}`);
|
|
1183
|
+
writeFileSync(probe, '');
|
|
1184
|
+
rmSync(probe, { force: true });
|
|
1185
|
+
add(id, 'ok', dir);
|
|
1186
|
+
} catch (e) {
|
|
1187
|
+
add(id, 'fail', `${dir}: ${e.message}`, '检查权限,或用对应环境变量指向可写目录');
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
try {
|
|
1192
|
+
const orphans = listOrphans();
|
|
1193
|
+
add(
|
|
1194
|
+
'orphan_locks',
|
|
1195
|
+
orphans.length ? 'warn' : 'ok',
|
|
1196
|
+
orphans.length ? `${orphans.length} 个孤儿锁持有者(可 dsh_takeover)` : '没有孤儿锁',
|
|
1197
|
+
);
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
add('orphan_locks', 'warn', e.message);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const all = [...hub.conversations.values()];
|
|
1203
|
+
const alive = all.filter((c) => c.proc?.alive);
|
|
1204
|
+
const totalRss = alive.reduce((s, c) => s + (rssMb(c.proc?.pid) ?? 0), 0);
|
|
1205
|
+
add('sessions', 'ok', `共 ${all.length},活进程 ${alive.length},RSS 合计 ${totalRss}MB`);
|
|
1206
|
+
add(
|
|
1207
|
+
'config',
|
|
1208
|
+
'ok',
|
|
1209
|
+
`profile=${DSH_PROFILE} team=${TEAM_ACTIVE ? 'on' : 'off'} permission=${DEFAULT_PERMISSION} ` +
|
|
1210
|
+
`idle_ttl=${IDLE_TTL_MS}ms max_live=${MAX_LIVE} max_rss=${MAX_RSS_MB}MB shared=${SHARED_PROCESS}`,
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
const ok = !checks.some((c) => c.status === 'fail');
|
|
1214
|
+
const lines = checks.map(
|
|
1215
|
+
(c) =>
|
|
1216
|
+
`${c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : '✗'} ${c.id}: ${c.detail}` +
|
|
1217
|
+
(c.fix ? ` → ${c.fix}` : ''),
|
|
1218
|
+
);
|
|
1219
|
+
return {
|
|
1220
|
+
structured: { ok, verdict: ok ? 'ok' : 'fail', checks },
|
|
1221
|
+
text: `${ok ? '体检通过' : '体检发现失败项'}\n${lines.join('\n')}`,
|
|
1222
|
+
};
|
|
1223
|
+
},
|
|
707
1224
|
};
|
|
708
1225
|
}
|