dsh-any-agent 0.3.5 → 0.3.7
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 +57 -1
- package/DESIGN.md +1 -1
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/bin/dsh-any-agent.mjs +1 -1
- package/bin/restore.mjs +7 -3
- package/package.json +1 -1
- package/src/clients.mjs +25 -2
- package/src/hub.mjs +37 -13
- package/src/locks.mjs +39 -1
- package/src/mcp.mjs +6 -0
- package/src/tools.mjs +36 -15
- package/src/workspace.mjs +67 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,62 @@ 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.7 hardening.
|
|
11
|
+
|
|
12
|
+
## [0.3.7] — 2026-11-02
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Session-header lookup no longer blocks the event loop.** `Hub.get()`,
|
|
17
|
+
`Hub.load()` and `pruneStaleTombstones()` now use a streaming
|
|
18
|
+
`readHeaderAsync()` that stops at the first newline, with a 5 s timeout and
|
|
19
|
+
1 MiB header cap. Large session logs can no longer stall MCP requests,
|
|
20
|
+
heartbeat renewal, or approval handling while recovering an unknown
|
|
21
|
+
conversation. The synchronous reader remains only in the explicit
|
|
22
|
+
workspace CLI/test-cleanup path.
|
|
23
|
+
- **Website robots policy mirrors Cloudflare Managed content.** The generated
|
|
24
|
+
`robots.txt` now states `Content-Signal: search=yes, ai-train=no` and
|
|
25
|
+
disallows GPTBot/ClaudeBot/Google-Extended/CCBot, so non-Cloudflare hosts
|
|
26
|
+
and the repository agree with the production policy.
|
|
27
|
+
- Website tests now cover the English locale, robots policy, hreflang, CSP
|
|
28
|
+
hash, and bilingual pages (20 tests).
|
|
29
|
+
|
|
30
|
+
## [0.3.6] — 2026-11-02
|
|
31
|
+
|
|
10
32
|
Post-0.3.5 hardening (remaining completeness-audit items).
|
|
11
33
|
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `dsh_status` now reports `lock_holder_reason` and no longer calls `none`
|
|
37
|
+
"your GUI" when the conversation is simply idle/released; the GUI hint is
|
|
38
|
+
only shown after an actual lock conflict.
|
|
39
|
+
- `dsh_get` reports `title_source` (`native` / `title_hint` / `null`) and no
|
|
40
|
+
longer labels a `title_hint` fallback as "DSH generated".
|
|
41
|
+
- `dsh_read` caps a single output entry at 200k characters (keeps the tail)
|
|
42
|
+
so one runaway tool output cannot grow the in-memory buffer without bound.
|
|
43
|
+
- `dsh_list` exposes `probe_ok` / `probe_error` / `probe_cached`; a failed
|
|
44
|
+
on-disk probe is no longer indistinguishable from "no sessions".
|
|
45
|
+
- MCP clients that send `notifications/roots/list_changed` now invalidate the
|
|
46
|
+
cached project directory and re-request `roots/list`.
|
|
47
|
+
- Client config backups rotate (default 20 per file, `DSH_MCP_BACKUP_KEEP`);
|
|
48
|
+
`dsh-any-agent-restore` writes atomically and preserves the target mode.
|
|
49
|
+
- The shared `workspace.json` writer keeps a `<file>.bak-dsh` last-known-good
|
|
50
|
+
copy before replacing it.
|
|
51
|
+
- `dsh_takeover` now compares the child's OS start time with the registration
|
|
52
|
+
before sending SIGKILL; a mismatched PID (reuse) is refused.
|
|
53
|
+
- Website: full English page at `/en/`, hreflang alternates, bilingual sitemap,
|
|
54
|
+
JSON-LD, Cloudflare Insights domains in CSP, and a pre-paint theme script
|
|
55
|
+
(CSP sha256) so light-system users no longer see a dark flash.
|
|
56
|
+
|
|
57
|
+
### Known residual items
|
|
58
|
+
|
|
59
|
+
- Cloudflare Managed robots.txt / Content Signals are dashboard-managed; the
|
|
60
|
+
repository documents the split instead of trying to override them.
|
|
61
|
+
- Session-log header decompression is still synchronous; it is bounded by the
|
|
62
|
+
fact that only the header is parsed, but a very large log can still block
|
|
63
|
+
the event loop briefly. A fully async reader needs a broader refactor of
|
|
64
|
+
`Hub.get`/`findSessionHeader`.
|
|
65
|
+
|
|
12
66
|
## [0.3.5] — 2026-11-02
|
|
13
67
|
|
|
14
68
|
Completeness-audit follow-up. Data-safety and privilege boundaries first,
|
|
@@ -616,7 +670,9 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
|
|
|
616
670
|
profiles, sessions and client configs. The product name changed; the on-disk
|
|
617
671
|
contract did not. See the README FAQ.
|
|
618
672
|
|
|
619
|
-
[Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.
|
|
673
|
+
[Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.7...HEAD
|
|
674
|
+
[0.3.7]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...v0.3.7
|
|
675
|
+
[0.3.6]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...v0.3.6
|
|
620
676
|
[0.3.5]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.4...v0.3.5
|
|
621
677
|
[0.3.4]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.3...v0.3.4
|
|
622
678
|
[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.7 的缺失功能。技术判断最初基于 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.7 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.7; 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.7 — 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.7 本机 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.7 上全部退出码 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.7** —— 它们要可用 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/dsh-any-agent.mjs
CHANGED
|
@@ -59,7 +59,7 @@ const { createHandlers } = await import('../src/tools.mjs');
|
|
|
59
59
|
const log = createLogger();
|
|
60
60
|
|
|
61
61
|
const hub = new Hub({ log });
|
|
62
|
-
hub.load();
|
|
62
|
+
await hub.load();
|
|
63
63
|
hub.startReaper();
|
|
64
64
|
// 启动时清一次过期哨兵(哨兵内含正文,长期不清理会无声堆积)
|
|
65
65
|
const prunedSentinels = hub.pruneSentinels();
|
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.7",
|
|
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
|
@@ -134,14 +134,15 @@ function isForgotten(id) {
|
|
|
134
134
|
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
135
135
|
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
136
136
|
*/
|
|
137
|
-
function pruneStaleTombstones() {
|
|
137
|
+
async function pruneStaleTombstones() {
|
|
138
138
|
let removed = 0;
|
|
139
139
|
try {
|
|
140
140
|
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
141
141
|
for (const f of readdirSync(FORGOTTEN_DIR)) {
|
|
142
142
|
if (!f.endsWith('.json')) continue;
|
|
143
143
|
const id = f.slice(0, -5);
|
|
144
|
-
|
|
144
|
+
// 异步读会话头:启动路径不能被大日志的同步解压卡住。
|
|
145
|
+
if (await findSessionHeader(id)) continue;
|
|
145
146
|
try {
|
|
146
147
|
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
147
148
|
removed++;
|
|
@@ -155,6 +156,8 @@ function pruneStaleTombstones() {
|
|
|
155
156
|
|
|
156
157
|
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
157
158
|
const MAX_OUT_ENTRIES = 500;
|
|
159
|
+
/** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
|
|
160
|
+
const MAX_OUT_TEXT = 200_000;
|
|
158
161
|
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
159
162
|
const MAX_PROMPT_ECHO = 4000;
|
|
160
163
|
/** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
|
|
@@ -350,10 +353,15 @@ export class Conversation {
|
|
|
350
353
|
snapshot() {
|
|
351
354
|
const meta = readConversationMeta(this.id);
|
|
352
355
|
const nativeTitle = meta?.title ?? null;
|
|
356
|
+
const holder = this.proc?.alive
|
|
357
|
+
? { kind: 'self', reason: '本进程持有该会话的写锁' }
|
|
358
|
+
: classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
353
359
|
return {
|
|
354
360
|
conversation_id: this.id,
|
|
355
361
|
/** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
|
|
356
362
|
title: nativeTitle ?? this.titleHint ?? null,
|
|
363
|
+
/** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
|
|
364
|
+
title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
|
|
357
365
|
/**
|
|
358
366
|
* DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
|
|
359
367
|
* 例如「这是一次连通性冒烟测试,请」),信息量很低。
|
|
@@ -390,8 +398,13 @@ export class Conversation {
|
|
|
390
398
|
approval_id: approvalId,
|
|
391
399
|
...pending.params.details,
|
|
392
400
|
})),
|
|
393
|
-
/**
|
|
394
|
-
|
|
401
|
+
/**
|
|
402
|
+
* 不在本进程手里时,写锁在谁手上:
|
|
403
|
+
* self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
|
|
404
|
+
* 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
|
|
405
|
+
*/
|
|
406
|
+
lock_holder: holder.kind,
|
|
407
|
+
lock_holder_reason: holder.reason ?? null,
|
|
395
408
|
/** ★ 最近一次"撞写锁"的持久记录。
|
|
396
409
|
* 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
|
|
397
410
|
* dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
|
|
@@ -569,7 +582,7 @@ export class Conversation {
|
|
|
569
582
|
}
|
|
570
583
|
|
|
571
584
|
// 3) 杀 + 重试(内核在持有者进程退出时释放信号量)
|
|
572
|
-
const kill = killHolderChild(killTarget);
|
|
585
|
+
const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
|
|
573
586
|
report.killed_pid = killTarget;
|
|
574
587
|
report.kill = kill;
|
|
575
588
|
if (!kill.killed) {
|
|
@@ -949,14 +962,15 @@ export class Conversation {
|
|
|
949
962
|
/** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
|
|
950
963
|
pushOut(kind, text, extra = {}) {
|
|
951
964
|
const last = this.out[this.out.length - 1];
|
|
965
|
+
const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
|
|
952
966
|
if (last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
953
|
-
last.text
|
|
967
|
+
last.text = cap(last.text + text);
|
|
954
968
|
last.at = now();
|
|
955
969
|
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
956
970
|
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
957
971
|
last.rev = ++this.outRev;
|
|
958
972
|
} else {
|
|
959
|
-
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
|
|
973
|
+
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
|
|
960
974
|
if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
|
|
961
975
|
}
|
|
962
976
|
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
@@ -1183,6 +1197,8 @@ export class Hub {
|
|
|
1183
1197
|
constructor({ log } = {}) {
|
|
1184
1198
|
this.conversations = new Map();
|
|
1185
1199
|
this.log = log ?? NOOP_LOG;
|
|
1200
|
+
/** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
|
|
1201
|
+
this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
|
|
1186
1202
|
const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
|
|
1187
1203
|
// 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
|
|
1188
1204
|
this.approvalTimeoutMs =
|
|
@@ -1194,7 +1210,7 @@ export class Hub {
|
|
|
1194
1210
|
/** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
|
|
1195
1211
|
#probeCache = null;
|
|
1196
1212
|
|
|
1197
|
-
load() {
|
|
1213
|
+
async load() {
|
|
1198
1214
|
// ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
|
|
1199
1215
|
// 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
|
|
1200
1216
|
assertWritableDir(dirname(STATE_FILE), '状态目录');
|
|
@@ -1219,7 +1235,7 @@ export class Hub {
|
|
|
1219
1235
|
}
|
|
1220
1236
|
}
|
|
1221
1237
|
|
|
1222
|
-
const stale = pruneStaleTombstones();
|
|
1238
|
+
const stale = await pruneStaleTombstones();
|
|
1223
1239
|
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
1224
1240
|
if (!existsSync(STATE_FILE)) return;
|
|
1225
1241
|
try {
|
|
@@ -1358,7 +1374,7 @@ export class Hub {
|
|
|
1358
1374
|
}
|
|
1359
1375
|
}
|
|
1360
1376
|
|
|
1361
|
-
get(id) {
|
|
1377
|
+
async get(id) {
|
|
1362
1378
|
// ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
|
|
1363
1379
|
// 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
|
|
1364
1380
|
if (isForgotten(id)) {
|
|
@@ -1377,7 +1393,7 @@ export class Hub {
|
|
|
1377
1393
|
if (!c) {
|
|
1378
1394
|
// 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
|
|
1379
1395
|
// 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
|
|
1380
|
-
const header = findSessionHeader(id);
|
|
1396
|
+
const header = await findSessionHeader(id);
|
|
1381
1397
|
if (header?.cwd) {
|
|
1382
1398
|
// ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
|
|
1383
1399
|
// 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
|
|
@@ -1591,7 +1607,10 @@ export class Hub {
|
|
|
1591
1607
|
if (isForgotten(c.id)) continue;
|
|
1592
1608
|
out.set(c.id, c.snapshot());
|
|
1593
1609
|
}
|
|
1594
|
-
if (!includeClosed)
|
|
1610
|
+
if (!includeClosed) {
|
|
1611
|
+
this.lastProbe = { ok: null, error: null, cached: false, at: now() };
|
|
1612
|
+
return [...out.values()];
|
|
1613
|
+
}
|
|
1595
1614
|
|
|
1596
1615
|
// 磁盘探测要 spawn 一个 DSH 进程(实测约 1 秒),所以同 cwd 的结果缓存一小段时间,
|
|
1597
1616
|
// 避免"连着列几次会话"就反复拉进程。
|
|
@@ -1603,7 +1622,9 @@ export class Hub {
|
|
|
1603
1622
|
|
|
1604
1623
|
const probeCwd = cwd ?? [...this.conversations.values()][0]?.cwd ?? process.cwd();
|
|
1605
1624
|
let sessions = cached;
|
|
1606
|
-
if (
|
|
1625
|
+
if (cached) {
|
|
1626
|
+
this.lastProbe = { ok: true, error: null, cached: true, at: this.#probeCache.at };
|
|
1627
|
+
} else if (!sessions) {
|
|
1607
1628
|
let proc = null;
|
|
1608
1629
|
try {
|
|
1609
1630
|
proc = new AcpProcess({ cwd: probeCwd, permission: 'read-only' });
|
|
@@ -1612,9 +1633,12 @@ export class Hub {
|
|
|
1612
1633
|
const res = await proc.request('session/list', cwd ? { cwd } : {});
|
|
1613
1634
|
sessions = res?.sessions ?? [];
|
|
1614
1635
|
this.#probeCache = { key: cacheKey, at: now(), sessions };
|
|
1636
|
+
this.lastProbe = { ok: true, error: null, cached: false, at: this.#probeCache.at };
|
|
1615
1637
|
} catch (e) {
|
|
1616
1638
|
this.log.info(`[hub] session/list 探测失败: ${e.message}`);
|
|
1617
1639
|
sessions = []; // 失败不缓存,下次重试
|
|
1640
|
+
// ★ 调用方必须能区分"没有别的会话"和"探测失败",否则会以为会话丢了。
|
|
1641
|
+
this.lastProbe = { ok: false, error: e.message, cached: false, at: now() };
|
|
1618
1642
|
} finally {
|
|
1619
1643
|
await proc?.stop().catch(() => {});
|
|
1620
1644
|
}
|
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
|
@@ -403,7 +403,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
403
403
|
if (typeof args.prompt !== 'string' || args.prompt.trim() === '') {
|
|
404
404
|
throw new Error('prompt 不能为空:至少包含一个非空白字符');
|
|
405
405
|
}
|
|
406
|
-
const conv = hub.get(args.conversation_id);
|
|
406
|
+
const conv = await hub.get(args.conversation_id);
|
|
407
407
|
const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
|
|
408
408
|
if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
|
|
409
409
|
|
|
@@ -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,13 +475,21 @@ 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
|
|
|
479
491
|
async dsh_read(args) {
|
|
480
|
-
const conv = hub.get(args.conversation_id);
|
|
492
|
+
const conv = await hub.get(args.conversation_id);
|
|
481
493
|
const r = conv.readOut({
|
|
482
494
|
cursor: args.cursor ?? 0,
|
|
483
495
|
includeReasoning: args.include_reasoning === true,
|
|
@@ -503,12 +515,17 @@ export function createHandlers(hub, opts = {}) {
|
|
|
503
515
|
},
|
|
504
516
|
|
|
505
517
|
async dsh_get(args) {
|
|
506
|
-
const conv = hub.get(args.conversation_id);
|
|
518
|
+
const conv = await hub.get(args.conversation_id);
|
|
507
519
|
const snap = conv.snapshot();
|
|
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 复活)'}`);
|
|
@@ -558,7 +575,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
558
575
|
if (typeof args.message !== 'string' || args.message.trim() === '') {
|
|
559
576
|
throw new Error('message 不能为空:至少包含一个非空白字符');
|
|
560
577
|
}
|
|
561
|
-
const conv = hub.get(args.conversation_id);
|
|
578
|
+
const conv = await hub.get(args.conversation_id);
|
|
562
579
|
const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
|
|
563
580
|
if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
|
|
564
581
|
const r = await conv.interject(args.message, opts);
|
|
@@ -583,7 +600,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
583
600
|
},
|
|
584
601
|
|
|
585
602
|
async dsh_interrupt(args) {
|
|
586
|
-
const conv = hub.get(args.conversation_id);
|
|
603
|
+
const conv = await hub.get(args.conversation_id);
|
|
587
604
|
const r = await conv.interrupt();
|
|
588
605
|
return { structured: r, text: r.interrupted ? '已发送中断请求。' : `未中断:${r.reason}` };
|
|
589
606
|
},
|
|
@@ -603,7 +620,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
603
620
|
`\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
|
|
604
621
|
};
|
|
605
622
|
}
|
|
606
|
-
const conv = hub.get(args.conversation_id);
|
|
623
|
+
const conv = await hub.get(args.conversation_id);
|
|
607
624
|
const r = await conv.close({ forget: args.forget === true });
|
|
608
625
|
return {
|
|
609
626
|
structured: { conversation_id: args.conversation_id, ...r },
|
|
@@ -614,19 +631,19 @@ export function createHandlers(hub, opts = {}) {
|
|
|
614
631
|
},
|
|
615
632
|
|
|
616
633
|
async dsh_takeover(args) {
|
|
617
|
-
const conv = hub.get(args.conversation_id);
|
|
634
|
+
const conv = await hub.get(args.conversation_id);
|
|
618
635
|
const r = await conv.takeover({ force: args.force === true });
|
|
619
636
|
return { structured: r, text: `${r.ok ? '✅' : '❌'} ${r.message}` };
|
|
620
637
|
},
|
|
621
638
|
|
|
622
639
|
async dsh_approval_decide(args) {
|
|
623
|
-
const conv = hub.get(args.conversation_id);
|
|
640
|
+
const conv = await hub.get(args.conversation_id);
|
|
624
641
|
const r = conv.decideApproval(args.approval_id, args.decision);
|
|
625
642
|
return { structured: r, text: `审批 ${r.approval_id} 已裁决为 ${r.decision}。` };
|
|
626
643
|
},
|
|
627
644
|
|
|
628
645
|
async dsh_status(args) {
|
|
629
|
-
const conv = hub.get(args.conversation_id);
|
|
646
|
+
const conv = await hub.get(args.conversation_id);
|
|
630
647
|
const snap = conv.snapshot();
|
|
631
648
|
const run = conv.getRun();
|
|
632
649
|
const bits = [`状态=${snap.state}`, `轮数=${snap.turns}`];
|
|
@@ -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,11 +15,11 @@
|
|
|
15
15
|
* 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { copyFileSync, createReadStream, 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';
|
|
22
|
-
import { zstdDecompressSync } from 'node:zlib';
|
|
22
|
+
import { createZstdDecompress, zstdDecompressSync } from 'node:zlib';
|
|
23
23
|
import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
|
|
24
24
|
|
|
25
25
|
const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
|
|
@@ -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 {
|
|
@@ -214,7 +220,7 @@ export function registerWorkspace(cwd, sessionId) {
|
|
|
214
220
|
}
|
|
215
221
|
}
|
|
216
222
|
|
|
217
|
-
/** 读取一个会话头的第一个 zstd
|
|
223
|
+
/** 读取一个会话头的第一个 zstd 帧(就是头行)。仅用于 CLI/测试清理,不用于 MCP 热路径。 */
|
|
218
224
|
function readHeader(file) {
|
|
219
225
|
try {
|
|
220
226
|
const text = zstdDecompressSync(readFileSync(file)).toString('utf8');
|
|
@@ -224,6 +230,58 @@ function readHeader(file) {
|
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 异步读取会话头:流式解压,拿到第一行就销毁流。
|
|
235
|
+
*
|
|
236
|
+
* 为什么需要它:MCP 服务在 `Hub.get()` 里找回磁盘会话时必须读头;
|
|
237
|
+
* `readHeader()` 会把整份日志同步解压,超大日志会阻塞事件循环、停掉
|
|
238
|
+
* 心跳(多实例下可能被误判 stale-mcp)。这里只解到第一个换行,
|
|
239
|
+
* 不把整份日志读进内存,也不阻塞事件循环。
|
|
240
|
+
*
|
|
241
|
+
* 超时/读失败返回 null;调用方按“找不到会话头”处理。
|
|
242
|
+
*/
|
|
243
|
+
export async function readHeaderAsync(file, { timeoutMs = 5000, maxHeaderBytes = 1024 * 1024 } = {}) {
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
let settled = false;
|
|
246
|
+
let buf = '';
|
|
247
|
+
let timer = null;
|
|
248
|
+
const rs = createReadStream(file);
|
|
249
|
+
const zs = createZstdDecompress();
|
|
250
|
+
const finish = (value) => {
|
|
251
|
+
if (settled) return;
|
|
252
|
+
settled = true;
|
|
253
|
+
if (timer) clearTimeout(timer);
|
|
254
|
+
try { rs.destroy(); } catch {}
|
|
255
|
+
try { zs.destroy(); } catch {}
|
|
256
|
+
resolve(value);
|
|
257
|
+
};
|
|
258
|
+
const parseLine = (line) => {
|
|
259
|
+
try {
|
|
260
|
+
const value = JSON.parse(String(line).trim());
|
|
261
|
+
finish(value && typeof value === 'object' ? value : null);
|
|
262
|
+
} catch {
|
|
263
|
+
finish(null);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
timer = setTimeout(() => finish(null), timeoutMs);
|
|
267
|
+
timer.unref?.();
|
|
268
|
+
rs.on('error', () => finish(null));
|
|
269
|
+
zs.on('error', () => finish(null));
|
|
270
|
+
zs.on('data', (chunk) => {
|
|
271
|
+
buf += chunk.toString('utf8');
|
|
272
|
+
const nl = buf.search(/\r?\n/);
|
|
273
|
+
if (nl >= 0) parseLine(buf.slice(0, nl));
|
|
274
|
+
else if (buf.length > maxHeaderBytes) finish(null);
|
|
275
|
+
});
|
|
276
|
+
zs.on('end', () => {
|
|
277
|
+
const line = buf.split(/\r?\n/).find(Boolean);
|
|
278
|
+
if (line) parseLine(line);
|
|
279
|
+
else finish(null);
|
|
280
|
+
});
|
|
281
|
+
rs.pipe(zs);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
227
285
|
/**
|
|
228
286
|
* 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。
|
|
229
287
|
*
|
|
@@ -266,7 +324,7 @@ export function scanSessions() {
|
|
|
266
324
|
*
|
|
267
325
|
* 会对 id 做形状校验(只允许 UUID / `session-` 前缀那类字符),避免 `..` 之类逃出会话目录。
|
|
268
326
|
*/
|
|
269
|
-
export function findSessionHeader(sessionId) {
|
|
327
|
+
export async function findSessionHeader(sessionId) {
|
|
270
328
|
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
|
|
271
329
|
if (!existsSync(SESSIONS_ROOT)) return null;
|
|
272
330
|
let buckets;
|
|
@@ -278,7 +336,7 @@ export function findSessionHeader(sessionId) {
|
|
|
278
336
|
for (const bucket of buckets) {
|
|
279
337
|
const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
|
|
280
338
|
if (!existsSync(f)) continue;
|
|
281
|
-
const header =
|
|
339
|
+
const header = await readHeaderAsync(f);
|
|
282
340
|
if (header?.id === sessionId) return header;
|
|
283
341
|
}
|
|
284
342
|
return null;
|
|
@@ -355,9 +413,10 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
|
|
|
355
413
|
* 删除测试会话(**这是唯一会动磁盘会话文件的函数**,所以规则写得很死)。
|
|
356
414
|
*
|
|
357
415
|
* 只会删这几类,其余一律不碰:
|
|
358
|
-
* 1)
|
|
359
|
-
* 2)
|
|
360
|
-
* 3)
|
|
416
|
+
* 1) testOnly:cwd 在 `tmpdir()/dsh-any-agent-*` 下(本仓库测试自己造的)
|
|
417
|
+
* 2) cwd 匹配 Desktop\dsh-any-agent-test-*
|
|
418
|
+
* 3) tempOnly:cwd 在系统临时目录下(显式人工命令才用,范围最大)
|
|
419
|
+
* 4) ids 里显式指定的
|
|
361
420
|
*
|
|
362
421
|
* @param {object} opts
|
|
363
422
|
* @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)
|