@ray1270/dsh-task-stack
给 DeepSeek Harness Agent 用的可持久化任务栈:Agent 显式调用三个工具来 push / pop / 查看自己的多步任务焦点,状态以纯文本 JSON 落在会话工作区里。
- 跨上下文压缩存活 —— 状态在磁盘上,压缩掉对话也不丢
- 跨会话恢复可找回 —— 同一
sessionId+cwd重新挂载即读回同一份文件 - 零自动注入 —— 不写系统提示词、不注入上下文,Agent 不主动调用就一个 token 都不花
focus_task("给 CLI 加 JSON 导入命令")
└─ 遇到需要单独完成的多步目标 → focus_task("顺便把导入的错误提示改掉")
└─ focus_complete("错误提示已改,附带单测")
← 栈顶回到 "给 CLI 加 JSON 导入命令",重新变 active
focus_complete("导入命令已加,冒烟测试通过")
三个工具
模型看到的描述都按「什么时候该用 / 什么时候不该用 / 返回什么」写。
focus_task(description)
把一行任务描述压到栈顶;原先 active 的任务自动变 paused。返回新的栈深度与栈顶任务。
- 该用:用户要的东西需要好几步才能交付,尤其是一个请求里有多个可分离目标时。推你最外层要对用户负责的那个目标,不是你自己计划的每一步。
- 不该用:单轮问答;栈顶任务内部的例行步骤;复述用户消息。
- 栈到达
maxStackDepth时拒绝并说明原因,不抛异常。
focus_complete(conclusion)
弹出栈顶任务,记下一行结论,下面那个任务重新变 active。返回被关闭的任务、重新激活的任务、剩余深度。
- 该用:栈顶任务的多步工作完成、产物已经落地时,且在写最终答复之前调用——这样之后即使发生上下文压缩,也能看到已关闭的结论。
- 不该用:放弃、暂停、改名。目标变了应该改述栈顶任务,而不是把它挂着。
- 空栈时拒绝并提示:说明这次工作根本没 push 过任务。
read_focus()
以 markdown 返回完整栈(active + paused)与最近若干条已完成记录。
- 该用:上下文压缩之后、恢复会话时、用户问「你现在在做什么」时、决定该 push 还是 complete 之前。
- 不该用:每一步之后都调。你自己近几轮还看得到焦点时就别花 token。
- 因为本插件不注入提示词,这是唯一能看到任务栈的办法。
/focus 斜杠命令(人用,不花模型 token)
除了三个工具,插件还注册一个 /focus 命令,直接作用于当前会话的任务栈——排查问题或手工收尾时不用消耗一轮对话。
| 输入 | 作用 |
|---|---|
/focus |
打印任务栈(active/paused 标注 + 深度)与最近历史 |
/focus done <结论> |
关闭栈顶任务并把结论写进 history,等价于一次 focus_complete |
/focus clear |
清空整个栈,每个被清的任务都会记入 history(结论为 cleared via /focus),不会静默丢失 |
/focus clear 是幂等的:空栈时回一句"已经是空的",不算错误。/focus done 空栈时会明确告诉你栈是空的——那是"没 push 过任务"的信号,而不是需要重试。
安装
方式 A:作为 profile bundle 安装(常规使用)
# 从 npm 安装(推荐)
dsh plugin --profile desktop add @ray1270/dsh-task-stack
# 或者克隆仓库后按本地目录安装(适合要改源码时)
git clone https://github.com/Ray1270/dsh-task-stack.git
cd dsh-task-stack
dsh plugin --profile desktop add $pwd
dsh plugin add 会把包装进 profile 的 node_modules 并把它登记进该 profile package.json 的 dsh.profile.bundles。profile 应用本包自带的 cordis.patch.yml,其中的加载行 name: @ray1270/dsh-task-stack 就从 profile 的 node_modules 解析。重启 DSH Desktop 后新会话即可直接让模型调用这三个工具、使用 /focus。
若不想走包管理器安装,也可以手工两步(等价)。在克隆出来的仓库根目录里执行:
# 1) 建 junction,让 profile 能按包名解析到这个目录
$plugin = (Get-Location).Path
$link = "$env:DSH_HOME\profiles\desktop\node_modules\@ray1270\dsh-task-stack"
New-Item -ItemType Directory -Force -Path (Split-Path $link) | Out-Null
cmd /c mklink /J "$link" "$plugin"
# 2) 编辑 $env:DSH_HOME\profiles\desktop\package.json,
# 在 dsh.profile.bundles 数组里加一项 "@ray1270/dsh-task-stack"
不要写
"dependencies": { "@ray1270/dsh-task-stack": "link:...." }:profile 在 C: 而插件在 D:,path.resolve到盘根就停,多少个..都跨不了盘,一条解析不了的link:会让以后的pnpm install直接失败。Bundle 靠 profilenode_modules里的 junction 解析,不需要依赖项。
方式 B:免安装的临时 overlay(开发调试)
# 注意 --patch 属于启动器:必须写在 profile 名之前
dsh --profile web --patch ".\dev.patch.yml" --no-open
dev.patch.yml 用 name: ./lib/index.js,由加载器改写成 patch 文件旁边的 file:// URL,因此不需要安装、不需要 node_modules 解析。cordis.patch.yml 用的裸包名只在包已安装(或可被 node_modules 解析)时才有效。
两个坑:
dsh web --patch x.yml是错的(web子命令不认识--patch),要写dsh --profile web --patch x.yml。- patch 行里的相对目录(
name: ./)也不行:Node 对目录 URL 抛ERR_UNSUPPORTED_DIR_IMPORT,且file:说明符不走exports。
HTTP 快照端点(Web 侧栏数据面)
在 Web 组合(dsh --profile web)里,插件会额外注册一个只读端点,供 GUI 面板读取任务栈:
GET /api/dsh-task-stack/snapshot?sessionId=<id>&cwd=<绝对路径>
返回:
{
"sessionId": "session-…",
"cwd": "D:\\…",
"depth": 2,
"top": { "id": "task-…", "description": "…", "status": "active", "createdAt": "…" },
"stack": [ /* 最深在前,栈顶在最后 */ ],
"history": [ /* 最旧在前 */ ],
"warnings": []
}
行为约定:
| 情况 | 响应 |
|---|---|
| 正常 | 200 + JSON,cache-control: no-store |
| 未认证(无浏览器 cookie)/ Host 不在信任范围 | 401 / 403 |
组合里没有 connection 服务(无法鉴权) |
503,失败关闭而不是无鉴权放行 |
缺 sessionId、cwd 非绝对路径 |
400 |
非 GET/HEAD |
405 + Allow |
| 会话没有状态文件 | 200,depth: 0 的空栈(不是 404) |
三点实现说明:
- 它不继承栅栏。 raw WebServer 路由不吃
connection的鉴权,所以端点自己调connection.requestRejection(req)——与第一方 API 同一道 Host/Origin + 浏览器 cookie 校验。这条路径实测过:带 cookie 200、匿名 401。 - 只读。 端点只暴露
getSnapshot;关闭任务仍走focus_complete或/focus。写入面需要单独的安全设计,不在本版本内。 - 可选激活。
webServer/connection都不写进inject(终端与 headless 组合没有它们,硬依赖会让插件整个 pending),而是用ctx.inject(['webServer'], …)等服务出现后再注册;connection在每次请求时解析——两者都inject: [webRuntime],激活顺序不定,捕获一次会把undefined存下来(这个 bug 我踩过,表现为端点一直 503)。
手动验证(宿主起来后):
# 1) 用启动时打印的 URL 换浏览器 cookie(303 + Set-Cookie)
curl.exe -i "http://127.0.0.1:3080/?token=<token>"
# 2) 带 cookie 请求快照
curl.exe "http://127.0.0.1:3080/api/dsh-task-stack/snapshot?sessionId=<id>&cwd=D%3A%5Cpath" -H "Cookie: dsh-auth-…=…"
# 3) 不带 cookie 必须被拒
curl.exe -i "http://127.0.0.1:3080/api/dsh-task-stack/snapshot?sessionId=<id>"
cwd 是可选的:宿主会先用自己的会话登记表按 sessionId 解析该会话的工作目录,只有解析不到时才退回查询参数。浏览器的说法不是权威,因此宿主优先——否则任何通过鉴权的页面都能把读取指向别的工作区。
侧栏面板(Web GUI)
在 Web GUI 里,插件会注册一个可折叠入口 「任务栈」,点击后打开只读面板。面板顶部先用一句话说明自己在画什么,三段的标题也都各带一条解释:
- 标题右侧的 未完成 n 是栈深
- 正在做 —— 栈顶,当前任务,附
HH:MM 开始 · n 分钟前 - 压着的 · n 件 —— 被压在下面的任务,从离栈顶最近的开始往下排(上面这件做完,依次回来)
- 最近做完 · n 件 —— 最近 3 条完成记录,带删除线的是任务,下一行是当时写下的结论
- 每 4 秒自动刷新,也可点 刷新
三条设计约束:
- 只读。 面板不提供关闭/清理按钮:写入面需要单独的安全设计,而宿主路由只暴露
getSnapshot。改栈仍走模型工具或/focus。 - 座位缺失不会崩。 两个座位(
sidebar.panellist与main)都由外壳插件声明,本包在运行时不依赖它们,因此注册走slots.inject:外壳没声明就只是不出现,不会让整个 GUI 起不来。 - 没有会话也能渲染。 未选中会话时显示提示而不是抛错。
客户端半侧是手写的 client/client.js,构建时原样复制到 lib/client.js(不经过 JSX 转译或打包器),因此读到的就是浏览器执行的。宿主按 dsh.client 声明扫描 Loader 行,并从 /plugins/<包名>/client.js 提供它——新增或改动面板后需要重启 DSH 才会加载。
配置
在 profile patch 层的该行 config: 下配置,全部可选;Cordis 会在插件启动之前校验,越界直接拒绝加载该插件并在启动日志里指出字段(不会静默夹紧成另一个策略)。
| 字段 | 默认 | 约束 | 含义 |
|---|---|---|---|
stateDir |
.dsh/task-stack |
非空、相对路径 | 状态文件目录,相对会话工作区 |
maxStackDepth |
20 |
整数 ≥ 1 | 栈上最多同时开着的任务数;超过则 focus_task 拒绝 |
historyLimit |
100 |
整数 ≥ 1 | history 最多保留多少条完成记录,从最旧的开始裁剪 |
statePruneDays |
0 |
整数 ≥ 0(0 = 不清理) | 插件加载时删除超过这么多天没写过的会话状态文件 |
- insert:
- id: @ray1270/dsh-task-stack
name: @ray1270/dsh-task-stack
config:
stateDir: .dsh/task-stack
maxStackDepth: 20
historyLimit: 100
statePruneDays: 30 # 可选:清掉一个月没动过的会话
statePruneDays 是尽力而为的维护动作,以 process.cwd() 为工作区、在插件加载后异步执行,只删 stateDir 里直接存放的 *.json:
- 文件的 mtime 就是它最后一次写入时间;
- 目录不存在、没有任何文件要删 → 静默跳过,不是错误;
- 崩在写入中途留下的
*.tmp不删(留给人工注意); - 每个删除都走同一把 per-file 锁,不会和正在进行的 read-modify-write 交错;
- 清理慢或失败只写一行日志,绝不影响插件激活。
状态文件
路径:<session-cwd>/<stateDir>/<sessionId>.json,一个会话一个文件,同工作区的不同会话互不干扰。
{
"version": 1,
"sessionId": "session-087e04a2-f613-413f-ab38-e3a3ff2f1e75",
"stack": [
{
"id": "task-35e0216c-75cc-472c-8e89-1fbbc05b1ac9",
"description": "给 CLI 加 JSON 导入命令",
"createdAt": "2026-10-01T07:24:46.200Z",
"status": "active"
}
],
"history": [
{
"id": "task-c28dd757-3568-4ccb-a3b7-0ea05b81bccb",
"description": "重写导入的错误提示",
"conclusion": "错误提示已改,附带单测",
"createdAt": "2026-10-01T07:24:46.208Z",
"completedAt": "2026-10-01T07:24:46.214Z"
}
]
}
stack里恰好只有一个status: "active"(栈顶);读到的文件若有多个 active,会被就地修复- 写入是原子的:先写
<file>.<pid>.<n>.tmp→fsync→rename覆盖,读者只会看到完整的旧文档或新文档,崩溃也不会留半截 JSON - 同一文件的读写用 per-file 异步锁(promise 链)串行化,不同文件互不阻塞
- 文件缺失按空栈处理(不算警告);读失败 / JSON 坏 / 版本不符 / 会话不符 / 帧结构坏 → 退化成空栈或丢弃坏帧,附一条
warnings且从不抛异常
开发与验证
# 在克隆出来的仓库根目录里执行
pnpm typecheck # tsc strict(含 noUncheckedIndexedAccess / exactOptionalPropertyTypes)
pnpm build # tsc + 产物自检
pnpm test # 85 项:store 22 + tools 18 + config 12 + commands 13 + http 12 + client 5 + lifecycle 4 + demo
pnpm demo # 真实 ToolRuntime 里把三个工具与 /focus 跑一遍,打印 markdown 与落盘文件
pnpm probe # 无宿主装载:真 Cordis Context + 注册面断言 + 全生命周期实跑
| 脚本 | 覆盖 |
|---|---|
| scripts/test-store.mjs | 路径解析、文档结构、push/pop 语义、栈满/空栈、history 裁剪、200 并发压测无丢失无 .tmp 残留、坏 JSON/坏版本/坏帧/串会话降级、prune 阈值与边界、clearStack 幂等 |
| scripts/test-tools.mjs | 三个 defineTool 契约、参数校验(空串/错类型/缺参)、无 agent 报错、渲染 markdown、拒绝路径的渲染、history 裁剪对模型可见 |
| scripts/test-config.mjs | Config schema 默认值/越界拒绝/JSON schema、自定义 stateDir/maxStackDepth/historyLimit 端到端生效、手写坏文件不崩、statePruneDays 真的在加载时清理(子进程换 cwd 测) |
| scripts/test-commands.mjs | /focus 的三种输入与全部拒绝路径、命令与工具共用同一份状态、坏文件降级为 warning |
| scripts/test-lifecycle.mjs | 用真实 ToolRuntime + 真实子 fiber:加载→卸载→重载无残留、无 "already registered"、store 可重建 |
| scripts/demo.mjs | 可读演示:真 registry 上跑完整生命周期与 /focus,打印模型侧 markdown 与状态文件 |
| scripts/probe-load.mjs | 真实 Cordis Context 里 apply,断言三个工具 + /focus 都注册,并实跑一次完整生命周期 |
宿主侧的 loader 集成由工作区根目录的 ../loader-harness.mjs 验证(走真 @deepseek-ai/cordis-plugin-loader,四种模式:裸名/相对/坏锚点/非法配置)。
发布
# 0) 本地全绿(prepublishOnly 也会再跑一遍 build + test)
pnpm build && pnpm test
# 1) 登录(首次)
npm login
# 2) 预览包里到底装了什么,并让发布闸门跑一遍(不发布)
npm pack --dry-run
# 3) 发布
npm publish --access public # scope 包首次发布必须显式 --access public
要点:
- 发布闸门挂在
prepack上(pack与publish都会触发),由 scripts/release-check.mjs 用纯 Node 顺序跑 14 步:typecheck → build → build-client → check-build → release-notes → probe → 七套测试 → demo。它不调用任何包管理器,所以不会被 PATH 上那个会写 crashpad 日志并异常退出的 pnpm 干扰;任一步失败会以自己的输出退出,npm publish会如实报出真正的原因。 - 想单独跑闸门:
node scripts/release-check.mjs; files只包含lib、两份 patch、README.md、CHANGELOG.md——src与scripts不进包(lib由闸门里的 build 步骤保证是最新的);- 包名与 cordis.patch.yml 里的加载行
name必须一致,否则 profile 装了也解析不到。工作区根目录的rename-package.mjs会一次性同步所有出现位置并校验(含 YAML 引号,以及"目录名必须保持裸名"的断言); - 版本按 semver:修 bug 走 patch,新增工具/命令走 minor,改配置语义走 major;
- 首次发布后别人这样装:
dsh plugin --profile <名称> add @ray1270/dsh-task-stack。
兼容性
- DSH:
>=0.2.0-rc.2 <0.3.0(已在0.2.0-rc.2与0.2.1-rc.2上实测) - 依赖:
@deepseek-ai/cordis ^4.0.2、@deepseek-ai/dsh-tools、@deepseek-ai/dsh-commands、@deepseek-ai/schemastery ^3.18.2(均为 peerDependencies,由宿主在运行时提供) - 不注册任何 prompt section、不做自动注入
为什么声明顶层
engines.dsh:app-boot 的兼容检查在插件 manifest 有engines.dsh时只看它,没有才回落到@deepseek-ai/dsh*的 peer 范围。早先版本只写了精确 peer(0.2.0-rc.2),DSH 升到0.2.1-rc.2后整个插件被静默跳过(skipping profile bundle),工具和面板一起消失。顶层engines.dsh让"支持哪个 DSH"成为一处显式声明,且includePrerelease使 rc 线能被正确覆盖。
项目结构
dsh-task-stack/
├── package.json # dsh.bundle 声明;peerDependencies 声明宿主依赖
├── cordis.patch.yml # bundle 加载行(包名,随安装生效)
├── dev.patch.yml # 免安装 overlay(./lib/index.js,随 --patch 生效)
├── CHANGELOG.md # 每个版本一节:Release 正文的来源,也是发布闸门的一项
├── tsconfig.json
├── LICENSE # MIT
├── .github/workflows/ci.yml # 每次 push 跑同一个发布闸门
├── src/
│ ├── index.ts # 插件入口:name / inject / Config / apply
│ ├── tools.ts # 三个 defineTool:描述、schema、渲染、执行
│ ├── commands.ts # /focus 斜杠命令
│ ├── store.ts # 路径解析 + 原子写入 + per-file 锁 + 容错读取
│ └── types.ts # 共享类型与默认值
├── client/
│ └── client.js # 浏览器半侧(手写 __ModuleLoader__ bundle,原样复制到 lib/)
└── scripts/
├── release-check.mjs # 发布闸门:14 步顺序执行(CI 与 prepack 共用)
├── release-notes.mjs # 从 CHANGELOG.md 取某版本的正文(Release 用)
├── build-client.mjs # 复制客户端 bundle 并校验其形态
├── test-store.mjs # 状态层 22 项
├── test-tools.mjs # 工具层 18 项
├── test-config.mjs # 配置与边界 12 项
├── test-commands.mjs # /focus 13 项
├── test-http.mjs # 真实 dsh-host-webserver 12 项
├── test-client.mjs # 客户端 bundle 契约 5 项
├── test-lifecycle.mjs # 真实 ToolRuntime 卸载/重载 4 项
├── probe-load.mjs # 无宿主装载 + 全生命周期实跑
├── demo.mjs # 可读演示
├── check-build.mjs # 构建产物自检
└── publish-release.mjs # tag → 发布(可本地干跑)
发布流程
推一个 v* tag 就会自动发布到 npm 并建 GitHub Release(.github/workflows/release.yml):
# 1) 升版本(改 package.json 的 version),并给 CHANGELOG.md 写一节:
# ## 0.3.0 — YYYY-MM-DD
# 闸门里的 release-notes 一步会检查这一节在不在
pnpm release-check
# 2) 提交并推 tag —— 这一步触发自动化,之后无需手工操作
git commit -am "chore: release 0.3.0"
git tag -a v0.3.0 -m "v0.3.0"
git push && git push origin v0.3.0
自动化做五件事:装依赖 → node scripts/publish-release.mjs --tag $GITHUB_REF_NAME(内部跑 npm publish,其 prepack 就是那套发布闸门)→ 产出 tarball → 用 scripts/release-notes.mjs 取出 CHANGELOG.md 里这一版的正文 → 建 Release 并把正文和 tarball 都挂上。
Release 正文取自 CHANGELOG.md,不是自动生成的提交列表。 自动生成那版(generate_release_notes)在 v0.3.0 上只剩一行 Full Changelog,看 release 页面的人根本不知道这一版加了侧栏面板、修了静默跳过。正文现在由 scripts/release-notes.mjs 从 changelog 的对应小节取出;某个版本没有小节时会退化成标注清楚的提交摘要,页面不会空着。发布闸门里的 release-notes 一步则要求"当前版本有小节",把这个问题挡在发版之前。
已经发出去的版本号不能重发,所以改 changelog 之后要靠 .github/workflows/sync-release-notes.yml 更新既有 Release:main 上的 CHANGELOG.md 一改,它就把 package.json 当前版本对应的 Release 正文同步过去;对应 Release 不存在时安静退出,不会创建或删除任何东西。
认证走 trusted publishing(OIDC),不需要任何密钥:工作流声明了 id-token: write,npm publish 会用 runner 签发的短期 OIDC token 换一个只在本次 job 内有效的发布凭据,并顺带附上 provenance 证明。没有长期 token,也就没有会过期的东西要换。
需要一次性在 npm 侧登记这层信任:npmjs.com → 你的包 → Settings → Trusted publishing → 选 GitHub Actions,填 owner Ray1270、仓库 dsh-task-stack、工作流文件名 release.yml。工作流一旦改名或搬家,这里必须同步改,否则发布会以 404 失败。
旧的
NPM_TOKEN仓库 secret 已经用不上了,可以删掉。scripts/publish-release.mjs仍然兼容它(以及本地~/.npmrc里的 token),所以老仓库或手工发布会照常工作。
坑(2026-09-03 起):新建的 trusted publisher 默认只允许
npm stage publish(暂存发布,人审后再放行),直接npm publish会被 registry 拒成403 OIDC permission denied for this action—— 看着像身份没对上,其实是这个动作没被授权。新建时必须在 Allowed actions 里额外勾上npm publish(npm dist-tag视需要)。已建好的连接字段不可改,只能删掉重建。
三种 ref 的三种行为
脚本按 ref 判断意图,因为 workflow_dispatch 拿到的 GITHUB_REF_NAME 是分支名而不是 tag:
| 触发 | ref | 行为 |
|---|---|---|
推 v0.3.0 tag |
v0.3.0 |
校验 tag == package.json 版本 → 发布 |
| Actions 页面手动 Run workflow | main |
校验模式:报告凭据状态、不发布、成功结束 |
| 普通分支 push | 分支名 | 同上(CI 工作流才是分支上的主验证) |
推真实 tag 但与 package.json 版本不符时仍然直接失败——用错版本号发布会污染 npm,那是事故而不是可以抹平的小事。
两个刻意的设计,都是被事故逼出来的
- tag 必须与
package.json的版本一致,否则失败。 - 已发布过的版本视为成功,而不是报错。因为"registry 已记录版本、客户端却收到失败响应"会让这个版本号永久不可用(0.2.0 就是这么丢的:重试同一个版本号只会得到 409)——所以重试必须能收敛。
可以本地干跑看 CI 会做什么:
node scripts/publish-release.mjs --dry-run # 只决策,不发布
node scripts/publish-release.mjs --tag main # 校验模式(等同手动触发)
许可
MIT
No comments yet. Be the first to write one.