dsh-any-agent 0.3.6 → 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 CHANGED
@@ -7,7 +7,25 @@ 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.
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).
11
29
 
12
30
  ## [0.3.6] — 2026-11-02
13
31
 
@@ -652,7 +670,8 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
652
670
  profiles, sessions and client configs. The product name changed; the on-disk
653
671
  contract did not. See the README FAQ.
654
672
 
655
- [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...HEAD
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
656
675
  [0.3.6]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...v0.3.6
657
676
  [0.3.5]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.4...v0.3.5
658
677
  [0.3.4]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.3...v0.3.4
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.6 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
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.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
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
 
@@ -595,11 +595,11 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
595
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) |
596
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 |
597
597
 
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
+ **`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).
599
599
 
600
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.
601
601
 
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
+ 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__*`).
603
603
 
604
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.
605
605
 
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 0.3.6 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
18
+ 🧪 0.3.7 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
19
19
 
20
20
  一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
21
21
 
@@ -610,11 +610,11 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
610
610
  | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
611
611
  | `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
612
612
 
613
- **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.6 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
613
+ **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.7 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
614
614
 
615
615
  其中 **519 项属于客户端注册器套件**(`setup` 41、`removal-scope` 24、`config-safety` 406、`real-configs` 48;九种齐全时 555 项)—— 0.3 的活其实都在这里。
616
616
 
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
+ 需要模型的套件(`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**。
618
618
 
619
619
  > 三个总数是刻意分开的:`node test/run.mjs`(815/851)、CI 离线 job(786/822,不含 smoke)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
620
620
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-agent",
3
- "version": "0.3.6",
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/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
- if (findSessionHeader(id)) continue;
144
+ // 异步读会话头:启动路径不能被大日志的同步解压卡住。
145
+ if (await findSessionHeader(id)) continue;
145
146
  try {
146
147
  rmSync(join(FORGOTTEN_DIR, f), { force: true });
147
148
  removed++;
@@ -1209,7 +1210,7 @@ export class Hub {
1209
1210
  /** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
1210
1211
  #probeCache = null;
1211
1212
 
1212
- load() {
1213
+ async load() {
1213
1214
  // ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
1214
1215
  // 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
1215
1216
  assertWritableDir(dirname(STATE_FILE), '状态目录');
@@ -1234,7 +1235,7 @@ export class Hub {
1234
1235
  }
1235
1236
  }
1236
1237
 
1237
- const stale = pruneStaleTombstones();
1238
+ const stale = await pruneStaleTombstones();
1238
1239
  if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
1239
1240
  if (!existsSync(STATE_FILE)) return;
1240
1241
  try {
@@ -1373,7 +1374,7 @@ export class Hub {
1373
1374
  }
1374
1375
  }
1375
1376
 
1376
- get(id) {
1377
+ async get(id) {
1377
1378
  // ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
1378
1379
  // 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
1379
1380
  if (isForgotten(id)) {
@@ -1392,7 +1393,7 @@ export class Hub {
1392
1393
  if (!c) {
1393
1394
  // 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
1394
1395
  // 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
1395
- const header = findSessionHeader(id);
1396
+ const header = await findSessionHeader(id);
1396
1397
  if (header?.cwd) {
1397
1398
  // ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
1398
1399
  // 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
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
 
@@ -489,7 +489,7 @@ export function createHandlers(hub, opts = {}) {
489
489
  },
490
490
 
491
491
  async dsh_read(args) {
492
- const conv = hub.get(args.conversation_id);
492
+ const conv = await hub.get(args.conversation_id);
493
493
  const r = conv.readOut({
494
494
  cursor: args.cursor ?? 0,
495
495
  includeReasoning: args.include_reasoning === true,
@@ -515,7 +515,7 @@ export function createHandlers(hub, opts = {}) {
515
515
  },
516
516
 
517
517
  async dsh_get(args) {
518
- const conv = hub.get(args.conversation_id);
518
+ const conv = await hub.get(args.conversation_id);
519
519
  const snap = conv.snapshot();
520
520
  let run = null;
521
521
  const meta = [];
@@ -575,7 +575,7 @@ export function createHandlers(hub, opts = {}) {
575
575
  if (typeof args.message !== 'string' || args.message.trim() === '') {
576
576
  throw new Error('message 不能为空:至少包含一个非空白字符');
577
577
  }
578
- const conv = hub.get(args.conversation_id);
578
+ const conv = await hub.get(args.conversation_id);
579
579
  const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
580
580
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
581
581
  const r = await conv.interject(args.message, opts);
@@ -600,7 +600,7 @@ export function createHandlers(hub, opts = {}) {
600
600
  },
601
601
 
602
602
  async dsh_interrupt(args) {
603
- const conv = hub.get(args.conversation_id);
603
+ const conv = await hub.get(args.conversation_id);
604
604
  const r = await conv.interrupt();
605
605
  return { structured: r, text: r.interrupted ? '已发送中断请求。' : `未中断:${r.reason}` };
606
606
  },
@@ -620,7 +620,7 @@ export function createHandlers(hub, opts = {}) {
620
620
  `\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
621
621
  };
622
622
  }
623
- const conv = hub.get(args.conversation_id);
623
+ const conv = await hub.get(args.conversation_id);
624
624
  const r = await conv.close({ forget: args.forget === true });
625
625
  return {
626
626
  structured: { conversation_id: args.conversation_id, ...r },
@@ -631,19 +631,19 @@ export function createHandlers(hub, opts = {}) {
631
631
  },
632
632
 
633
633
  async dsh_takeover(args) {
634
- const conv = hub.get(args.conversation_id);
634
+ const conv = await hub.get(args.conversation_id);
635
635
  const r = await conv.takeover({ force: args.force === true });
636
636
  return { structured: r, text: `${r.ok ? '✅' : '❌'} ${r.message}` };
637
637
  },
638
638
 
639
639
  async dsh_approval_decide(args) {
640
- const conv = hub.get(args.conversation_id);
640
+ const conv = await hub.get(args.conversation_id);
641
641
  const r = conv.decideApproval(args.approval_id, args.decision);
642
642
  return { structured: r, text: `审批 ${r.approval_id} 已裁决为 ${r.decision}。` };
643
643
  },
644
644
 
645
645
  async dsh_status(args) {
646
- const conv = hub.get(args.conversation_id);
646
+ const conv = await hub.get(args.conversation_id);
647
647
  const snap = conv.snapshot();
648
648
  const run = conv.getRun();
649
649
  const bits = [`状态=${snap.state}`, `轮数=${snap.turns}`];
package/src/workspace.mjs CHANGED
@@ -15,11 +15,11 @@
15
15
  * 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
16
16
  */
17
17
 
18
- import { copyFileSync, 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');
@@ -220,7 +220,7 @@ export function registerWorkspace(cwd, sessionId) {
220
220
  }
221
221
  }
222
222
 
223
- /** 读取一个会话头的第一个 zstd 帧(就是头行)。 */
223
+ /** 读取一个会话头的第一个 zstd 帧(就是头行)。仅用于 CLI/测试清理,不用于 MCP 热路径。 */
224
224
  function readHeader(file) {
225
225
  try {
226
226
  const text = zstdDecompressSync(readFileSync(file)).toString('utf8');
@@ -230,6 +230,58 @@ function readHeader(file) {
230
230
  }
231
231
  }
232
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
+
233
285
  /**
234
286
  * 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。
235
287
  *
@@ -272,7 +324,7 @@ export function scanSessions() {
272
324
  *
273
325
  * 会对 id 做形状校验(只允许 UUID / `session-` 前缀那类字符),避免 `..` 之类逃出会话目录。
274
326
  */
275
- export function findSessionHeader(sessionId) {
327
+ export async function findSessionHeader(sessionId) {
276
328
  if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
277
329
  if (!existsSync(SESSIONS_ROOT)) return null;
278
330
  let buckets;
@@ -284,7 +336,7 @@ export function findSessionHeader(sessionId) {
284
336
  for (const bucket of buckets) {
285
337
  const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
286
338
  if (!existsSync(f)) continue;
287
- const header = readHeader(f);
339
+ const header = await readHeaderAsync(f);
288
340
  if (header?.id === sessionId) return header;
289
341
  }
290
342
  return null;