dsh-any-agent 0.4.0 → 0.5.1
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 +80 -0
- package/DESIGN.md +9 -0
- package/README.md +49 -676
- package/README.zh.md +45 -690
- package/USAGE-NOTES.en.md +4 -3
- package/USAGE-NOTES.md +4 -3
- package/bin/dsh-any-agent.mjs +66 -2
- package/demo/mcp-client.mjs +130 -0
- package/demo/run.mjs +326 -0
- package/demo/transcript.json +47 -0
- package/docs/brand/README.md +58 -0
- package/docs/brand/header.jpg +0 -0
- package/docs/en/architecture.md +43 -0
- package/docs/en/compatibility.md +51 -0
- package/docs/en/demo.md +90 -0
- package/docs/en/install.md +257 -0
- package/docs/en/limits.md +27 -0
- package/docs/en/locking.md +24 -0
- package/docs/en/reference.md +42 -0
- package/docs/en/release.md +33 -0
- package/docs/en/security.md +66 -0
- package/docs/en/team.md +74 -0
- package/docs/en/tests.md +97 -0
- package/docs/en/usage.md +78 -0
- package/docs/zh/architecture.md +43 -0
- package/docs/zh/compatibility.md +49 -0
- package/docs/zh/demo.md +90 -0
- package/docs/zh/install.md +274 -0
- package/docs/zh/limits.md +30 -0
- package/docs/zh/locking.md +24 -0
- package/docs/zh/reference.md +42 -0
- package/docs/zh/release.md +38 -0
- package/docs/zh/security.md +66 -0
- package/docs/zh/team.md +71 -0
- package/docs/zh/tests.md +96 -0
- package/docs/zh/usage.md +76 -0
- package/package.json +5 -1
- package/scripts/check-release.mjs +73 -0
- package/src/config.mjs +90 -23
- package/src/host.mjs +1 -1
- package/src/hub.mjs +423 -69
- package/src/ids.mjs +21 -0
- package/src/mcp.mjs +15 -7
- package/src/redact.mjs +34 -0
- package/src/review.mjs +199 -0
- package/src/tools.mjs +184 -12
package/USAGE-NOTES.en.md
CHANGED
|
@@ -131,6 +131,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
131
131
|
2. **One conversation per task** — mixing unrelated work pollutes history and costs more.
|
|
132
132
|
3. **Split long work into dispatch + collect**: `wait=false` → do something else → `dsh_get(run_id)`.
|
|
133
133
|
4. **Review output via workspace changes** — `workspace_changed` is true when the `git status` entry set changed across the turn; `diff_stat` lists the current dirty files (it is not a per-turn diff).
|
|
134
|
-
5. **
|
|
135
|
-
6. **
|
|
136
|
-
7. **
|
|
134
|
+
5. **Review the result, don't just relay it** — every result carries a `review` ticket. Check at least one claim with your own tools (read the changed file, inspect `git diff`, run the relevant test), then record `pass` / `partial` / `fail` with `dsh_review`. Default `advisory` does not block the next send; `fail`/`partial` need a reason, and a failed review is not fed back to DSH automatically.
|
|
135
|
+
6. **Ask for a plan first** if you want to approve before execution (DSH has plan mode).
|
|
136
|
+
7. **Three cost knobs**: lower `reasoning_effort`, keep `reasoning=hide`, release and start fresh.
|
|
137
|
+
8. **Don't paste large code blocks into the prompt** — tell it which file to read instead; DSH has its own file tools.
|
package/USAGE-NOTES.md
CHANGED
|
@@ -272,6 +272,7 @@
|
|
|
272
272
|
2. **一个会话一件事**。别往一个会话里塞互不相关的任务,历史会互相污染且越来越贵。
|
|
273
273
|
3. **长任务拆成"派活 + 验收"**:`wait=false` → 干别的 → `dsh_get(run_id)`。
|
|
274
274
|
4. **要审查产出就用工作区变更**:`workspace_changed` 表示本回合前后 `git status` 的条目集合发生了变化;`diff_stat` 列出当前仍处于改动状态的文件(不是本回合的精确 diff)。
|
|
275
|
-
5.
|
|
276
|
-
6.
|
|
277
|
-
7.
|
|
275
|
+
5. **审核结果,别直接转述**:每个结果都带 `review` 审核单。用自己的工具核验至少一项关键声明(读改动的文件、看 `git diff`、跑相关测试),再用 `dsh_review` 记录 `pass` / `partial` / `fail`。默认 `advisory` 不阻塞下一次派活;`fail`/`partial` 必须给理由,且审核不通过不会自动回灌给 DSH。
|
|
276
|
+
6. **要它先想清楚再动手**:让它先给方案、你确认后再执行(DSH 有计划模式)。
|
|
277
|
+
7. **省钱的三个旋钮**:`reasoning_effort` 调低、`reasoning` 保持 `hide`、及时 `dsh_release` 并开新会话。
|
|
278
|
+
8. **别在提示词里重复贴大段代码**——告诉它去看哪个文件更划算(DSH 自己有文件工具)。
|
package/bin/dsh-any-agent.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* DSH_MCP_STATE 会话注册表路径(默认 $DSH_HOME/.dsh-any-agent/conversations.json)
|
|
9
9
|
* DSH_MCP_PERMISSION 新建会话的默认权限档(默认 danger-full-access)
|
|
10
10
|
* DSH_MCP_APPROVAL 默认审批策略(非只读档默认 auto-allow;ask = 挂起待裁决)
|
|
11
|
+
* DSH_MCP_REVIEW 调用方审核策略(默认 advisory;off 关闭自动审核单)
|
|
12
|
+
* DSH_MCP_REVIEWS_DIR 审核记录目录(默认 <状态文件同目录>/reviews)
|
|
11
13
|
* DSH_MCP_IDLE_TTL_MS 空闲多久回收会话进程(默认 0 = 永不回收)
|
|
12
14
|
* DSH_MCP_PROMPT_TIMEOUT_MS wait=true 时的等待上限(默认 0 = 不设超时)
|
|
13
15
|
* DSH_MCP_LOCK_STALE_MS 写锁登记多久没心跳算"持有者失联"(默认 90 秒)
|
|
@@ -34,6 +36,8 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
34
36
|
'Runs as an MCP stdio server; normally launched by your MCP client, not by hand.\n\n' +
|
|
35
37
|
'Usage:\n' +
|
|
36
38
|
' dsh-any-agent run the MCP server on stdio\n' +
|
|
39
|
+
' dsh-any-agent team on|off enable/disable Agent Teams (writes settings.json)\n' +
|
|
40
|
+
' dsh-any-agent team status show the effective team mode\n' +
|
|
37
41
|
' dsh-any-agent --version print the version and exit\n' +
|
|
38
42
|
' dsh-any-agent --help this text\n\n' +
|
|
39
43
|
'Register it with your client:\n' +
|
|
@@ -44,12 +48,70 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
44
48
|
' DSH_MCP_PROFILE DSH profile to drive (default dsh-mcp)\n' +
|
|
45
49
|
' DSH_MCP_PERMISSION danger-full-access | workspace-write | read-only\n' +
|
|
46
50
|
' DSH_MCP_APPROVAL auto-allow | auto-deny | ask\n' +
|
|
51
|
+
' DSH_MCP_REVIEW advisory (default) | off — caller review ticket on results\n' +
|
|
47
52
|
' DSH_MCP_LOG silent (default) | info | debug\n\n' +
|
|
48
53
|
'Docs: https://github.com/xmtlzzz/dsh-any-agent\n',
|
|
49
54
|
);
|
|
50
55
|
process.exit(0);
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* `dsh-any-agent team on|off|status`
|
|
60
|
+
*
|
|
61
|
+
* 团队开关写 `<DSH_HOME>/.dsh-any-agent/settings.json`,不改客户端配置。
|
|
62
|
+
* 优先级:环境变量 DSH_MCP_TEAM > 该文件 > 默认关。
|
|
63
|
+
*/
|
|
64
|
+
if (argv[0] === 'team') {
|
|
65
|
+
const fs = await import('node:fs');
|
|
66
|
+
const nodePath = await import('node:path');
|
|
67
|
+
const cfg = await import('../src/config.mjs');
|
|
68
|
+
const readSettings = () => {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(fs.readFileSync(cfg.TEAM_SETTINGS_FILE, 'utf8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const writeSettings = (obj) => {
|
|
76
|
+
fs.mkdirSync(nodePath.dirname(cfg.TEAM_SETTINGS_FILE), { recursive: true });
|
|
77
|
+
const tmp = `${cfg.TEAM_SETTINGS_FILE}.tmp.${process.pid}`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
79
|
+
fs.renameSync(tmp, cfg.TEAM_SETTINGS_FILE);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const action = argv[1] ?? 'status';
|
|
83
|
+
if (action === 'on' || action === 'off') {
|
|
84
|
+
const cur = readSettings();
|
|
85
|
+
cur.team = action === 'on';
|
|
86
|
+
writeSettings(cur);
|
|
87
|
+
process.stdout.write(
|
|
88
|
+
`团队模式已${action === 'on' ? '开启' : '关闭'}(${cfg.TEAM_SETTINGS_FILE})。\n` +
|
|
89
|
+
`DSH 版本: ${cfg.DSH_VERSION ?? '未检测到'}(团队需要 >= 0.1.7-0)。\n`,
|
|
90
|
+
);
|
|
91
|
+
if (cfg.TEAM_MODE_SOURCE === 'env') {
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
'⚠️ 环境变量 DSH_MCP_TEAM 优先级更高;请同时删除它,否则本次设置不会生效。\n',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
process.stdout.write('重新连接 MCP 服务(或重启客户端)后生效。\n');
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
if (action === 'status') {
|
|
100
|
+
const file = readSettings();
|
|
101
|
+
process.stdout.write(
|
|
102
|
+
'团队模式状态:\n' +
|
|
103
|
+
` 环境变量 DSH_MCP_TEAM: ${process.env.DSH_MCP_TEAM ?? '(未设置)'}\n` +
|
|
104
|
+
` 设置文件: ${cfg.TEAM_SETTINGS_FILE}(team=${file.team === undefined ? '未设置' : file.team})\n` +
|
|
105
|
+
` 生效值: ${cfg.TEAM_MODE ? '开' : '关'}(来源: ${cfg.TEAM_MODE_SOURCE})\n` +
|
|
106
|
+
` profile: ${cfg.DSH_PROFILE}\n` +
|
|
107
|
+
` 团队能力可用: ${cfg.TEAM_ACTIVE ? '是' : '否'}\n`,
|
|
108
|
+
);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
process.stderr.write('用法: dsh-any-agent team on|off|status\n');
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
|
|
53
115
|
const { createLogger } = await import('../src/config.mjs');
|
|
54
116
|
const { Hub } = await import('../src/hub.mjs');
|
|
55
117
|
const { createMcpServer } = await import('../src/mcp.mjs');
|
|
@@ -63,9 +125,11 @@ await hub.load();
|
|
|
63
125
|
hub.startReaper();
|
|
64
126
|
// 启动时清一次过期哨兵(哨兵内含正文,长期不清理会无声堆积)
|
|
65
127
|
const prunedSentinels = hub.pruneSentinels();
|
|
66
|
-
if (prunedSentinels.pruned) {
|
|
128
|
+
if (prunedSentinels.pruned || prunedSentinels.reviews_pruned) {
|
|
67
129
|
log.info(
|
|
68
|
-
`[dsh-any-agent] 启动清理:删除 ${prunedSentinels.pruned}
|
|
130
|
+
`[dsh-any-agent] 启动清理:删除 ${prunedSentinels.pruned} 个过期哨兵、` +
|
|
131
|
+
`${prunedSentinels.reviews_pruned ?? 0} 条过期审核记录、` +
|
|
132
|
+
`${prunedSentinels.dirs_removed + (prunedSentinels.review_dirs_removed ?? 0)} 个空目录`,
|
|
69
133
|
);
|
|
70
134
|
}
|
|
71
135
|
// 写锁登记清理:删掉"MCP 已死 + 子进程也死了"的记录;**保留**子进程还活着的那些 ——
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 极简 MCP 客户端(demo 用)。
|
|
3
|
+
*
|
|
4
|
+
* 与 `test/mcp-client.mjs` 是同一套换行分隔 JSON-RPC 协议,但这里刻意独立一份:
|
|
5
|
+
* `test/` 不进 npm 发布包,而 demo 要随包分发 —— 依赖它会让 `npm run demo`
|
|
6
|
+
* 在装好的包里直接崩。
|
|
7
|
+
*
|
|
8
|
+
* 只做四件事:拉起服务端、initialize、tools/list、tools/call。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export class McpClient {
|
|
15
|
+
#child = null;
|
|
16
|
+
#nextId = 1;
|
|
17
|
+
#pending = new Map();
|
|
18
|
+
#buf = '';
|
|
19
|
+
|
|
20
|
+
constructor({ pkgRoot, env = {} }) {
|
|
21
|
+
this.pkgRoot = pkgRoot;
|
|
22
|
+
this.env = env;
|
|
23
|
+
/** 服务端的 stderr 行(demo 默认静默,只有 -v 时才值得看)。 */
|
|
24
|
+
this.stderr = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get pid() {
|
|
28
|
+
return this.#child?.pid ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start() {
|
|
32
|
+
this.#child = spawn(process.execPath, [join(this.pkgRoot, 'bin', 'dsh-any-agent.mjs')], {
|
|
33
|
+
env: { ...process.env, ...this.env },
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
this.#child.stdout.setEncoding('utf8');
|
|
37
|
+
this.#child.stderr.setEncoding('utf8');
|
|
38
|
+
this.#child.stdout.on('data', (d) => this.#onData(d));
|
|
39
|
+
this.#child.stderr.on('data', (d) => {
|
|
40
|
+
for (const line of d.split(/\r?\n/)) if (line.trim()) this.stderr.push(line.trim());
|
|
41
|
+
});
|
|
42
|
+
this.#child.on('exit', (code, signal) => {
|
|
43
|
+
const why = `服务端退出(code=${code}${signal ? `, signal=${signal}` : ''})`;
|
|
44
|
+
for (const [, p] of this.#pending) p.reject(new Error(why));
|
|
45
|
+
this.#pending.clear();
|
|
46
|
+
});
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#onData(chunk) {
|
|
51
|
+
this.#buf += chunk;
|
|
52
|
+
let i;
|
|
53
|
+
while ((i = this.#buf.indexOf('\n')) !== -1) {
|
|
54
|
+
const line = this.#buf.slice(0, i);
|
|
55
|
+
this.#buf = this.#buf.slice(i + 1);
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
let msg;
|
|
58
|
+
try {
|
|
59
|
+
msg = JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`服务端输出了非 JSON 内容: ${line.slice(0, 300)}`);
|
|
62
|
+
}
|
|
63
|
+
if (msg.id === undefined) continue;
|
|
64
|
+
const p = this.#pending.get(msg.id);
|
|
65
|
+
if (!p) continue;
|
|
66
|
+
this.#pending.delete(msg.id);
|
|
67
|
+
clearTimeout(p.timer);
|
|
68
|
+
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
request(method, params, timeoutMs = 120_000) {
|
|
73
|
+
const id = this.#nextId++;
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
if (this.#pending.delete(id)) reject(new Error(`请求超时: ${method}`));
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
79
|
+
this.#child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
notify(method, params) {
|
|
84
|
+
this.#child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async initialize() {
|
|
88
|
+
const r = await this.request('initialize', {
|
|
89
|
+
protocolVersion: '2025-06-18',
|
|
90
|
+
capabilities: {},
|
|
91
|
+
clientInfo: { name: 'dsh-any-agent-demo', version: '1.0.0' },
|
|
92
|
+
});
|
|
93
|
+
this.notify('notifications/initialized', {});
|
|
94
|
+
return r;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async listTools() {
|
|
98
|
+
return (await this.request('tools/list', {})).tools;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async callTool(name, args = {}, timeoutMs) {
|
|
102
|
+
return await this.request('tools/call', { name, arguments: args }, timeoutMs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 取工具结果里的纯文本。 */
|
|
106
|
+
static text(result) {
|
|
107
|
+
return (result?.content ?? [])
|
|
108
|
+
.filter((c) => c.type === 'text')
|
|
109
|
+
.map((c) => c.text)
|
|
110
|
+
.join('\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async close() {
|
|
114
|
+
try {
|
|
115
|
+
this.#child.stdin.end();
|
|
116
|
+
} catch {}
|
|
117
|
+
await new Promise((resolve) => {
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
try {
|
|
120
|
+
this.#child?.kill();
|
|
121
|
+
} catch {}
|
|
122
|
+
resolve();
|
|
123
|
+
}, 6000);
|
|
124
|
+
this.#child?.once('exit', () => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
package/demo/run.mjs
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 5 分钟真实案例:把一件活派给 DSH,中途插两次话,再回头验收。
|
|
4
|
+
*
|
|
5
|
+
* 这个脚本只用 MCP 协议本身(`initialize` / `tools/list` / `tools/call`),
|
|
6
|
+
* 不 import 服务端的任何内部模块 —— 它就是你自己的客户端会做的事,
|
|
7
|
+
* 只不过换成了命令行。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node demo/run.mjs 真实跑一遍(需要本机 DSH + 可用 provider)
|
|
11
|
+
* node demo/run.mjs --replay 回放上一次录制(零成本、不需要 DSH)
|
|
12
|
+
* node demo/run.mjs --keep 保留临时工作区,方便自己翻结果
|
|
13
|
+
* node demo/run.mjs --save 跑完把这份记录写成 demo/transcript.json
|
|
14
|
+
* node demo/run.mjs --timeout 240 单个回合的最长等待秒数(默认 240)
|
|
15
|
+
* node demo/run.mjs --prompt "…" 换一个任务(会跳过内置的两条插话)
|
|
16
|
+
*
|
|
17
|
+
* 隔离:注册表 / 哨兵 / 写锁登记全部指向临时目录,不会动你日常的
|
|
18
|
+
* `~/.dsh/.dsh-any-agent`。DSH 自己的会话仍落在 `~/.dsh/sessions`(凭据与
|
|
19
|
+
* provider 在那里),脚本结束时会 `dsh_release(forget=true)` 把它移出注册表。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
import { McpClient } from './mcp-client.mjs';
|
|
29
|
+
|
|
30
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const TRANSCRIPT = join(PKG_ROOT, 'demo', 'transcript.json');
|
|
32
|
+
|
|
33
|
+
// ── 参数 ────────────────────────────────────────────────────────────
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const flag = (name) => argv.includes(name);
|
|
36
|
+
const opt = (name, fallback) => {
|
|
37
|
+
const i = argv.indexOf(name);
|
|
38
|
+
return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[i + 1] : fallback;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (flag('-h') || flag('--help')) {
|
|
42
|
+
console.log(
|
|
43
|
+
[
|
|
44
|
+
'dsh-any-agent demo —— 派活 → 插话 → 验收',
|
|
45
|
+
'',
|
|
46
|
+
' node demo/run.mjs real run (needs a local DSH + a working provider)',
|
|
47
|
+
' node demo/run.mjs --replay replay the last recorded run (no DSH, no tokens)',
|
|
48
|
+
' node demo/run.mjs --keep keep the temp workspace',
|
|
49
|
+
' node demo/run.mjs --save write demo/transcript.json from this run',
|
|
50
|
+
' node demo/run.mjs --timeout 240 seconds to wait per turn (default 240)',
|
|
51
|
+
' node demo/run.mjs --prompt "…" use your own task instead of the built-in one',
|
|
52
|
+
'',
|
|
53
|
+
'Env: DSH_BIN, DSH_HOME, DSH_MCP_PROFILE (same as the server).',
|
|
54
|
+
].join('\n'),
|
|
55
|
+
);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const TURN_TIMEOUT_S = Number(opt('--timeout', '240')) || 240;
|
|
60
|
+
const KEEP = flag('--keep');
|
|
61
|
+
const SAVE = flag('--save');
|
|
62
|
+
|
|
63
|
+
// ── 记录器:打印 + 收集,--save 时落盘 ──────────────────────────────
|
|
64
|
+
const log = [];
|
|
65
|
+
function say(line = '') {
|
|
66
|
+
process.stdout.write(line + '\n');
|
|
67
|
+
log.push(line);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── --replay:零成本回放 ────────────────────────────────────────────
|
|
71
|
+
if (flag('--replay')) {
|
|
72
|
+
if (!existsSync(TRANSCRIPT)) {
|
|
73
|
+
console.error(`还没有录制过:${TRANSCRIPT} 不存在。\n先真实跑一次:node demo/run.mjs --save`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const doc = JSON.parse(readFileSync(TRANSCRIPT, 'utf8'));
|
|
77
|
+
say(`(回放:${doc.recorded_at} 在本机录制的真实运行,不是现场跑的)`);
|
|
78
|
+
say(` 任务:${doc.prompt}`);
|
|
79
|
+
say('');
|
|
80
|
+
for (const line of doc.lines) say(line);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 真实运行 ────────────────────────────────────────────────────────
|
|
85
|
+
const DEFAULT_PROMPT = [
|
|
86
|
+
'这是一个自动化演示,请直接动手,不要做架构/影响面分析,不要创建 .birdview/ 之类的产物,不要使用技能。',
|
|
87
|
+
'',
|
|
88
|
+
'在这个空目录里做一件小事,并自己验证:',
|
|
89
|
+
'',
|
|
90
|
+
'1. 写 fib.mjs:ESM 导出 fib(n),fib(0)=0、fib(1)=1,用迭代实现,不要递归。',
|
|
91
|
+
'2. 写 test-fib.mjs:用 node:test + node:assert 覆盖 fib(0)、fib(1)、fib(10)=55。',
|
|
92
|
+
'3. 运行 `node --test test-fib.mjs`,确认全部通过。',
|
|
93
|
+
'',
|
|
94
|
+
'只在当前目录里建这两个文件,不要建别的文件,不要安装任何依赖。',
|
|
95
|
+
].join('\n');
|
|
96
|
+
|
|
97
|
+
const PROMPT = opt('--prompt', DEFAULT_PROMPT);
|
|
98
|
+
const CUSTOM = PROMPT !== DEFAULT_PROMPT;
|
|
99
|
+
|
|
100
|
+
const INTERJECTS = [
|
|
101
|
+
'补充一条要求:fib(n) 收到负数要抛 RangeError("n must be >= 0"),测试里补一条对应断言。',
|
|
102
|
+
'再补一条:在 fib.mjs 顶上加一行注释,说明时间复杂度是 O(n)。其它别动。',
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const t0 = Date.now();
|
|
106
|
+
const since = () => `+${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
|
107
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
108
|
+
|
|
109
|
+
// 隔离目录:注册表 / 哨兵 / 锁登记都放这里
|
|
110
|
+
const base = mkdtempSync(join(tmpdir(), 'dsh-any-agent-demo-'));
|
|
111
|
+
const stateDir = join(base, 'state');
|
|
112
|
+
const workspace = join(base, 'workspace');
|
|
113
|
+
mkdirSync(stateDir, { recursive: true });
|
|
114
|
+
mkdirSync(workspace, { recursive: true });
|
|
115
|
+
|
|
116
|
+
const env = {
|
|
117
|
+
DSH_MCP_STATE: join(stateDir, 'conversations.json'),
|
|
118
|
+
DSH_MCP_RUNS_DIR: join(stateDir, 'runs'),
|
|
119
|
+
DSH_MCP_LOCKS_DIR: join(stateDir, 'locks'),
|
|
120
|
+
DSH_MCP_LOG: 'silent',
|
|
121
|
+
DSH_MCP_PERMISSION: 'workspace-write',
|
|
122
|
+
DSH_MCP_APPROVAL: 'auto-allow',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
say('dsh-any-agent · 5 分钟真实案例');
|
|
126
|
+
say('='.repeat(64));
|
|
127
|
+
say(`工作区:${workspace}`);
|
|
128
|
+
say(`注册表:${env.DSH_MCP_STATE}(隔离,不动你的日常状态)`);
|
|
129
|
+
say('');
|
|
130
|
+
|
|
131
|
+
// 前置检查:Node 版本 + DSH 是否可用。早失败 > 跑一半失败。
|
|
132
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
133
|
+
if (major < 22 || (major === 22 && minor < 15)) {
|
|
134
|
+
console.error(
|
|
135
|
+
`Node ${process.versions.node} 太旧:服务端要用 node:zlib 的 zstdDecompressSync 读会话头,需要 >= 22.15。`,
|
|
136
|
+
);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let resolveDshBin;
|
|
141
|
+
try {
|
|
142
|
+
({ resolveDshBin } = await import('../src/config.mjs'));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.error(`加载配置失败:${e.message}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
say(`DSH:${resolveDshBin()}`);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.error(
|
|
151
|
+
[
|
|
152
|
+
'找不到可用的 DSH。这个 demo 演示的是「让 DSH 干活」,不是「不装 DSH 也能跑」。',
|
|
153
|
+
' 装一份:npm i -g @deepseek-ai/dsh (或设置 DSH_BIN 指向它的 lib/bin.js)',
|
|
154
|
+
` 原因:${e.message}`,
|
|
155
|
+
].join('\n'),
|
|
156
|
+
);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
say('');
|
|
160
|
+
|
|
161
|
+
const client = new McpClient({ pkgRoot: PKG_ROOT, env }).start();
|
|
162
|
+
let exitCode = 0;
|
|
163
|
+
|
|
164
|
+
/** 从 tools/call 的返回里同时拿结构化字段和可读文本。 */
|
|
165
|
+
const info = (res) => res?.structuredContent ?? {};
|
|
166
|
+
const text = (res) => McpClient.text(res);
|
|
167
|
+
const sentinelStatus = (file) => {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// 1. 握手
|
|
177
|
+
say(`[1/6] ${since()} initialize —— 服务端只要求 4 个标准方法`);
|
|
178
|
+
await client.initialize();
|
|
179
|
+
const tools = await client.listTools();
|
|
180
|
+
say(` 已连接,暴露 ${tools.length} 个工具:${tools.map((t) => t.name).join(', ')}`);
|
|
181
|
+
|
|
182
|
+
// 2. 建会话
|
|
183
|
+
say('');
|
|
184
|
+
say(`[2/6] ${since()} dsh_start —— 建一个长驻会话`);
|
|
185
|
+
const started = await client.callTool('dsh_start', {
|
|
186
|
+
cwd: workspace,
|
|
187
|
+
title_hint: 'demo: fib + test',
|
|
188
|
+
permission: 'workspace-write',
|
|
189
|
+
});
|
|
190
|
+
const convId = info(started).conversation_id;
|
|
191
|
+
if (!convId) throw new Error(`dsh_start 没返回 conversation_id:\n${text(started)}`);
|
|
192
|
+
say(` 会话 ${convId}`);
|
|
193
|
+
|
|
194
|
+
// 3. 派活(不等)
|
|
195
|
+
say('');
|
|
196
|
+
say(`[3/6] ${since()} dsh_send(wait=false) —— 派完立刻返回,回合在后台跑`);
|
|
197
|
+
const sent = await client.callTool('dsh_send', {
|
|
198
|
+
conversation_id: convId,
|
|
199
|
+
prompt: PROMPT,
|
|
200
|
+
wait: false,
|
|
201
|
+
});
|
|
202
|
+
const sendInfo = info(sent);
|
|
203
|
+
if (!sendInfo.run_id) throw new Error(`dsh_send 没返回 run_id:\n${text(sent)}`);
|
|
204
|
+
const firstRun = sendInfo.run_id;
|
|
205
|
+
const firstSentinel = sendInfo.sentinel_file;
|
|
206
|
+
say(` run_id=${firstRun}`);
|
|
207
|
+
say(` 哨兵=${firstSentinel} ← 跑完由它通知,不用轮询`);
|
|
208
|
+
|
|
209
|
+
// 4. 两次插话。注意:dsh_interject 会一直等到"改口后的那一轮"结束才返回,
|
|
210
|
+
// 所以这一步同时也是"第二轮的验收点"。
|
|
211
|
+
say('');
|
|
212
|
+
say(`[4/6] ${since()} 中途插话 —— 打断当前回合并改口,已做的工作不丢`);
|
|
213
|
+
let last = null;
|
|
214
|
+
let n = 0;
|
|
215
|
+
for (const message of INTERJECTS) {
|
|
216
|
+
if (CUSTOM) break;
|
|
217
|
+
await sleep(4000);
|
|
218
|
+
const res = await client.callTool(
|
|
219
|
+
'dsh_interject',
|
|
220
|
+
{ conversation_id: convId, message, timeout_ms: TURN_TIMEOUT_S * 1000 },
|
|
221
|
+
(TURN_TIMEOUT_S + 60) * 1000,
|
|
222
|
+
);
|
|
223
|
+
const r = info(res);
|
|
224
|
+
n += 1;
|
|
225
|
+
// interject 的返回是"改口后那一轮"的结果:有的版本带 status,有的只带 stop_reason。
|
|
226
|
+
// 两个都认,别把一次正常结束报成 undefined。
|
|
227
|
+
const state = r.status ?? (r.error ? 'error' : r.stop_reason === 'end_turn' ? 'done' : r.stop_reason);
|
|
228
|
+
if (r.interjected === 'idle') {
|
|
229
|
+
say(` 插话 #${n}:会话当时已空闲,按普通回合执行;status=${state}(${since()})`);
|
|
230
|
+
} else {
|
|
231
|
+
say(
|
|
232
|
+
` 插话 #${n}:已打断上一轮(上一轮跑了 ${((r.waited_ms ?? 0) / 1000).toFixed(1)}s),` +
|
|
233
|
+
`新一轮 status=${state}(${since()})`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
last = r;
|
|
237
|
+
if (state !== 'done') say(` 本轮未正常结束:${r.error ?? state}`);
|
|
238
|
+
}
|
|
239
|
+
if (CUSTOM) say(' (--prompt 自定义任务,跳过内置插话)');
|
|
240
|
+
|
|
241
|
+
// 5. 收活:先看被打断那一轮的哨兵,再看最后一轮
|
|
242
|
+
say('');
|
|
243
|
+
say(`[5/6] ${since()} 收活 —— 一个回合 = 一个哨兵,被打断的也算`);
|
|
244
|
+
const cancelled = firstSentinel ? sentinelStatus(firstSentinel) : null;
|
|
245
|
+
if (cancelled) {
|
|
246
|
+
say(` 第 1 轮(被打断)哨兵:status=${cancelled.status} ← 没被丢掉,照样落地`);
|
|
247
|
+
}
|
|
248
|
+
if (last?.sentinel_file) {
|
|
249
|
+
const fin = sentinelStatus(last.sentinel_file);
|
|
250
|
+
say(` 最后一轮哨兵:status=${fin?.status ?? '(尚未落地)'} ${last.sentinel_file}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 6. 验收:用 run_id 取正文,然后自己复核磁盘
|
|
254
|
+
say('');
|
|
255
|
+
say(`[6/6] ${since()} dsh_get —— 用 run_id 取回最终结果`);
|
|
256
|
+
const finalRun = last?.run_id ?? firstRun;
|
|
257
|
+
const got = await client.callTool('dsh_get', { conversation_id: convId, run_id: finalRun });
|
|
258
|
+
const answer = info(got).run?.result?.answer ?? '';
|
|
259
|
+
say(
|
|
260
|
+
(answer || text(got))
|
|
261
|
+
.split('\n')
|
|
262
|
+
.filter((l) => l.trim())
|
|
263
|
+
.slice(0, 10)
|
|
264
|
+
.map((l) => ` ${l}`)
|
|
265
|
+
.join('\n'),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
say('');
|
|
269
|
+
say('—'.repeat(64));
|
|
270
|
+
say('复核(不看它说了什么,看磁盘上有什么)');
|
|
271
|
+
const files = ['fib.mjs', 'test-fib.mjs'].filter((f) => existsSync(join(workspace, f)));
|
|
272
|
+
say(` 产出文件:${files.length ? files.join(', ') : '(无)'}`);
|
|
273
|
+
if (files.includes('test-fib.mjs')) {
|
|
274
|
+
const test = spawnSync(process.execPath, ['--test', 'test-fib.mjs'], {
|
|
275
|
+
cwd: workspace,
|
|
276
|
+
encoding: 'utf8',
|
|
277
|
+
timeout: 60_000,
|
|
278
|
+
});
|
|
279
|
+
say(` node --test:exit=${test.status}`);
|
|
280
|
+
for (const line of (test.stdout ?? '').trim().split('\n').filter(Boolean).slice(-3)) {
|
|
281
|
+
say(` ${line.trim()}`);
|
|
282
|
+
}
|
|
283
|
+
if (test.status !== 0) exitCode = 1;
|
|
284
|
+
} else {
|
|
285
|
+
exitCode = 1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 状态快照:锁在谁手上、内存多少、是否共享进程
|
|
289
|
+
const status = await client.callTool('dsh_status', { conversation_id: convId }).catch(() => null);
|
|
290
|
+
if (status) {
|
|
291
|
+
say('');
|
|
292
|
+
say('状态快照(dsh_status)');
|
|
293
|
+
for (const line of text(status).split('\n').slice(0, 8)) say(` ${line}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await client.callTool('dsh_release', { conversation_id: convId, forget: true }).catch(() => null);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
exitCode = 1;
|
|
299
|
+
say('');
|
|
300
|
+
say(`!! 失败:${e.message}`);
|
|
301
|
+
if (client.stderr.length) {
|
|
302
|
+
say('服务端 stderr(最后 10 行):');
|
|
303
|
+
for (const l of client.stderr.slice(-10)) say(` ${l}`);
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
await client.close();
|
|
307
|
+
if (!KEEP) rmSync(base, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
say('');
|
|
311
|
+
say('='.repeat(64));
|
|
312
|
+
say(
|
|
313
|
+
`${exitCode === 0 ? '完成' : '未通过'} · 总耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s` +
|
|
314
|
+
(KEEP ? ` · 工作区保留在 ${workspace}` : ''),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (SAVE && exitCode === 0) {
|
|
318
|
+
writeFileSync(
|
|
319
|
+
TRANSCRIPT,
|
|
320
|
+
JSON.stringify({ recorded_at: new Date().toISOString(), prompt: PROMPT, lines: log }, null, 2) + '\n',
|
|
321
|
+
'utf8',
|
|
322
|
+
);
|
|
323
|
+
say(`已录制:${TRANSCRIPT}(之后可用 --replay 零成本回放)`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
process.exit(exitCode);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"recorded_at": "2026-11-03T08:16:28.187Z",
|
|
3
|
+
"prompt": "这是一个自动化演示,请直接动手,不要做架构/影响面分析,不要创建 .birdview/ 之类的产物,不要使用技能。\n\n在这个空目录里做一件小事,并自己验证:\n\n1. 写 fib.mjs:ESM 导出 fib(n),fib(0)=0、fib(1)=1,用迭代实现,不要递归。\n2. 写 test-fib.mjs:用 node:test + node:assert 覆盖 fib(0)、fib(1)、fib(10)=55。\n3. 运行 `node --test test-fib.mjs`,确认全部通过。\n\n只在当前目录里建这两个文件,不要建别的文件,不要安装任何依赖。",
|
|
4
|
+
"lines": [
|
|
5
|
+
"dsh-any-agent · 5 分钟真实案例",
|
|
6
|
+
"================================================================",
|
|
7
|
+
"工作区:C:\\Users\\xmtlz\\AppData\\Local\\Temp\\dsh-any-agent-demo-R2f2pV\\workspace",
|
|
8
|
+
"注册表:C:\\Users\\xmtlz\\AppData\\Local\\Temp\\dsh-any-agent-demo-R2f2pV\\state\\conversations.json(隔离,不动你的日常状态)",
|
|
9
|
+
"",
|
|
10
|
+
"DSH:C:\\Users\\xmtlz\\AppData\\Local\\npm-cache\\_npx\\57331acab1a6b93f\\node_modules\\@deepseek-ai\\dsh\\lib\\bin.js",
|
|
11
|
+
"",
|
|
12
|
+
"[1/6] +0.1s initialize —— 服务端只要求 4 个标准方法",
|
|
13
|
+
" 已连接,暴露 14 个工具:dsh_start, dsh_send, dsh_list, dsh_read, dsh_get, dsh_interject, dsh_interrupt, dsh_takeover, dsh_release, dsh_approval_decide, dsh_status, dsh_inbox, dsh_queue, dsh_doctor",
|
|
14
|
+
"",
|
|
15
|
+
"[2/6] +0.2s dsh_start —— 建一个长驻会话",
|
|
16
|
+
" 会话 cfc31c11-b407-4917-86d9-ccf851f06228",
|
|
17
|
+
"",
|
|
18
|
+
"[3/6] +7.6s dsh_send(wait=false) —— 派完立刻返回,回合在后台跑",
|
|
19
|
+
" run_id=run-mw0exu3d-1",
|
|
20
|
+
" 哨兵=C:\\Users\\xmtlz\\AppData\\Local\\Temp\\dsh-any-agent-demo-R2f2pV\\state\\runs\\cfc31c11-b407-4917-86d9-ccf851f06228\\run-mw0exu3d-1.json ← 跑完由它通知,不用轮询",
|
|
21
|
+
"",
|
|
22
|
+
"[4/6] +7.7s 中途插话 —— 打断当前回合并改口,已做的工作不丢",
|
|
23
|
+
" 插话 #1:已打断上一轮(上一轮跑了 0.1s),新一轮 status=done(+38.7s)",
|
|
24
|
+
" 插话 #2:会话当时已空闲,按普通回合执行;status=done(+56.1s)",
|
|
25
|
+
"",
|
|
26
|
+
"[5/6] +56.1s 收活 —— 一个回合 = 一个哨兵,被打断的也算",
|
|
27
|
+
" 第 1 轮(被打断)哨兵:status=cancelled ← 没被丢掉,照样落地",
|
|
28
|
+
" 最后一轮哨兵:status=done C:\\Users\\xmtlz\\AppData\\Local\\Temp\\dsh-any-agent-demo-R2f2pV\\state\\runs\\cfc31c11-b407-4917-86d9-ccf851f06228\\run-mw0eyl8k-3.json",
|
|
29
|
+
"",
|
|
30
|
+
"[6/6] +56.1s dsh_get —— 用 run_id 取回最终结果",
|
|
31
|
+
" 已在 `fib.mjs` 第 1 行加上注释 `// Time complexity: O(n)`,其余内容一字未动,仍只有这两个文件、无依赖。重跑 `node --test test-fib.mjs` 依旧 4/4 通过。",
|
|
32
|
+
"",
|
|
33
|
+
"————————————————————————————————————————————————————————————————",
|
|
34
|
+
"复核(不看它说了什么,看磁盘上有什么)",
|
|
35
|
+
" 产出文件:fib.mjs, test-fib.mjs",
|
|
36
|
+
" node --test:exit=0",
|
|
37
|
+
" ℹ skipped 0",
|
|
38
|
+
" ℹ todo 0",
|
|
39
|
+
" ℹ duration_ms 131.9934",
|
|
40
|
+
"",
|
|
41
|
+
"状态快照(dsh_status)",
|
|
42
|
+
" 状态=idle | 轮数=3 | 最近回合=run-mw0eyl8k-3(done) | 待决审批=0 | rss=214MB",
|
|
43
|
+
"",
|
|
44
|
+
"================================================================",
|
|
45
|
+
"完成 · 总耗时 57.6s"
|
|
46
|
+
]
|
|
47
|
+
}
|