dsh-laa
Verifieddsh-laa · v0.5.0 · MIT · Web UI
DSH 插件:为每个会话提供 LAA 模式——DeepSeek 峰时自动停止运行,谷时自动继续运行。
Install
dsh plugin add dsh-laa Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
description: "面向用户与维护者的会话级 LAA 模式说明:在 DeepSeek 峰时停止运行、谷时继续运行的 /laa 开关、峰谷判定与停机/续跑的确切语义。" kind: "package-reference"
dsh-laa
dsh-laa 给每一个会话加一个 LAA 模式。会话打开它之后,就只在 DeepSeek 的
谷时(空闲时段) 运行:
- 峰时:正在跑的轮次被立刻停下;任何想要进入的步骤会被拦下,于是一个模型请求都不会发出。被拦下的输入被原样保存。
- 谷时:被停下的轮次收到一条续跑提示词继续做;峰时被拦下的输入按原顺序重新投递。
峰谷时段取自 DeepSeek 官方定价口径,窗口可配置,官方改口径时不需要改代码。 每个版本的变更记录见 CHANGELOG.md。
目录
安装
# 从 npm
dsh plugin --profile web add dsh-laa
# 或者本地开发中的 checkout(link: 会建软链,改完重启即可生效)
dsh plugin --profile web add link:D:\Project\dsh-laa
DSH Desktop 用户直接改 %USERPROFILE%\.dsh\profiles\web(macOS / Linux 为
~/.dsh/profiles/web)即可:本包自带 cordis.patch.yml,作为 profile bundle 加入
dsh.profile.bundles 后就会插入 laa 加载器行。
装好后重启 dsh web / DSH Desktop,然后任意会话里输入 /laa。
使用
LAA 模式是每个会话各自的开关,默认关闭。
| 命令 | 作用 |
|---|---|
/laa |
查看状态:开关、当前是峰时还是谷时、下一次切换、峰时窗口、待恢复的输入数量 |
/laa on |
打开该会话的 LAA 模式 |
/laa off |
关闭;当刻就把压着的输入发出去(被中断过的轮次会先收到一条「LAA 已关闭」的续跑提示词),命令会报告发了多少、还有几个会话在等实时 agent |
/laa status |
等同于 /laa |
命令直接从 UI 作用到会话,不产生任何模型消息,因此开关本身不花 token。
Web UI 里还有第二种入口:滑动开关(轨道 + LAA 标签)。单击即切换当前会话的
模式;悬停显示当前峰谷时段、下一次切换、峰时窗口与待恢复的输入条数;开启且正处峰时
的时候轨道会变成琥珀色,一眼就能看出"它现在正被按住"。命令、开关、状态文件读的是
同一份快照,因此三者永远一致。
开关的位置跟着页面走,页面上任何时刻都恰好有一个:
| 页面 | 位置 |
|---|---|
| 已有对话的会话 | 会话页顶栏右侧(DSH 的 conversation.session.header.utilities) |
| 新对话页(还没有第一条消息) | 输入框工具行的左侧(conversation.input.left),就在权限选择与 Plan 旁边 |
DSH 在会话还没有第一条消息时会把整条顶栏藏起来(连标题带工具区),顶栏里的开关因此 根本不会挂载——这就是为什么新对话页需要第二份。它只在顶栏确实不显示时渲染,所以新 对话页、以及正在打开的会话里都点得到,可以在发出第一条消息之前就把 LAA 打开。
典型用法:晚上下班前对一个长任务说「继续做」,然后点一下那个开关(或 /laa on)——
峰时它会自己停住,谷时(工作日 12:00-14:00、18:00 之后、以及整个周末)它会自己接着做。
子会话(subagent 会话)
LAA 模式属于会话树,而不是单个会话:DSH 的 subagent 子会话(会话 header 里带
origin: 'subagent' 与 parentSession,在侧栏里嵌套显示的那些)没有自己的模式,
它跟随自己所属的顶层会话。于是父会话开着的时候子会话也开着、父会话关掉的时候子会话
立刻恢复放行,整棵树永远只有一个开关:
| 动作 | 结果 |
|---|---|
| 父会话开 LAA | 子会话也受管:峰时子会话同样被拦下,一个模型请求都不会发出 |
| 父会话关 LAA | 子会话立刻恢复放行 |
在子会话页点开关 / /laa on|off |
改的是父会话(命令会明确告诉你改的是哪个会话) |
| 子会话的悬停提示 | 多一行「跟随父会话」,写着父会话的标题与 id |
但遗留工作记在会话自己名下:峰时被拦下的输入、被中断的轮次都属于产生它们的那个 会话,谷时回到那个会话自己的 agent 上继续跑——子会话的输入不会被投递到父会话去。
峰谷切换时的动画与通知
峰谷真的翻过去的那一刻(宿主在边界后重新评估相位,前端按快照里的 nextChangeAt
对齐去问),页面上的开关:
- 跳三下(
prefers-reduced-motion下自动不播动画); - 开着 LAA 的时候,轨道颜色用 1.26 秒从绿平滑推到琥珀(或反过来)——平时 0.2 秒的 换色只够「变」,不够「看得出它变了」;
- 弹一条浏览器通知(
LAA · 进入峰时/LAA · 进入谷时),标签页在后台也收得到; 同一个会话的播报互相顶替,不会堆一屏。
通知要授权:第一次点开关时顺手申请一次(浏览器要求用户手势);拒绝授权、或者浏览器
没有这个 API,都只是没有通知,开关照常工作。播报范围由「配置」里的 notify 决定。
前端只按宿主给的边界对表,不在前端自己算第二套时间;标签页或系统休眠期间错过的切换, 由 20 秒的兜底轮询追平。
这层关系落盘在 $DSH_HOME/laa/state.json(lineage 字段,来自会话 header 的
parentSession),所以进程重启、或者子会话当前没有实时 agent 时,开关与 /laa
依旧显示父会话的那一份状态。
插件见过的子会话会随手记下;装插件之前就存在、之后一直冷着的子会话则由控制面在
第一次收到请求时,向宿主的会话清单(ctx.sessionQuery.listSessions(),也就是侧栏
在用的那一份)补一次谱系——每个进程只补一次,读失败只是退回原来的判断。
浏览器开关与控制面
两个位置的开关都通过宿主插件的一条 HTTP 控制面读写状态,路径全部挂在 /dsh-laa 前缀下,
只用了 ctx.webServer.register() 这一条既有 seam(不需要 typert 远端声明,也不需要
额外的依赖):
| 方法 | 路径 | 作用 |
|---|---|---|
GET |
/dsh-laa/health |
插件是否在跑、总开关、时区 |
GET |
/dsh-laa/state?sessionId=<id> |
一个会话的完整状态(开关、峰谷、下一次切换、待恢复条数);子会话的 enabled 就是父会话的那一份,另带 inheritedFrom(它跟随的顶层会话) |
POST |
/dsh-laa/mode |
{ "sessionId": "...", "enabled": true },改写该会话的模式 |
于是它也能被脚本直接驱动:
curl -s "$DSH_URL/dsh-laa/state?sessionId=$SID"
curl -s -X POST "$DSH_URL/dsh-laa/mode" -H 'content-type: application/json' \
-d "{\"sessionId\":\"$SID\",\"enabled\":true}"
写入被拒绝时返回 400(缺参数/类型不对)、409(插件总开关 enabled: false)或 404
(未知路径),并且不会先把状态改掉;前端拿到的永远只有 { ok, value } 或
{ ok: false, error }。控制面是可选挂载:没有 webServer 的组合(headless / SDK /
ACP)只是没有浏览器入口,调度行为不受影响。
峰时到底发生什么
峰时开始的那一刻:
- 所有属于该会话的实时 agent(根会话 agent 及其在进程内的子代理)如果正在跑,
会被
cancel(cause, { keepInbox: true })中止当前轮次,已排队但还没开始的输入会被保留。 - 之后任何试图进入的步骤都会在
agent/pre-step瀑布里被拒绝。拒绝发生在 模型请求之前,所以峰时不会产生任何 API 调用。 - 被拒绝步骤的输入不会丢:轮次的第一个步骤里携带的、真正的用户/插件输入会被 原样记进状态文件;轮次中途的步骤只把会话标记为「需要续跑」——工具结果不是用户 输入,不会被当成用户消息重放。
峰时期间你在 UI 里发消息是安全的:它会等谷时再被投递,而不是被丢掉;不想等就直接把
LAA 关掉(/laa off 或点开关),攒下的输入会在当刻发出去。
关掉 LAA 时的投递范围是整棵会话树(父会话 + 它的所有子会话),并且与谷时用不同的
续跑提示词([LAA MODE — TURNED OFF],而不是 OFF-PEAK WINDOW OPENED)——模型因此
知道自己不是「等到了谷时」,而是「人把 LAA 关了」。当时没有实时 agent 的会话交不出去,
输入留在状态里,等它下次运行(agent 被拉起)时立刻投递,一条都不会丢。
谷时到底发生什么
谷时开始的那一刻,每一个还有遗留工作的会话:
- 如果峰时中断过轮次,先投递一条续跑提示词
[LAA MODE — OFF-PEAK WINDOW OPENED], 让模型从既有历史、工作区与工具结果继续,而不是从头重来。 - 然后把峰时暂存的输入按原顺序逐条重新投递(保留原本的 message source)。
- 如果峰时拦下的是 goal round,还会把被拦下时自动暂停的 goal 重新武装, 让自动续行回到你原本的意图。
只有确实投递成功的部分才会从状态里清掉。会话在谷时到来时如果还没有实时 agent (没打开过),遗留输入会一直留着,等它下一次变成实时 agent 时立刻交付。
配置
编辑 profile 的 cordis.patch.yml(或直接改本包自带的 cordis.patch.yml):
- id: laa # 注意:patch 是【整体替换】config,不是逐字段合并
config:
enabled: true
timeZone: 'Asia/Shanghai'
peakWindows:
- days: [mon, tue, wed, thu, fri]
start: '09:00'
end: '12:00'
- days: [mon, tue, wed, thu, fri]
start: '14:00'
end: '18:00'
defaultMode: false
cancelRunningOnPeak: true
resumeOnValley: true
tickMs: 30000
maxDeferred: 20
| 字段 | 默认 | 含义 |
|---|---|---|
enabled |
true |
插件总开关。false 时插件照常加载,但任何会话都不会被停。 |
timeZone |
'Asia/Shanghai' |
峰时窗口所依据的 IANA 时区(DeepSeek 官方口径为北京时间)。 |
peakWindows |
见上 | 峰时窗口数组,其余时段一律是谷时。days 接受 mon…sun 或 1(周一)…7(周日);start/end 为 HH:MM;end <= start 表示跨零点窗口。 |
defaultMode |
false |
从未被 /laa 切换过的会话是否默认开启。显式关掉过的会话不会被它重新打开。 |
notify |
'enabled' |
峰谷切换时的浏览器通知策略:enabled(只有开着 LAA 的会话才播报)、always(任何会话都播报)、off(不播报)。只影响通知,开关上的切换动画不受影响。 |
cancelRunningOnPeak |
true |
峰时开始时是否中止正在跑的轮次。 |
resumeOnValley |
true |
谷时开始时是否自动续跑。 |
tickMs |
30000 |
相位重新评估的兜底间隔;窗口边界本身总是会被精确唤醒。 |
maxDeferred |
20 |
每个会话最多暂存多少条输入,超出时丢弃最旧的并记一条警告。 |
statePath |
$DSH_HOME/laa/state.json |
状态文件位置。 |
官方峰谷口径
据 DeepSeek 官方定价页 注 (3): 空闲时段价格为高峰时段价格的一半,高峰时段为北京时间周一至周五 09:00-12:00、 14:00-18:00,其余为空闲时段。折算成 UTC 即周一至周五 01:00-04:00 与 06:00-10:00。
于是:工作日 12:00-14:00、每天 18:00 之后、以及整个周末都是谷时,其中从 周五 18:00 到周一 09:00 是一段连续 63 小时的谷时。
窗口写在配置里而不是写死在代码里——DeepSeek 调整过峰谷口径(2025-02-26 的
16:30-00:30 UTC 方案已被 2026-08-17 生效的新口径取代),下一次调整只需要改配置。
本地隔离测试环境
scripts/dev-home.mjs 会在工作区里建一个只属于本仓库的 DSH home(.dsh-test/)。
它和你日常在用的 ~/.dsh(含其中的 web profile)完全分开:会话、设置、插件,
以及 dsh-laa 自己的 laa/state.json 都落在那个目录下,所以测这个插件不会碰到你的
真实环境。
node scripts/dev-home.mjs # 创建/修复环境并打印状态(幂等,重复跑不会覆盖你的改动)
node scripts/dev-home.mjs --boot # 创建后启动 dsh web(用 Ctrl+C 结束)
脚本建立的东西:
| 位置 | 内容 |
|---|---|
.dsh-test/profiles/web/package.json |
bundle 列表 = @deepseek-ai/dsh-base、@deepseek-ai/dsh-web-app、dsh-laa,patchReload: live |
.dsh-test/profiles/web/node_modules/dsh-laa |
指向本仓库的目录链接,所以改完源码重启即生效 |
.dsh-test/profiles/web/cordis.patch.yml |
只属于这个环境的用户 patch 层,预置了「整天峰时」与「每天头一分钟峰时」两段可直接取消注释的测试窗口 |
.dsh-test/.credentials.yaml |
从 ~/.dsh/.credentials.yaml 复制的一份。隔离 home 看不到真实 home 的密钥,没有它这个环境一次模型请求都发不出去;脚本从不读取或打印密钥值 |
.dsh-test/ 整个目录都在 .gitignore 里。其它参数:--refresh-credentials 重新同步密钥、
--no-credentials 建一个不带密钥的环境、-- --port 64100 换端口、DSH_BIN=<路径> 指定 dsh CLI。
因为 profile 是 patchReload: live,改 cordis.patch.yml 保存后加载器会重新应用该插件
(loader 在 config 变化时重启对应 fiber),运行时于是立刻按新窗口重新评估相位——不用重启
就能在「峰时停机」和「谷时续跑」之间来回切换。想把插件完全摘掉试试,把 bundle 列表里的
dsh-laa 删掉或给它加一行 - id: laa + disabled: true 即可。
怎么测
四层,从最便宜到最贵。前三层都跑在隔离环境里,不会碰你日常的 ~/.dsh。
1. 单元 / 集成测试 —— 秒级,不碰真实会话
npm test
48 个用例,自带一个只实现 LAA 用到的那部分 Cordis 契约的假宿主:峰谷算术(含跨零点、
周末、下一个边界)、峰时拦截与暂存、谷时续跑与重放、工具结果不被当成用户输入、
子代理继承、状态跨进程存活、/laa 命令、浏览器 bundle 的插槽注册与开关显隐门控、
以及"没有 export default"这条模块形状约定。
2. 真实 Cordis 冒烟 —— 秒级
npm run smoke
在真实的 @deepseek-ai/cordis 上验证假宿主替不掉的那部分:inject 门控、
agent/pre-step 瀑布的 { prepend: true } 顺序、ctx.inject(['commands']) 的迟到挂载、
以及卸载时 effect 树的回收。
3. 零成本端到端 —— 真实 DSH + 真实 agent loop,一个模型请求都不发
npm run e2e
.dsh-test 的 headless profile 把峰时窗口设成整天,于是任何步骤都会在
agent/pre-step 里被拒绝——拒绝发生在构建并发出请求之前,所以这次运行不花任何
token,却完整走过了真实的 agent loop、真实会话与真实插件运行时。脚本不断言 stdout,
而是读插件自己的状态文件 .dsh-test/laa/state.json:
✔ 一次真实的 headless 运行创建了新会话并在插件里留了记录 — 1 条新记录
✔ 该运行没有正常完成(被拦下的轮次映射为非零退出码) — exit=1
✔ 被拒绝的是第 1 轮第 1 步 — {"turn":1,"step":1,"deferred":1}
✔ 输入没有丢:被原样暂存进 laa/state.json — deferred=1
4. 手动交互验证 —— 需要你自己点,会真的调用模型
node scripts/dev-home.mjs --boot # 启动隔离环境的 web
A. 看「峰时停机」:打开 .dsh-test/profiles/web/cordis.patch.yml,取消注释第一段
(窗口 = 整天),保存。因为 profile 是 patchReload: live,加载器会重新应用插件。
然后在会话里发一句话:
- 会话不会产生任何回答(请求根本没发出);
/laa会报告「当前 DeepSeek 时段:峰时」和「等待谷时:N 条暂存输入」;.dsh-test/laa/state.json里出现deferred与lastRefusal。
B. 看「谷时续跑」:把同一段里的 end: '23:59' 改成 '00:01'(每天只有头一分钟是
峰时),保存。插件重新加载后会立刻按谷时评估,把上一步暂存的输入投递出去——这时才会
真的调用模型,你会看到会话把刚才那句话接着做完。
C. 看开关:/laa off 之后同样的输入会直接执行;/laa on 再回到 A 的行为。
观察点只有三个:
/laa的输出、.dsh-test/laa/state.json、以及会话本身有没有 产生回答。cordis 的默认 logger 只做内存缓冲、不打到终端,所以别指望在控制台看到 插件的logger.info。
边界与已知限制
- 只续跑实时会话。 谷时到来时没有实时 agent 的会话不会被插件唤醒——拉起一个
冷会话需要 profile 的 agent preset 组合(
ctx.agents.resume({ resumeSessionId, agentOptions, setup })), 那是 UI/会话控制器的职责。这类会话的遗留输入会被保留,等它下一次变成实时 agent 时立刻交付。 - 不杀后台任务。 峰时停的是 agent 的轮次,不是操作系统进程:
pwsh的后台 job、子进程等会继续在后台跑完(它们无法「暂停后继续」,只能被杀掉重跑)。会话在 谷时续跑时再收它们的结果。 - 子会话跟随所属的顶层会话。 子会话没有自己的模式(见「使用 → 子会话」); 已经派发到进程外的子代理(ACP / SDK)不在管辖范围内。
- 子会话的遗留工作要等它自己回到实时状态。 峰时被拦下的子会话输入记在子会话 名下,谷时只有该子会话有实时 agent 时才投递——这与「只续跑实时会话」是同一条规则。
/laa需要命令适配器。 headless / SDK 这类没有命令适配器的组合只是没有这个 入口,LAA 的调度行为不受影响。- 峰时会在会话日志里留下空轮次。 被拒绝的步骤已经开启了
turn/start,拒绝后 以turn/end { reason: { kind: 'blocked' } }收尾。这是 DSH 既有的语义,不是错误。 - 状态文件是本插件自己的。 DSH 的持久化读取路径会拒绝解释未知的会话事件类型
(除非事件带
ignorable标记,而这在公开的session.append()上无法设置),所以 这里不去污染会话日志,而是把状态放在$DSH_HOME/laa/state.json,写入是原子的 (临时文件 + rename)且做了防抖。
开发
本包是零运行时依赖的纯 ESM:不 import 任何 @deepseek-ai/*,只用 Node 内建能力。
profile 中的第三方插件要自己解析依赖,零依赖让它不与宿主版本耦合,也不需要构建步骤。
验证入口见上一节。npm 脚本:test、smoke、e2e、dev-home、dev。
| 文件 | 职责 |
|---|---|
lib/index.js |
Cordis 插件入口:name / inject / apply(以及返回运行时的 mount) |
lib/pricing.js |
峰谷判定与窗口算术的纯函数:resolvePhase、normalizeWindows、zonedTimeToEpoch |
lib/laa.js |
运行时状态机:峰时停机、agent/pre-step 拦截、暂存、谷时续跑与重放 |
lib/command.js |
/laa 斜杠命令 |
lib/web.js |
浏览器控制面:/dsh-laa/health、/state、/mode |
lib/client.js |
浏览器 bundle:滑动开关(会话顶栏 + 新对话页的输入框工具行;手写 __ModuleLoader__ 格式,无构建步骤) |
lib/store.js |
$DSH_HOME/laa/state.json 的同步读 / 防抖原子写:每个会话的模式与遗留工作,外加子会话谱系(lineage) |
scripts/dev-home.mjs |
隔离测试环境(web + headless)的创建与启动 |
scripts/e2e.mjs |
零成本端到端验证:真实 headless 运行 + 状态文件断言 |
只用到 DSH 的公开扩展点:ctx.agents 注册表、Agent.cancel/followup/status、
agent/pre-step 瀑布、agent/created、ctx.commands.register 与 ctx.goals(可选)。
cancel 用的 cause 是 { kind: 'hook', reason: 'dsh-laa: deepseek peak window' }——
hook 是 AgentCancelCause 里为插件/钩子预留的成员,日志与 UI 因此能看出是谁停的。