← 返回列表
✓ 可直接安装
LAA:峰时停止运行,谷时继续运行的开关
自动检查通过:npm 包已发布且 engines 声明满足基线(声明 Node ^22.19.0 || >=24.0.0);该结论来自程序自动检查,未经人工实机验证。 · 最近上游提交 2026/9/17 · 已提供中文文档
综合分
30.7
GitHub 分
30.7
用户评分
—
★ Stars
1
周下载量
—
安装插件(需先安装 dsh CLI 引擎:npm install -g @deepseek-ai/dsh)
dsh plugin --profile web add dsh-laanpm 包 dsh-laa 已校验归属本仓库,走 npm 安装最省事
数据截至 2026/9/18(元数据每日更新 · 实装验证按队列轮转,单条结论的验证时间见上方)
安装兼容性检查✓ 自动检查通过
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✓npm 包dsh-laa @ 0.6.0
✓Node 引擎要求 ^22.19.0 || >=24.0.0 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
验证方式:npm registry 存在性 + package.json 静态校验 · 最后验证 2026/9/19 14:35:10
用户评分
还没有人投票,来当第一个
订阅周报,不错过优质插件更新
每周一封 · 高评分插件 + 新用户活动
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
对齐去问),页面上的开关:
1. 跳三下(prefers-reduced-motion 下自动不播动画);
2. 开着 LAA 的时候,轨道颜色用 1.26 秒从绿平滑推到琥珀(或反过来)——平时 0.2 秒的
换色只够「变」,不够「看得出它变了」;
3. 弹一条浏览器通知(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= | 一个会话的完整状态(开关、峰谷、下一次切换、待恢复条数);子会话的 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 ""
写入被拒绝时返回 400(缺参数/类型不对)、409(插件总开关 enabled: false)或 404
(未知路径),并且不会先把状态改掉;前端拿到的永远只有 { ok, value } 或
{ ok: false, error }。控制面是可选挂载:没有 webServer 的组合(headless / SDK /
ACP)只是没有浏览器入口,调度行为不受影响。
峰时到底发生什么
峰时开始的那一刻:
1. 所有属于该会话的实时 agent(根会话 agent 及其在进程内的子代理)如果正在跑,
会被 cancel(cause, { keepInbox: true }) 中止当前轮次,已排队但还没开始的输入会被保留。
2. 之后任何试图进入的步骤都会在 agent/pre-step 瀑布里被拒绝。拒绝发生在
模型请求之前,所以峰时不会产生任何 API 调用。
3. 被拒绝步骤的输入不会丢:轮次的第一个步骤里携带的、真正的用户/插件输入会被
原样记进状态文件;轮次中途的步骤只把会话标记为「需要续跑」——工具结果不是用户
输入,不会被当成用户消息重放。
峰时期间你在 UI 里发消息是安全的:它会等谷时再被投递,而不是被丢掉;不想等就直接把
LAA 关掉(/laa off 或点开关),攒下的输入会在当刻发出去。
关掉 LAA 时的投递范围是整棵会话树(父会话 + 它的所有子会话),并且与谷时用不同的
续跑提示词([LAA MODE — TURNED OFF],而不是 OFF-PEAK WINDOW OPENED)——模型因此
知道自己不是「等到了谷时」,而是「人把 LAA 关了」。当时没有实时 agent 的会话交不出去,
输入留在状态里,等它下次运行(agent 被拉起)时立刻投递,一条都不会丢。
只管按峰谷计费的提供方
峰谷定价只存在于 DeepSeek 官方路由(DSH 里是 deepseek-official)。所以峰时约束
只作用于 peakProviders 里的提供方(默认就是它一个):别的路由——自建网关、第三方
中转、别家模型——不按峰谷计费,峰时一个请求都不会被拦,会话照跑。
路由是这样解析的(从最贴近事实到最兜底):
1. 会话最近一次请求的记录头(session.requestHeader())——会话中途换过模型的话,
只有这里反映当前真正在用的路由;
2. agent 创建时的路由(agent.options.provider);
3. 部署默认路由(ctx.agentDefaultModel.currentSelection())。
所以中途换模型会立刻跟上:换成别的提供方,峰时就不再拦你。三级都读不到路由时按
「按峰谷计费」保守处理——宁可多等一会儿,也不漏拦真正按峰时计费的请求。
开关的悬停提示会写明这一条:当会话当前的路由不在清单里时,多一行
「峰时不会阻塞:这个提供方不按峰谷计费(openai)」。想让某个自建路由也受约束,把它的
路由 id 加进 peakProviders 即可;[] 表示谁都不拦。
一个例外要说清楚:如果这个会话之前(还是按峰谷计费的路由时)已经在峰时攒下了输入,
那些输入仍然按原来的节奏走——谷时投递,或者你 /laa off 立即投递。换路由不会替你把
已经压下的活立刻发出去。
谷时到底发生什么
谷时开始的那一刻,每一个还有遗留工作的会话:
1. 如果峰时中断过轮次,先投递一条续跑提示词 [LAA MODE — OFF-PEAK WINDOW OPENED],
让模型从既有历史、工作区与工具结果继续,而不是从头重来。
2. 然后把峰时暂存的输入按原顺序逐条重新投递(保留原本的 message source)。
3. 如果峰时拦下的是 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
peakProviders:
- deepseek-official
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
本地隔离测试环境
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 因此能看出是谁停的。扫码进群