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/CHANGELOG.md +126 -1
- package/USAGE-NOTES.en.md +3 -1
- package/USAGE-NOTES.md +9 -2
- package/bin/dsh-any-agent-workspaces.mjs +12 -5
- package/bin/dsh-any-agent.mjs +4 -1
- package/bin/restore.mjs +1 -1
- package/docs/en/compatibility.md +27 -6
- package/docs/en/reference.md +2 -1
- package/docs/en/release.md +4 -0
- package/docs/en/tests.md +57 -15
- package/docs/zh/compatibility.md +25 -6
- package/docs/zh/reference.md +2 -1
- package/docs/zh/release.md +3 -0
- package/docs/zh/tests.md +57 -15
- package/package.json +4 -1
- package/scripts/detect-dsh.mjs +55 -0
- package/scripts/lint.mjs +595 -0
- package/scripts/sync-test-counts.mjs +699 -0
- package/src/acp-session.mjs +63 -0
- package/src/acp.mjs +14 -5
- package/src/config.mjs +14 -111
- package/src/conversation.mjs +1600 -0
- package/src/doctor.mjs +203 -0
- package/src/dsh-locate.mjs +560 -0
- package/src/git-snapshot.mjs +196 -0
- package/src/hub-store.mjs +108 -0
- package/src/hub.mjs +26 -1972
- package/src/mcp.mjs +21 -75
- package/src/residue.mjs +278 -0
- package/src/sentinel.mjs +97 -0
- package/src/tool-defs.mjs +399 -0
- package/src/tool-render.mjs +262 -0
- package/src/tools.mjs +18 -767
- package/src/workspace-guard.mjs +89 -0
- package/src/workspace.mjs +295 -59
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区 git 快照:回合前后各取一次,用来回答"这一轮有没有改工作区"(workspace_changed)。
|
|
3
|
+
*
|
|
4
|
+
* 从 hub.mjs 拆出来:这部分只有纯函数与异步 git 子进程调用,不依赖任何会话/注册表状态,
|
|
5
|
+
* 所以能单独加载与测试(test/workspace-snapshot.mjs)。hub.mjs 仍然重新导出
|
|
6
|
+
* `gitStatus` / `sameWorkspaceSnapshot`,外部的导入路径不变。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { join, resolve, sep } from 'node:path';
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 两次 workspace 快照是否表示同一组工作区变更。
|
|
17
|
+
*
|
|
18
|
+
* 优先比较 `fingerprint`(覆盖**全量**条目:状态行 + 每个路径的 size/mtime,
|
|
19
|
+
* 未跟踪目录递归展开)。只比较前 40 条 entries 会把"同一状态行但内容变了"
|
|
20
|
+
* 和第 41 条之后的改动都漏掉(workspace_changed 假阴性)。
|
|
21
|
+
*/
|
|
22
|
+
export function sameWorkspaceSnapshot(a, b) {
|
|
23
|
+
if (!a && !b) return true;
|
|
24
|
+
if (!a || !b) return false;
|
|
25
|
+
if (typeof a.fingerprint === 'string' && typeof b.fingerprint === 'string') {
|
|
26
|
+
return a.fingerprint === b.fingerprint;
|
|
27
|
+
}
|
|
28
|
+
// 兼容旧快照/测试里手写的 { changed_files, entries }。
|
|
29
|
+
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 干净工作区的固定指纹(任何非空状态的指纹都至少包含一条状态行,不会与它相同)。 */
|
|
33
|
+
const CLEAN_FINGERPRINT = createHash('sha256').update('clean\0').digest('hex');
|
|
34
|
+
|
|
35
|
+
const GIT_TIMEOUT_MS = 8000;
|
|
36
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
37
|
+
const MAX_UNTRACKED_DIR_ENTRIES = 2000;
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 安全 git 参数(放 subcommand 之前)。
|
|
42
|
+
*
|
|
43
|
+
* `--no-optional-locks` 是 **git 全局选项**,放在 `status` 之后会被 git 当成
|
|
44
|
+
* 未知参数直接退出 129 —— 那样快照永远是 null,workspace_changed 恒为 false。
|
|
45
|
+
*/
|
|
46
|
+
const GIT_SAFE = ['--no-optional-locks', '-c', 'core.fsmonitor=false'];
|
|
47
|
+
|
|
48
|
+
function gitEnv() {
|
|
49
|
+
return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 异步跑一次 git 并取 stdout;非 git 目录/超时/输出过大都抛给调用方。 */
|
|
53
|
+
async function gitOutput(cwd, args) {
|
|
54
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
55
|
+
cwd,
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: GIT_TIMEOUT_MS,
|
|
58
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
env: gitEnv(),
|
|
61
|
+
});
|
|
62
|
+
return stdout;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 解析 porcelain v1 -z 状态行;rename/copy 的“原路径”是下一个 NUL 段。 */
|
|
66
|
+
function parsePorcelainZ(stdout) {
|
|
67
|
+
const tokens = String(stdout ?? '').split('\0');
|
|
68
|
+
const rows = [];
|
|
69
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
70
|
+
const token = tokens[i];
|
|
71
|
+
if (!token) continue;
|
|
72
|
+
// 形如 "XY path";posix 允许路径里有空格,所以只切前三个字符。
|
|
73
|
+
if (token.length < 4 || token[2] !== ' ') continue;
|
|
74
|
+
const status = token.slice(0, 2);
|
|
75
|
+
const path = token.slice(3);
|
|
76
|
+
let orig = null;
|
|
77
|
+
if (/[RC]/.test(status)) orig = tokens[++i] || null;
|
|
78
|
+
rows.push({ status, path, orig });
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 把一个路径的当前状态(类型 + size + mtimeMs)混进指纹。
|
|
85
|
+
*
|
|
86
|
+
* - 普通文件:size + mtimeMs 足以识别"状态行没变、内容变了";
|
|
87
|
+
* - 未跟踪目录(`?? dir/`):目录 mtime 只在直接子项增删时变,光看它不够,
|
|
88
|
+
* 所以递归展开(只用 lstat,不跟随符号链接/不钻 .git,且有预算上限);
|
|
89
|
+
* - 已跟踪的目录条目(子模块):只看它自己的 lstat,避免把子模块整个走一遍。
|
|
90
|
+
*/
|
|
91
|
+
function hashPathStat(hash, full, label, budget) {
|
|
92
|
+
let st;
|
|
93
|
+
try {
|
|
94
|
+
st = lstatSync(full);
|
|
95
|
+
} catch {
|
|
96
|
+
hash.update(`${label}\0missing\0`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (st.isDirectory() && budget) {
|
|
100
|
+
hash.update(`${label}\0dir\0${st.mtimeMs}\0`);
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(full, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
hash.update('unreadable\0');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
109
|
+
for (const ent of entries) {
|
|
110
|
+
if (ent.name === '.git') continue;
|
|
111
|
+
if (budget.left <= 0) {
|
|
112
|
+
hash.update(`${label}\0truncated\0`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
budget.left -= 1;
|
|
116
|
+
hashPathStat(hash, join(full, ent.name), `${label}/${ent.name}`, budget);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const kind = st.isSymbolicLink() ? 'link' : st.isFile() ? 'file' : st.isDirectory() ? 'dir' : 'other';
|
|
121
|
+
hash.update(`${label}\0${kind}\0${st.size}\0${st.mtimeMs}\0`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。
|
|
126
|
+
*
|
|
127
|
+
* 异步实现:`execFile` 不占用事件循环 —— 一个 MCP 服务进程承载多个会话,
|
|
128
|
+
* 同步 `spawnSync` 最长 8s 会把所有会话的 ACP 消息/审批一起卡住。
|
|
129
|
+
*
|
|
130
|
+
* 返回对象向后兼容:`changed_files` = 全量状态条目数,`entries` = 前 40 条
|
|
131
|
+
* 展示用状态行;新增 `fingerprint` 覆盖**全量**条目(含逐路径 size/mtime),
|
|
132
|
+
* 供 `sameWorkspaceSnapshot` 判断"内容是否真的变了"。
|
|
133
|
+
*/
|
|
134
|
+
export async function gitStatus(cwd) {
|
|
135
|
+
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
136
|
+
try {
|
|
137
|
+
// ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
|
|
138
|
+
// 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
|
|
139
|
+
// DSH_MCP_GIT_STATUS=0 完全关掉。
|
|
140
|
+
// porcelain 的路径相对 **仓库根**(cwd 是子目录时也是),所以同时取 top-level。
|
|
141
|
+
const [statusOut, rootOut] = await Promise.all([
|
|
142
|
+
gitOutput(cwd, [...GIT_SAFE, 'status', '--porcelain', '-z']),
|
|
143
|
+
gitOutput(cwd, [...GIT_SAFE, 'rev-parse', '--show-toplevel']),
|
|
144
|
+
]);
|
|
145
|
+
const rows = parsePorcelainZ(statusOut);
|
|
146
|
+
// ★ 干净的仓库返回“0 个改动”的快照,而不是 null:null 专指“拿不到快照”(非 git 目录、
|
|
147
|
+
// git 不可用、总开关关闭)。以前两者混用,一轮把唯一的改动删干净时就会出现
|
|
148
|
+
// workspace_changed=true 却 diff_stat=null —— 看不出是“现在干净了”还是“没查成”。
|
|
149
|
+
if (!rows.length) return { changed_files: 0, entries: [], fingerprint: CLEAN_FINGERPRINT };
|
|
150
|
+
// git 输出的是 `C:/...` 风格,统一成原生分隔符,后面 startsWith 才不会误判越界。
|
|
151
|
+
const root = resolve(String(rootOut ?? '').trim() || cwd);
|
|
152
|
+
|
|
153
|
+
// 状态行按路径排序后参与指纹,避免依赖 git 的输出顺序。
|
|
154
|
+
const sorted = rows
|
|
155
|
+
.slice()
|
|
156
|
+
.sort((a, b) =>
|
|
157
|
+
a.path === b.path
|
|
158
|
+
? a.status < b.status
|
|
159
|
+
? -1
|
|
160
|
+
: a.status > b.status
|
|
161
|
+
? 1
|
|
162
|
+
: 0
|
|
163
|
+
: a.path < b.path
|
|
164
|
+
? -1
|
|
165
|
+
: 1,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const hash = createHash('sha256');
|
|
169
|
+
const budget = { left: MAX_UNTRACKED_DIR_ENTRIES };
|
|
170
|
+
const entries = [];
|
|
171
|
+
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
|
172
|
+
for (const row of sorted) {
|
|
173
|
+
const display = row.orig ? `${row.status} ${row.orig} -> ${row.path}` : `${row.status} ${row.path}`;
|
|
174
|
+
if (entries.length < 40) entries.push(display);
|
|
175
|
+
hash.update(display);
|
|
176
|
+
hash.update('\0');
|
|
177
|
+
|
|
178
|
+
const cleanPath = row.path.endsWith('/') ? row.path.slice(0, -1) : row.path;
|
|
179
|
+
const full = resolve(root, cleanPath);
|
|
180
|
+
if (full !== root && !full.startsWith(rootPrefix)) {
|
|
181
|
+
hash.update('outside-worktree\0');
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// 只有未跟踪目录需要递归;已跟踪条目(含子模块)看 lstat 即可。
|
|
185
|
+
hashPathStat(hash, full, cleanPath, row.status === '??' ? budget : null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
changed_files: rows.length,
|
|
190
|
+
entries,
|
|
191
|
+
fingerprint: hash.digest('hex'),
|
|
192
|
+
};
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub 的两类磁盘小状态:会话元数据侧车与 forget 墓碑。
|
|
3
|
+
*
|
|
4
|
+
* 从 hub.mjs 拆出来:两者都只是"按 id 一个小 JSON 文件"的读写,不依赖 Hub/Conversation
|
|
5
|
+
* 的运行期状态(写侧车只用 `conv.toMeta()` 与 `conv.id`)。红线不变:只存元数据,
|
|
6
|
+
* 不存任何对话/思考正文。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { DSH_HOME, STATE_FILE } from './config.mjs';
|
|
12
|
+
import { findSessionHeaderStatus } from './workspace.mjs';
|
|
13
|
+
|
|
14
|
+
/** 没有注入 logger 时的占位(Hub 构造器与 pruneStaleTombstones 的默认值共用)。 */
|
|
15
|
+
export const NOOP_LOG = { info: () => {}, debug: () => {}, dshStderr: () => {} };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 会话元数据侧车(`.state/meta/<id>.json`)。
|
|
19
|
+
*
|
|
20
|
+
* 为什么需要:`forget=true` 只从注册表移除;之后按 id 查询时,`Hub.get()` 会从
|
|
21
|
+
* DSH 会话存储重建会话(这是多实例恢复的刻意设计)。但 DSH 的会话记录里**没有**
|
|
22
|
+
* 本服务选择的 `onApproval` / `reasoning_effort` / `title_hint` / 轮数,
|
|
23
|
+
* 重建就会悄悄回落到默认值(实测 auto-allow→ask、low→max)。这个侧车保存
|
|
24
|
+
* **仅元数据**(不含任何对话/思考正文),让重建后仍与原会话一致。
|
|
25
|
+
*/
|
|
26
|
+
export const META_DIR = join(dirname(STATE_FILE), 'meta');
|
|
27
|
+
const metaFilePath = (id) => join(META_DIR, `${id}.json`);
|
|
28
|
+
|
|
29
|
+
export function writeConversationMeta(conv) {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(META_DIR, { recursive: true });
|
|
32
|
+
const file = metaFilePath(conv.id);
|
|
33
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(conv.toMeta(), null, 2), 'utf8');
|
|
35
|
+
renameSync(tmp, file);
|
|
36
|
+
} catch {
|
|
37
|
+
/* 元数据侧车只是辅助信息,写失败不影响主流程 */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readConversationMetaFile(id) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(metaFilePath(id), 'utf8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* forget 墓碑:`release(forget=true)` 之后,按 id 查询必须**明确失败**,
|
|
51
|
+
* 而不是从 DSH 会话存储里悄悄复活。墓碑只含 id 与时间,不含任何内容;
|
|
52
|
+
* 删除 `.state/forgotten/<id>.json` 即可恢复按 id 找回(手动、可审计)。
|
|
53
|
+
*/
|
|
54
|
+
// 墓碑放在 DSH home 下而不是注册表旁边:不同实例即使注册表路径不同,
|
|
55
|
+
// 也应该对同一个会话的 forget 达成一致(forget 是会话级终态,不是注册表级)。
|
|
56
|
+
const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
|
|
57
|
+
export const forgottenPath = (id) => join(FORGOTTEN_DIR, `${id}.json`);
|
|
58
|
+
|
|
59
|
+
export function writeTombstone(id) {
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(FORGOTTEN_DIR, { recursive: true });
|
|
62
|
+
const file = forgottenPath(id);
|
|
63
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
64
|
+
writeFileSync(tmp, JSON.stringify({ id, forgottenAt: Date.now() }, null, 2), 'utf8');
|
|
65
|
+
renameSync(tmp, file);
|
|
66
|
+
} catch {
|
|
67
|
+
/* 墓碑写失败时 forget 仍从注册表移除;下次 get 可能仍会复活,但不影响主流程 */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isForgotten(id) {
|
|
72
|
+
try {
|
|
73
|
+
return existsSync(forgottenPath(id));
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 启动时清掉“会话文件已经不存在”的墓碑:它们不再阻止任何 resurrection,
|
|
81
|
+
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
82
|
+
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
83
|
+
*/
|
|
84
|
+
export async function pruneStaleTombstones(log = NOOP_LOG) {
|
|
85
|
+
let removed = 0;
|
|
86
|
+
try {
|
|
87
|
+
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
88
|
+
for (const f of readdirSync(FORGOTTEN_DIR)) {
|
|
89
|
+
if (!f.endsWith('.json')) continue;
|
|
90
|
+
const id = f.slice(0, -5);
|
|
91
|
+
// 异步读会话头:启动路径不能被大日志的同步解压卡住。
|
|
92
|
+
// ★ 只有确认会话文件不存在时才删墓碑;读超时/损坏时保留,
|
|
93
|
+
// 否则一次慢盘/挂载异常就会让 forget 失效。
|
|
94
|
+
const found = await findSessionHeaderStatus(id);
|
|
95
|
+
if (found.status === 'found' || found.status === 'error') {
|
|
96
|
+
if (found.status === 'error') log.info(`[hub] 墓碑 ${id} 暂不清理:${found.reason}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
101
|
+
removed++;
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* 清理失败不影响启动 */
|
|
106
|
+
}
|
|
107
|
+
return removed;
|
|
108
|
+
}
|