dsh-any-agent 0.3.5 → 0.3.6
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 +38 -1
- package/DESIGN.md +1 -1
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/bin/restore.mjs +7 -3
- package/package.json +1 -1
- package/src/clients.mjs +25 -2
- package/src/hub.mjs +30 -7
- package/src/locks.mjs +39 -1
- package/src/mcp.mjs +6 -0
- package/src/tools.mjs +27 -6
- package/src/workspace.mjs +11 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
Post-0.3.6 hardening.
|
|
11
|
+
|
|
12
|
+
## [0.3.6] — 2026-11-02
|
|
13
|
+
|
|
10
14
|
Post-0.3.5 hardening (remaining completeness-audit items).
|
|
11
15
|
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `dsh_status` now reports `lock_holder_reason` and no longer calls `none`
|
|
19
|
+
"your GUI" when the conversation is simply idle/released; the GUI hint is
|
|
20
|
+
only shown after an actual lock conflict.
|
|
21
|
+
- `dsh_get` reports `title_source` (`native` / `title_hint` / `null`) and no
|
|
22
|
+
longer labels a `title_hint` fallback as "DSH generated".
|
|
23
|
+
- `dsh_read` caps a single output entry at 200k characters (keeps the tail)
|
|
24
|
+
so one runaway tool output cannot grow the in-memory buffer without bound.
|
|
25
|
+
- `dsh_list` exposes `probe_ok` / `probe_error` / `probe_cached`; a failed
|
|
26
|
+
on-disk probe is no longer indistinguishable from "no sessions".
|
|
27
|
+
- MCP clients that send `notifications/roots/list_changed` now invalidate the
|
|
28
|
+
cached project directory and re-request `roots/list`.
|
|
29
|
+
- Client config backups rotate (default 20 per file, `DSH_MCP_BACKUP_KEEP`);
|
|
30
|
+
`dsh-any-agent-restore` writes atomically and preserves the target mode.
|
|
31
|
+
- The shared `workspace.json` writer keeps a `<file>.bak-dsh` last-known-good
|
|
32
|
+
copy before replacing it.
|
|
33
|
+
- `dsh_takeover` now compares the child's OS start time with the registration
|
|
34
|
+
before sending SIGKILL; a mismatched PID (reuse) is refused.
|
|
35
|
+
- Website: full English page at `/en/`, hreflang alternates, bilingual sitemap,
|
|
36
|
+
JSON-LD, Cloudflare Insights domains in CSP, and a pre-paint theme script
|
|
37
|
+
(CSP sha256) so light-system users no longer see a dark flash.
|
|
38
|
+
|
|
39
|
+
### Known residual items
|
|
40
|
+
|
|
41
|
+
- Cloudflare Managed robots.txt / Content Signals are dashboard-managed; the
|
|
42
|
+
repository documents the split instead of trying to override them.
|
|
43
|
+
- Session-log header decompression is still synchronous; it is bounded by the
|
|
44
|
+
fact that only the header is parsed, but a very large log can still block
|
|
45
|
+
the event loop briefly. A fully async reader needs a broader refactor of
|
|
46
|
+
`Hub.get`/`findSessionHeader`.
|
|
47
|
+
|
|
12
48
|
## [0.3.5] — 2026-11-02
|
|
13
49
|
|
|
14
50
|
Completeness-audit follow-up. Data-safety and privilege boundaries first,
|
|
@@ -616,7 +652,8 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
|
|
|
616
652
|
profiles, sessions and client configs. The product name changed; the on-disk
|
|
617
653
|
contract did not. See the README FAQ.
|
|
618
654
|
|
|
619
|
-
[Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.
|
|
655
|
+
[Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...HEAD
|
|
656
|
+
[0.3.6]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...v0.3.6
|
|
620
657
|
[0.3.5]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.4...v0.3.5
|
|
621
658
|
[0.3.4]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.3...v0.3.4
|
|
622
659
|
[0.3.3]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.2...v0.3.3
|
package/DESIGN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 目标:做一个 MCP Server,让 **Codex / Claude Code** 能调用 **DeepSeek Harness (DSH)** 当"外包工程师"——写代码、搭脚手架、跑任务,并且 DSH 自己还能挂它自己的 MCP。
|
|
4
4
|
>
|
|
5
|
-
> 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.
|
|
5
|
+
> 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.6 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪 815 checks green on 0.3.
|
|
18
|
+
🧪 815 checks green on 0.3.6 on this machine (851 when all nine client fixtures exist); 519 of them are in the client-registration suites (555 with all fixtures) · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
|
|
19
19
|
|
|
20
20
|
An MCP server that drives DeepSeek Harness as a **long-lived coding agent**. Hand it a goal, let it write code and run scripts, steer it mid-flight, and collect the result when you are ready.
|
|
21
21
|
|
|
@@ -541,6 +541,7 @@ Silent by default — **not one byte is written**.
|
|
|
541
541
|
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
|
|
542
542
|
| `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
|
|
543
543
|
| `DSH_MCP_GIT_STATUS` | on | `0` disables the per-turn `git status` summary. Useful for untrusted repos (git can run repo-configured fsmonitor/filters outside DSH's sandbox) |
|
|
544
|
+
| `DSH_MCP_BACKUP_KEEP` | `20` | How many `.bak-dsh-*` copies to keep per client config (rotation is best-effort) |
|
|
544
545
|
| `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | Per-request timeout on the ACP channel (initialize, session/new, …) |
|
|
545
546
|
| `DSH_MCP_REAP_INTERVAL_MS` | `30000` | How often the idle reaper checks for conversations to reclaim. Values below the safe floor fall back to the default |
|
|
546
547
|
| `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
|
|
@@ -594,11 +595,11 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
594
595
|
| `list` | 13 | **the cost of `dsh_list`** (measured): a default call with the on-disk probe takes ~1 s (it spawns a DSH process) while **repeated calls hit a cache and drop to single-digit milliseconds**; `only_running=true` and `include_closed=false` **skip the probe** with equivalent semantics (unopened on-disk sessions are simply excluded) |
|
|
595
596
|
| `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
|
|
596
597
|
|
|
597
|
-
**`node test/run.mjs` runs 815 checks on a machine with five client fixtures (851 with all nine)** — smoke 29 + 786 offline checks (822 with nine). Every suite exited 0 on 0.3.
|
|
598
|
+
**`node test/run.mjs` runs 815 checks on a machine with five client fixtures (851 with all nine)** — smoke 29 + 786 offline checks (822 with nine). Every suite exited 0 on 0.3.6; none needs a provider (smoke needs a DSH install for its session-creation section).
|
|
598
599
|
|
|
599
600
|
That is **519 assertions in the client-registration suites alone** (`setup` 41, `removal-scope` 24, `config-safety` 406, `real-configs` 48; 555 with all nine fixtures), which is where the 0.3 work actually is.
|
|
600
601
|
|
|
601
|
-
The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8) total 474 and were last measured in full on 0.3.
|
|
602
|
+
The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8) total 474 and were last measured in full on 0.3.6 — they need a working provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` now **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`).
|
|
602
603
|
|
|
603
604
|
> Three different totals are intentional: `node test/run.mjs` (815/851), the CI offline job (786/822, no smoke), and the model-backed set (474 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
|
|
604
605
|
|
package/README.zh.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪 0.3.
|
|
18
|
+
🧪 0.3.6 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
|
|
19
19
|
|
|
20
20
|
一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
|
|
21
21
|
|
|
@@ -556,6 +556,7 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
|
|
|
556
556
|
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0`(不设超时) | **仅 `wait=true` 时**的等待上限;到期只转后台,不取消回合 |
|
|
557
557
|
| `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
|
|
558
558
|
| `DSH_MCP_GIT_STATUS` | 开 | `0` 关闭每回合的 `git status` 摘要。分析不可信仓库时建议关掉(git 可能触发仓库配置的 fsmonitor/filter,且发生在 DSH 沙箱之外) |
|
|
559
|
+
| `DSH_MCP_BACKUP_KEEP` | `20` | 每个客户端配置最多保留多少份 `.bak-dsh-*`(尽力轮转) |
|
|
559
560
|
| `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | ACP 通道单请求超时(initialize、session/new 等) |
|
|
560
561
|
| `DSH_MCP_REAP_INTERVAL_MS` | `30000` | 空闲回收器多久检查一次。低于安全下限的值会回落到默认值 |
|
|
561
562
|
| `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
|
|
@@ -609,11 +610,11 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
|
|
|
609
610
|
| `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
|
|
610
611
|
| `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
|
|
611
612
|
|
|
612
|
-
**`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.
|
|
613
|
+
**`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.6 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
|
|
613
614
|
|
|
614
615
|
其中 **519 项属于客户端注册器套件**(`setup` 41、`removal-scope` 24、`config-safety` 406、`real-configs` 48;九种齐全时 555 项)—— 0.3 的活其实都在这里。
|
|
615
616
|
|
|
616
|
-
需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.
|
|
617
|
+
需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.6** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
|
|
617
618
|
|
|
618
619
|
> 三个总数是刻意分开的:`node test/run.mjs`(815/851)、CI 离线 job(786/822,不含 smoke)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
|
|
619
620
|
|
package/bin/restore.mjs
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
* (后缀 .bak-dsh-pre-restore-<stamp>),退错了还能再退回来。
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { chmodSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
20
20
|
import { basename, dirname, join } from 'node:path';
|
|
21
21
|
|
|
22
|
-
import { backupFile, clientList, resolveDirs, ENTRY_NAME } from '../src/clients.mjs';
|
|
22
|
+
import { atomicWrite, backupFile, clientList, resolveDirs, ENTRY_NAME } from '../src/clients.mjs';
|
|
23
23
|
|
|
24
24
|
const argv = process.argv.slice(2);
|
|
25
25
|
const has = (f) => argv.includes(f);
|
|
@@ -127,7 +127,11 @@ for (const b of chosen) {
|
|
|
127
127
|
try {
|
|
128
128
|
// 还原也是破坏性操作:先把「现在」也存一份。
|
|
129
129
|
if (existsSync(b.target)) row.safety = backupFile(b.target, 'pre-restore-' + stamp);
|
|
130
|
-
|
|
130
|
+
// tmp + rename:崩溃时不会留下半截配置;还原后保留原文件的权限位。
|
|
131
|
+
let mode = null;
|
|
132
|
+
try { mode = statSync(b.target).mode; } catch {}
|
|
133
|
+
atomicWrite(b.target, readFileSync(b.file, 'utf8'));
|
|
134
|
+
if (mode !== null) { try { chmodSync(b.target, mode); } catch {} }
|
|
131
135
|
} catch (e) {
|
|
132
136
|
row.action = 'failed';
|
|
133
137
|
row.error = e.message;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code. Resume-aware, interruptible, approval-capable, and it only ever touches the one config entry it owns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/clients.mjs
CHANGED
|
@@ -25,13 +25,14 @@ import {
|
|
|
25
25
|
existsSync,
|
|
26
26
|
mkdirSync,
|
|
27
27
|
readFileSync,
|
|
28
|
+
readdirSync,
|
|
28
29
|
renameSync,
|
|
29
30
|
statSync,
|
|
30
31
|
unlinkSync,
|
|
31
32
|
writeFileSync,
|
|
32
33
|
} from 'node:fs';
|
|
33
34
|
import { platform } from 'node:os';
|
|
34
|
-
import { dirname } from 'node:path';
|
|
35
|
+
import { basename, dirname, join } from 'node:path';
|
|
35
36
|
import { fileURLToPath } from 'node:url';
|
|
36
37
|
|
|
37
38
|
/** 写进每个客户端的条目名。同时也是 --remove 唯一的识别依据。 */
|
|
@@ -57,9 +58,31 @@ export function backupFile(file, stamp) {
|
|
|
57
58
|
let dst = base;
|
|
58
59
|
for (let i = 2; existsSync(dst); i++) dst = base + '-' + i;
|
|
59
60
|
copyFileSync(file, dst);
|
|
61
|
+
pruneBackups(file);
|
|
60
62
|
return dst;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 备份轮转:同一目标只保留最近 N 份(默认 20,DSH_MCP_BACKUP_KEEP 可调)。
|
|
67
|
+
* 备份里可能有凭据,无限累积既占空间也扩大暴露面;保留最近若干份足够回滚。
|
|
68
|
+
*/
|
|
69
|
+
export function pruneBackups(file) {
|
|
70
|
+
const raw = Number(process.env.DSH_MCP_BACKUP_KEEP ?? 20);
|
|
71
|
+
const keep = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 20;
|
|
72
|
+
try {
|
|
73
|
+
const dir = dirname(file);
|
|
74
|
+
const prefix = basename(file) + '.bak-dsh-';
|
|
75
|
+
const backups = readdirSync(dir)
|
|
76
|
+
.filter((name) => name.startsWith(prefix))
|
|
77
|
+
.sort();
|
|
78
|
+
for (const name of backups.slice(0, Math.max(0, backups.length - keep))) {
|
|
79
|
+
try { unlinkSync(join(dir, name)); } catch {}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
/* 轮转失败不影响本次备份 */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
/**
|
|
64
87
|
* 先写临时文件再改名,避免崩溃时留下半截配置。
|
|
65
88
|
* 临时名带 pid + 随机后缀:同名会在并发运行时互相覆盖。
|
|
@@ -585,7 +608,7 @@ export function describeEntry(shape, entry) {
|
|
|
585
608
|
|
|
586
609
|
import { spawnSync } from 'node:child_process';
|
|
587
610
|
import { homedir } from 'node:os';
|
|
588
|
-
import {
|
|
611
|
+
import { resolve } from 'node:path';
|
|
589
612
|
|
|
590
613
|
/** 解析出本次要用的 home 与 appdata(--home / 环境变量 / 平台默认)。 */
|
|
591
614
|
export function resolveDirs(homeOverride) {
|
package/src/hub.mjs
CHANGED
|
@@ -155,6 +155,8 @@ function pruneStaleTombstones() {
|
|
|
155
155
|
|
|
156
156
|
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
157
157
|
const MAX_OUT_ENTRIES = 500;
|
|
158
|
+
/** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
|
|
159
|
+
const MAX_OUT_TEXT = 200_000;
|
|
158
160
|
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
159
161
|
const MAX_PROMPT_ECHO = 4000;
|
|
160
162
|
/** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
|
|
@@ -350,10 +352,15 @@ export class Conversation {
|
|
|
350
352
|
snapshot() {
|
|
351
353
|
const meta = readConversationMeta(this.id);
|
|
352
354
|
const nativeTitle = meta?.title ?? null;
|
|
355
|
+
const holder = this.proc?.alive
|
|
356
|
+
? { kind: 'self', reason: '本进程持有该会话的写锁' }
|
|
357
|
+
: classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
353
358
|
return {
|
|
354
359
|
conversation_id: this.id,
|
|
355
360
|
/** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
|
|
356
361
|
title: nativeTitle ?? this.titleHint ?? null,
|
|
362
|
+
/** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
|
|
363
|
+
title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
|
|
357
364
|
/**
|
|
358
365
|
* DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
|
|
359
366
|
* 例如「这是一次连通性冒烟测试,请」),信息量很低。
|
|
@@ -390,8 +397,13 @@ export class Conversation {
|
|
|
390
397
|
approval_id: approvalId,
|
|
391
398
|
...pending.params.details,
|
|
392
399
|
})),
|
|
393
|
-
/**
|
|
394
|
-
|
|
400
|
+
/**
|
|
401
|
+
* 不在本进程手里时,写锁在谁手上:
|
|
402
|
+
* self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
|
|
403
|
+
* 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
|
|
404
|
+
*/
|
|
405
|
+
lock_holder: holder.kind,
|
|
406
|
+
lock_holder_reason: holder.reason ?? null,
|
|
395
407
|
/** ★ 最近一次"撞写锁"的持久记录。
|
|
396
408
|
* 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
|
|
397
409
|
* dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
|
|
@@ -569,7 +581,7 @@ export class Conversation {
|
|
|
569
581
|
}
|
|
570
582
|
|
|
571
583
|
// 3) 杀 + 重试(内核在持有者进程退出时释放信号量)
|
|
572
|
-
const kill = killHolderChild(killTarget);
|
|
584
|
+
const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
|
|
573
585
|
report.killed_pid = killTarget;
|
|
574
586
|
report.kill = kill;
|
|
575
587
|
if (!kill.killed) {
|
|
@@ -949,14 +961,15 @@ export class Conversation {
|
|
|
949
961
|
/** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
|
|
950
962
|
pushOut(kind, text, extra = {}) {
|
|
951
963
|
const last = this.out[this.out.length - 1];
|
|
964
|
+
const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
|
|
952
965
|
if (last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
953
|
-
last.text
|
|
966
|
+
last.text = cap(last.text + text);
|
|
954
967
|
last.at = now();
|
|
955
968
|
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
956
969
|
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
957
970
|
last.rev = ++this.outRev;
|
|
958
971
|
} else {
|
|
959
|
-
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
|
|
972
|
+
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
|
|
960
973
|
if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
|
|
961
974
|
}
|
|
962
975
|
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
@@ -1183,6 +1196,8 @@ export class Hub {
|
|
|
1183
1196
|
constructor({ log } = {}) {
|
|
1184
1197
|
this.conversations = new Map();
|
|
1185
1198
|
this.log = log ?? NOOP_LOG;
|
|
1199
|
+
/** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
|
|
1200
|
+
this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
|
|
1186
1201
|
const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
|
|
1187
1202
|
// 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
|
|
1188
1203
|
this.approvalTimeoutMs =
|
|
@@ -1591,7 +1606,10 @@ export class Hub {
|
|
|
1591
1606
|
if (isForgotten(c.id)) continue;
|
|
1592
1607
|
out.set(c.id, c.snapshot());
|
|
1593
1608
|
}
|
|
1594
|
-
if (!includeClosed)
|
|
1609
|
+
if (!includeClosed) {
|
|
1610
|
+
this.lastProbe = { ok: null, error: null, cached: false, at: now() };
|
|
1611
|
+
return [...out.values()];
|
|
1612
|
+
}
|
|
1595
1613
|
|
|
1596
1614
|
// 磁盘探测要 spawn 一个 DSH 进程(实测约 1 秒),所以同 cwd 的结果缓存一小段时间,
|
|
1597
1615
|
// 避免"连着列几次会话"就反复拉进程。
|
|
@@ -1603,7 +1621,9 @@ export class Hub {
|
|
|
1603
1621
|
|
|
1604
1622
|
const probeCwd = cwd ?? [...this.conversations.values()][0]?.cwd ?? process.cwd();
|
|
1605
1623
|
let sessions = cached;
|
|
1606
|
-
if (
|
|
1624
|
+
if (cached) {
|
|
1625
|
+
this.lastProbe = { ok: true, error: null, cached: true, at: this.#probeCache.at };
|
|
1626
|
+
} else if (!sessions) {
|
|
1607
1627
|
let proc = null;
|
|
1608
1628
|
try {
|
|
1609
1629
|
proc = new AcpProcess({ cwd: probeCwd, permission: 'read-only' });
|
|
@@ -1612,9 +1632,12 @@ export class Hub {
|
|
|
1612
1632
|
const res = await proc.request('session/list', cwd ? { cwd } : {});
|
|
1613
1633
|
sessions = res?.sessions ?? [];
|
|
1614
1634
|
this.#probeCache = { key: cacheKey, at: now(), sessions };
|
|
1635
|
+
this.lastProbe = { ok: true, error: null, cached: false, at: this.#probeCache.at };
|
|
1615
1636
|
} catch (e) {
|
|
1616
1637
|
this.log.info(`[hub] session/list 探测失败: ${e.message}`);
|
|
1617
1638
|
sessions = []; // 失败不缓存,下次重试
|
|
1639
|
+
// ★ 调用方必须能区分"没有别的会话"和"探测失败",否则会以为会话丢了。
|
|
1640
|
+
this.lastProbe = { ok: false, error: e.message, cached: false, at: now() };
|
|
1618
1641
|
} finally {
|
|
1619
1642
|
await proc?.stop().catch(() => {});
|
|
1620
1643
|
}
|
package/src/locks.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, renameSync } from 'node:fs';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
30
31
|
import { join, dirname } from 'node:path';
|
|
31
32
|
import { randomUUID } from 'node:crypto';
|
|
32
33
|
import { STATE_FILE, REAP_INTERVAL_MS, numEnv } from './config.mjs';
|
|
@@ -220,8 +221,21 @@ export function classifyHolder(conversationId, { knownChildPid = null } = {}) {
|
|
|
220
221
|
* 杀掉一个 DSH 子进程。**只允许对 classification 判定为 `stale-mcp` / `live-mcp(force)`
|
|
221
222
|
* 的、由本 MCP 登记过的进程调用** —— 调用方负责先做分类。
|
|
222
223
|
*/
|
|
223
|
-
export function killHolderChild(pid) {
|
|
224
|
+
export function killHolderChild(pid, { expectedSpawnedAt } = {}) {
|
|
224
225
|
if (!pid || !isPidAlive(pid)) return { killed: false, reason: '进程已经不在了' };
|
|
226
|
+
// ★ PID 会被复用。抢占是"杀进程"这种不可逆动作,所以先核对启动时间:
|
|
227
|
+
// 登记时间与 OS 记录的启动时间差太多 → 这已经不是当初登记的那个进程,拒绝杀。
|
|
228
|
+
if (Number.isFinite(expectedSpawnedAt)) {
|
|
229
|
+
const actual = processStartTime(pid);
|
|
230
|
+
if (Number.isFinite(actual) && Math.abs(actual - expectedSpawnedAt) > 10 * 60 * 1000) {
|
|
231
|
+
return {
|
|
232
|
+
killed: false,
|
|
233
|
+
reason:
|
|
234
|
+
`PID ${pid} 的启动时间与登记不符(登记 ${new Date(expectedSpawnedAt).toISOString()},` +
|
|
235
|
+
`实际 ${new Date(actual).toISOString()}),疑似 PID 已被复用,拒绝杀。`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
225
239
|
try {
|
|
226
240
|
process.kill(pid, 'SIGKILL');
|
|
227
241
|
} catch (e) {
|
|
@@ -230,6 +244,30 @@ export function killHolderChild(pid) {
|
|
|
230
244
|
return { killed: true, reason: `已向 PID ${pid} 发送 SIGKILL` };
|
|
231
245
|
}
|
|
232
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 读取进程启动时间(毫秒时间戳)。拿不到时返回 null(调用方按旧行为处理)。
|
|
249
|
+
* Windows 用 PowerShell 的 Get-Process.StartTime;POSIX 用 ps -o lstart=。
|
|
250
|
+
* 只在 takeover 这种低频、不可逆动作前调用,不在热路径上。
|
|
251
|
+
*/
|
|
252
|
+
export function processStartTime(pid) {
|
|
253
|
+
if (!pid) return null;
|
|
254
|
+
try {
|
|
255
|
+
const args =
|
|
256
|
+
process.platform === 'win32'
|
|
257
|
+
? ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToString('o')`]
|
|
258
|
+
: ['-o', 'lstart=', '-p', String(pid)];
|
|
259
|
+
const r = spawnSync(process.platform === 'win32' ? 'powershell' : 'ps', args, {
|
|
260
|
+
encoding: 'utf8',
|
|
261
|
+
timeout: 3000,
|
|
262
|
+
windowsHide: true,
|
|
263
|
+
});
|
|
264
|
+
const t = Date.parse(String(r.stdout || '').trim());
|
|
265
|
+
return Number.isFinite(t) ? t : null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
233
271
|
/**
|
|
234
272
|
* 启动清理:删掉"MCP 已死 + 子进程也死了"的登记文件。
|
|
235
273
|
* 注意**不删**那些子进程还活着的登记 —— 那正是后面抢占要用的线索。
|
package/src/mcp.mjs
CHANGED
|
@@ -183,6 +183,12 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
|
|
|
183
183
|
// 通知(无 id):不需要回包
|
|
184
184
|
if (id === undefined) {
|
|
185
185
|
if (method === 'notifications/initialized') initialized = true;
|
|
186
|
+
// 客户端换了打开的项目:丢掉缓存并重新问一次,下一次 dsh_start 才会用新目录。
|
|
187
|
+
if (method === 'notifications/roots/list_changed') {
|
|
188
|
+
clientRoots = null;
|
|
189
|
+
rootsRequested = false;
|
|
190
|
+
requestRoots().catch(() => {});
|
|
191
|
+
}
|
|
186
192
|
return;
|
|
187
193
|
}
|
|
188
194
|
|
package/src/tools.mjs
CHANGED
|
@@ -459,9 +459,13 @@ export function createHandlers(hub, opts = {}) {
|
|
|
459
459
|
if (c.turns != null) bits.push(`轮数=${c.turns}`);
|
|
460
460
|
return `- ${bits.join(' | ')}`;
|
|
461
461
|
};
|
|
462
|
+
const probe = hub.lastProbe ?? { ok: null, error: null, cached: false };
|
|
463
|
+
const probeNote = probe.ok === false
|
|
464
|
+
? `\n\n⚠️ 磁盘会话探测失败:${probe.error}\n 上面只列出本服务已打开的会话;磁盘上未打开的会话可能没显示。`
|
|
465
|
+
: '';
|
|
462
466
|
const text = items.length
|
|
463
467
|
? `${items.length} 个会话(其中 ${running} 个正在跑):\n${items.map(describe).join('\n')}`
|
|
464
|
-
: '(没有会话)';
|
|
468
|
+
: probe.ok === false ? '(本服务没有已打开的会话;磁盘探测失败)' : '(没有会话)';
|
|
465
469
|
// 孤儿锁持有者:上一代 MCP 崩了但它的 DSH 子进程还活着握着写锁 —— 不主动报出来,
|
|
466
470
|
// 用户只会在下一次撞上"被占用"时才发现。这里顺手提示,并给出可执行的动作。
|
|
467
471
|
const orphans = listOrphans();
|
|
@@ -471,8 +475,16 @@ export function createHandlers(hub, opts = {}) {
|
|
|
471
475
|
'\n→ 用 dsh_takeover 抢占即可(这些是本服务自己的残留,抢占是安全的)。'
|
|
472
476
|
: '';
|
|
473
477
|
return {
|
|
474
|
-
structured: {
|
|
475
|
-
|
|
478
|
+
structured: {
|
|
479
|
+
count: items.length,
|
|
480
|
+
running,
|
|
481
|
+
conversations: items,
|
|
482
|
+
orphan_holders: orphans,
|
|
483
|
+
probe_ok: probe.ok,
|
|
484
|
+
probe_error: probe.error ?? null,
|
|
485
|
+
probe_cached: probe.cached === true,
|
|
486
|
+
},
|
|
487
|
+
text: text + probeNote + orphanNote,
|
|
476
488
|
};
|
|
477
489
|
},
|
|
478
490
|
|
|
@@ -508,7 +520,12 @@ export function createHandlers(hub, opts = {}) {
|
|
|
508
520
|
let run = null;
|
|
509
521
|
const meta = [];
|
|
510
522
|
// DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
|
|
511
|
-
|
|
523
|
+
const titleKind = snap.title_source === 'native'
|
|
524
|
+
? 'DSH 自动生成'
|
|
525
|
+
: snap.title_source === 'title_hint'
|
|
526
|
+
? '来自 title_hint(DSH 尚未生成标题)'
|
|
527
|
+
: '尚未生成';
|
|
528
|
+
meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(${titleKind})` +
|
|
512
529
|
(snap.title_hint && snap.title !== snap.title_hint ? ` / 标题提示: ${snap.title_hint}` : ''));
|
|
513
530
|
meta.push(`工作区: ${snap.cwd}`);
|
|
514
531
|
meta.push(`状态: ${snap.busy ? '忙' : snap.alive ? '空闲(进程活着)' : '未打开(可用 resume 复活)'}`);
|
|
@@ -641,11 +658,14 @@ export function createHandlers(hub, opts = {}) {
|
|
|
641
658
|
if (snap.lock_holder && snap.lock_holder !== 'self') {
|
|
642
659
|
const hint =
|
|
643
660
|
snap.lock_holder === 'stale-mcp'
|
|
644
|
-
? '
|
|
661
|
+
? '(本服务残留/卡死的持有者,可 dsh_takeover 抢占)'
|
|
645
662
|
: snap.lock_holder === 'live-mcp'
|
|
646
663
|
? '(另一个活着的 dsh-any-agent 实例在用;要夺需 dsh_takeover(force=true))'
|
|
647
|
-
:
|
|
664
|
+
: snap.lock_conflict
|
|
665
|
+
? '(最近撞过锁:多半是你自己的 DSH GUI 正开着它;本服务不会去杀它)'
|
|
666
|
+
: '(没有本服务的持锁登记;空闲/已释放时也会是 none,只有在撞锁时才可能是 GUI)';
|
|
648
667
|
bits.push(`写锁=${snap.lock_holder}${hint}`);
|
|
668
|
+
if (snap.lock_holder_reason) bits.push(` 判定:${snap.lock_holder_reason}`);
|
|
649
669
|
}
|
|
650
670
|
// ★ 撞过锁就把"谁占用 + 怎么解"讲清楚。实测:GUI 点开一次就会长期持有写锁
|
|
651
671
|
// (切走、等十几分钟都不释放),所以这条信息必须持久可见,而不是一闪而过的报错。
|
|
@@ -669,6 +689,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
669
689
|
pending_approval_details: snap.pending_approval_details,
|
|
670
690
|
turns: snap.turns,
|
|
671
691
|
lock_holder: snap.lock_holder,
|
|
692
|
+
lock_holder_reason: snap.lock_holder_reason,
|
|
672
693
|
lock_conflict: snap.lock_conflict,
|
|
673
694
|
run: run
|
|
674
695
|
? {
|
package/src/workspace.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { copyFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
20
|
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
@@ -152,6 +152,12 @@ function readDoc() {
|
|
|
152
152
|
|
|
153
153
|
function writeDoc(doc) {
|
|
154
154
|
mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
|
|
155
|
+
// 保留上一份**可解析**的注册表:逻辑写坏时用户还能手工回滚。
|
|
156
|
+
try {
|
|
157
|
+
if (existsSync(WORKSPACE_FILE)) copyFileSync(WORKSPACE_FILE, `${WORKSPACE_FILE}.bak-dsh`);
|
|
158
|
+
} catch {
|
|
159
|
+
/* 备份失败不阻断写入 */
|
|
160
|
+
}
|
|
155
161
|
// 临时文件名必须唯一:固定 `.dshmcp.tmp` 会让两个实例/CLI 并发写互相覆盖。
|
|
156
162
|
const tmp = `${WORKSPACE_FILE}.${process.pid}.${randomUUID()}.tmp`;
|
|
157
163
|
try {
|
|
@@ -355,9 +361,10 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
|
|
|
355
361
|
* 删除测试会话(**这是唯一会动磁盘会话文件的函数**,所以规则写得很死)。
|
|
356
362
|
*
|
|
357
363
|
* 只会删这几类,其余一律不碰:
|
|
358
|
-
* 1)
|
|
359
|
-
* 2)
|
|
360
|
-
* 3)
|
|
364
|
+
* 1) testOnly:cwd 在 `tmpdir()/dsh-any-agent-*` 下(本仓库测试自己造的)
|
|
365
|
+
* 2) cwd 匹配 Desktop\dsh-any-agent-test-*
|
|
366
|
+
* 3) tempOnly:cwd 在系统临时目录下(显式人工命令才用,范围最大)
|
|
367
|
+
* 4) ids 里显式指定的
|
|
361
368
|
*
|
|
362
369
|
* @param {object} opts
|
|
363
370
|
* @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)
|