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 +21 -2
- package/DESIGN.md +1 -1
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/bin/dsh-any-agent.mjs +1 -1
- package/package.json +1 -1
- package/src/hub.mjs +7 -6
- package/src/tools.mjs +9 -9
- package/src/workspace.mjs +57 -5
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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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/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/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++;
|
|
@@ -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 =
|
|
339
|
+
const header = await readHeaderAsync(f);
|
|
288
340
|
if (header?.id === sessionId) return header;
|
|
289
341
|
}
|
|
290
342
|
return null;
|