dsh-plugin-scheduled-tasks
给 DeepSeek Harness 的定时任务插件。
到点自动新建一个会话并发起一轮对话——「每天九点帮我看一下 CI」这类事情不必有人守着。侧边栏底部有入口,可以增删改任务、暂停/启用、立即运行,并查看运行历史。
- 三种触发规则:每天 / 每周几 / 固定间隔(最短 5 分钟),按 IANA 时区计算,正确处理夏令时跳变
- 无人值守权限:附带一个「限定在工作区内、且不会停下来征求批准」的权限预设——dsh 出厂预设里没有这个组合
- 完成通知:Bark / 企业微信群机器人 / ntfy / Server酱 / Telegram / 飞书 / 钉钉,或任意 webhook
- 模型可以自己排任务:
scheduled_task_*工具在每个会话里都能用 - 点通知直接跳进那次会话,运行历史里也能点开
- 一条命令装完即用:
dsh plugin --profile web add github:Jeff1573/dsh-plugin-scheduled-tasks,插件自带 bundle patch,不用手改配置 - 可以放到反向代理后面(TLS + 密码)从任何设备访问,见 反向代理部署
安装
前提:已经用得上 dsh(npx @deepseek-ai/dsh web 或全局安装)。
一条命令(推荐)
dsh plugin --profile web add github:Jeff1573/dsh-plugin-scheduled-tasks
然后重启 dsh web 即可——不需要手改任何配置文件。
dsh plugin 把参数转发给 profile 目录里的 pnpm,装完再按已安装状态调和 dsh.profile.bundles:凡是 manifest 里声明了 dsh.bundle.patch 的依赖都会自动加入层栈。本插件带着自己的 cordis.patch.yml,把注册自己那一行放在里面,所以装上就生效。
想跟某个分支或 tag,在后面加 #:
dsh plugin --profile web add github:Jeff1573/dsh-plugin-scheduled-tasks#main
几点说明:
- pnpm 必须在 PATH 上。
dsh plugin只是个 pnpm 转发器,找不到 pnpm 会直接以 127 退出并提示;npm i -g pnpm即可。 - profile 不存在时会自动初始化,不用先手工建。
- 本插件不发布到 npm registry,只从 git 安装。所以升级也走 git:
dsh plugin --profile web update dsh-plugin-scheduled-tasks。 - 本插件没有
prepare脚本,lib/下的构建产物直接提交进仓库,所以 git 安装不需要任何构建步骤,也不会撞上 pnpm 的构建拦截。(其他需要在安装时构建的 git 插件会被 pnpm 拦下,那时dsh会提示你把它打印的 key 加到 profile 的pnpm-workspace.yaml的allowBuilds下再重跑。)
手工安装(没有 pnpm 时)
仓库必须克隆到 ~/.dsh/profiles/ 目录树里面。 Node 会把 symlink 解析到真实路径再向上查找 node_modules,只有位于 profiles 树内才能共享 harness 那一份 @deepseek-ai/* 单例;放在别处会加载到重复实例,插件起不来。
mkdir -p ~/.dsh/profiles/plugins
git clone https://github.com/Jeff1573/dsh-plugin-scheduled-tasks.git \
~/.dsh/profiles/plugins/scheduled-tasks
mkdir -p ~/.dsh/profiles/web/node_modules
ln -sfn ~/.dsh/profiles/plugins/scheduled-tasks \
~/.dsh/profiles/web/node_modules/dsh-plugin-scheduled-tasks
然后手工做 dsh plugin 本来会替你做的那件事:把包名加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles(追加在官方 bundle 之后,层的顺序就是数组顺序):
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-plugin-scheduled-tasks"
]
}
}
}
插件自带的 patch 层会接手注册,所以不要再往 cordis.patch.yml 里写 insert——两处都写会让插件在合成树里出现两次,而 --dump-config 不会报这个错。
(建议)无人值守权限预设
理由见下文无人值守的权限。这一项不由插件自带:patch 的 config 是整体替换,插件擅自重述 permission 那一行会覆盖掉别的层定义的预设,而放宽部署的权限表属于运维决策,不该由某个插件的安装动作代劳。
所以这一段要你自己加到 ~/.dsh/profiles/web/cordis.patch.yml——注意出厂那三个必须原样重述:
- id: permission
name: '@deepseek-ai/dsh-permission-presets'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
scheduled:
sandbox: workspace-write
approval: never
name: 定时任务
description: 限定在工作区内,且不会停下来征求批准——供无人值守的定时运行使用。
确认与重启
dsh --profile web --dump-config | grep -A1 scheduled-tasks
只读、不 boot。应当恰好输出一条 id: scheduled-tasks;输出两条说明既进了 bundle 层又留着手写的 insert,去掉后者。确认无误后重启 dsh web——插件集合变更需要重启,包元数据缓存不过期。
从早期的手工安装迁移
0.1.0 之前本插件不带 bundle patch,装法是往 profile 的 cordis.patch.yml 里手写一行 insert。现在插件会自注册,那一行必须删掉,否则插件加载两次(两套定时器、两个侧边栏入口)。
把 cordis.patch.yml 里这一段删除:
- insert:
- id: scheduled-tasks
name: 'dsh-plugin-scheduled-tasks'
permission 那一段(如果加过)保留不动。然后按上面的「确认与重启」核对只剩一条。
卸载
dsh plugin --profile web remove dsh-plugin-scheduled-tasks
dsh plugin 的调和是双向的:依赖没了,它也会从 dsh.profile.bundles 里退出。手工安装的话,把包名从 bundles 里删掉、移除 symlink 即可。两种方式都需要重启。
任务数据留在 ~/.dsh/storages/scheduled_tasks.json,删掉该文件即彻底清除;webhook 地址在 ~/.dsh/.credentials.yaml 的 SCHEDULED_TASKS_NOTIFY_URL。
使用
重启后侧边栏底部会出现「定时任务」入口,点开就是面板。
建第一个任务
面板右下角「新建任务」。必填只有三项:
| 字段 | 说明 |
|---|---|
| 触发时间 | 「每天」/「每周」/「间隔」三选一。前两者填时:分,按表单下方显示的时区计算;「间隔」填分钟数(≥5) |
| 任务名称 | 列表和通知里显示的名字 |
| 你希望 dsh 做什么? | 到点后新建一个会话,把这段内容作为第一条消息发出 |
点「创建」即生效,列表里每行会显示下一次触发时间。
高级选项
同一个表单里展开「高级选项」:
- 工作目录 —— 留空则用默认 cwd
- 权限预设 —— 无人值守建议选
scheduled;留空跟随全局默认,而全局默认通常是会发问的,定时运行会卡住直到超时 - Agent 预设 —— 留空跟随全局默认
- 运行超时 —— 默认 30 分钟,到点
agent.cancel()并记为「超时」 - 失败后 5 分钟自动重试一次 —— 只重试一次,不串联
- 进程重启后,若已错过触发则补跑一次 —— 默认关闭
- 完成后通知 / 附带模型回复摘要 —— 可覆盖全局设置
运行与查看
列表每行有「立即运行 / 编辑 / 暂停 / 删除」:
- 立即运行 会等到会话创建完成就跳进那个对话,可以看着它流式生成
- 定时触发 的会话会在生成过程中自己出现在侧边栏,但不会抢占你正在看的界面
- 历史 N 展开该任务的运行记录,每条都能点开对应会话;状态分 成功 / 失败 / 超时 / 已中断
- 入口上带红点表示最近有失败
编辑复用新建表单,只是标题变成「编辑自动化任务」。编辑正在运行中的任务不会打断那一次运行,它跑完后按新记录重排。
完成通知
面板里「通知设置」,勾选「启用完成通知」后选通道:
| 通道 | 怎么填 |
|---|---|
| Bark(iOS 推送) | 地址填 App 给的 https://api.day.app/<KEY>,自架的 Bark 服务器同样可以 |
| 企业微信群机器人 | 群里添加「群机器人」,把它给的 Webhook 地址粘进来。官方接口、无需 OAuth,但个人用要先注册企业才能建群 |
| 自定义模板 | 地址填服务端点,下面粘贴它要的 JSON body,用 {{占位符}} 取值。界面里有 ntfy / Server酱 / Telegram / 飞书 / 钉钉的现成模板可一键套用 |
| 自定义 JSON webhook | 固定结构 {source, task, taskId, status, at, host, sessionId, detail?, reply?},发给你自己写的接收端 |
可用占位符:task taskId status statusText at atIso host sessionId detail reply。
其余几项:
- 默认时机 —— 「每次运行结束都通知」或「仅失败或超时时通知」,单个任务可在自己的高级选项里覆盖为
always/failure/never。 - Web 界面地址(可选) —— 填了之后推送会带上
<base>/?dsst-session=<id>,点通知直接跳到那次运行的会话。地址必须是收通知的设备能访问到的(内网 IP、内网穿透域名等),127.0.0.1只在本机有效。 - 默认附带模型回复摘要 —— 默认关闭,开启后只发前 200 字。通知本身必然要把任务名、时间、状态、主机名发出这台机器;回复内容属于额外外发,所以做成显式开关。
- 发送测试通知 —— 会立刻向已保存的地址真实发送一条消息。
几条约束:
- 地址即密钥。企业微信把 robot key 放在 query string 里,所以 URL 整体当凭据处理:存
ctx.credentials的SCHEDULED_TASKS_NOTIFY_URL(落在~/.dsh/.credentials.yaml,权限 600),不进任务表,也永不回传给浏览器——前端只拿到「是否已配置」和 origin。表单里留空表示「保持不变」。 - 公网必须
https;明文http只允许发往本机或内网(127/10/192.168/172.16-31、localhost、*.local),这样自架的 Bark/ntfy 在局域网里能直接用,而带 key 的地址永远不会明文走公网。 - 通知失败不会把成功的运行记成失败:投递错误写在该次运行记录的
notifyError上,仅此而已。 - 企业微信群机器人有频率限制(每分钟约 20 条),高频间隔任务请酌情用「仅失败时通知」。
- 个人微信没有官方发送接口。 网上看着能用的那些库走的是逆向出来的网页/桌面协议,违反服务条款且会导致封号,所以这里不提供这种渠道。想在微信里收,走企业微信(可转发到个人微信)或 Server酱 一类的服务号中转(用「自定义模板」通道)。纯个人用其实 Bark / ntfy 更顺手,而且可以自架。
在对话里排任务
scheduled_task_list / scheduled_task_create / scheduled_task_delete 注册在根 context 上,所以每个会话都能用。「每天九点帮我看一下 CI」可以在对话里直接说,不必打开面板。
scheduled_task_create 的参数:name、prompt、kind(daily / weekly / interval)、hour、minute、weekdays(0=周日)、minutes(≥5)、timeZone、cwd、权限预设。
注意定时任务跑起来的会话同样能看到这三个工具,也就是说一个定时任务可以再排新的定时任务。
设计与实现
为什么不用 @deepseek-ai/dsh-schedule
dsh-schedule 是 session-local reminder:只能在一个已经活着的会话里给它自己的 agent 排后续消息(deliveryMode 是写死的 'session-local'),冷会话不触发,也不支持日历/cron 语义。本插件要的是「无人值守、到点新建会话」,两者不是同一件事,所以自己拥有触发器并调用 ctx.agents.create()。
组成
| 文件 | 作用 |
|---|---|
lib/index.js |
Host 半边:持久化、定时、建会话跑一轮、RPC |
lib/clock.js |
时区日历计算(纯函数,可脱离 harness 运行) |
src/client/ |
浏览器半边源码(React + slot 注册) |
lib/client.js |
构建产物,由 build.mjs 生成 |
build.mjs |
复刻官方 clientBundle() 的输出契约 |
cordis.patch.yml |
bundle patch 层:dsh.bundle.patch 指向它,装上即自注册 |
执行链路
沿用 dsh-headless 的 run 链,并补上 Web 场景所需的两步(参考 dsh-host-apiproxy):
ctx.agents.withoutInitiator(() => ctx.agents.create({ sessionId, meta:{cwd, agentPreset}, agentOptions, setup }))
→ agent.whenIdle()
→ agent.followup(createUserMessage(...))
→ agent.whenIdle()
→ ctx.sessions.flush(agent.session)
→ workspace.attachSession(sessionId) // 让会话出现在侧边栏
→ handle.dispose() // 日志已落盘,用户点开走 resume
withoutInitiator 是必须的:定时唤醒不属于任何 agent,否则会误继承当时恰好活着的那一个。
无人值守的权限
这是本插件最要紧的一处设计。
dsh 出厂的权限预设只有 read-only(ask) / workspace-write(ask) / danger-full-access(never)——没有「受限但不发问」的组合。定时任务要么卡在没人回答的审批上,要么开全权限。
所以安装步骤 3 里额外定义了一个 scheduled 预设:sandbox: workspace-write + approval: never,即限定在工作区根目录与 /tmp,且不停下来问人。
应用方式是建完 agent、发 prompt 之前调用 ctx.permissionPresets.set(agent.session, preset)——第一个工具调用就必须已经在这个界限内。会话日志里能看到两段:先是用户默认被 pin 下来,然后切到任务自己的预设。
即便如此,配了会发问的预设仍可能卡住,所以每个任务都有运行超时(默认 30 分钟),到点 agent.cancel() 并把这次记为 timeout。没有超时的话,卡住的任务会永远占着 running,之后每一次到期都被跳过。
定时
ctx.timeout 只用于「睡到下一个检查点」。超过 Node 定时器上限(约 24.8 天)的等待分段进行,每次唤醒都重读墙钟再决定是否真的到点——所以主机休眠或系统时钟被步进都不会漏触发或重复触发。
日历计算在 lib/clock.js,三种触发规则:
daily/weekly:给定{hour, minute, timeZone}(weekly 另加weekdays,0=周日)求下一个严格晚于now的时刻。DST 春季跳变里不存在的钟点(例如 America/New_York 的 02:30)落到跳变后的第一个瞬间;秋季重叠取较早的那次。interval:锚定在任务创建时刻,而不是上一次唤醒。09:02 创建、周期 15 分,就永远落在 :02/:17/:32/:47,任何一次运行拖多久都不会让相位漂移。错过的周期直接坍缩,不会堆积补跑。
previousOccurrence() 支撑「补跑」:进程重启时,若某任务上一次应触发的时刻已过且没有对应的运行记录,就补跑一次(latest-only,沿用 dsh-schedule 的先例)。默认关闭——无人值守的任务在启动时突然自己跑起来,应该是一个选择而不是一个意外。
存储
ctx.storageDomain,domain 名 scheduled_tasks,落在 $DSH_HOME/storages/scheduled_tasks.json。
注意存储层的 UNIT_NAME_RE 是 /^[a-z][a-z0-9_]*$/ —— 下划线,不能用中划线。
前后端通信
ctx.connection.rpc,通道 /rpc-scheduled-tasks,authority: 'trusted-host'。
不能用 loopback:loopback 通道的可信列表是写死的空表,只接受 loopback 的 Host 头,套在反向代理后面 Host 是域名,每个调用都会 403。trusted-host 是出厂 /api 通道用的同一道围栏——由部署方用 dsh web --trusted-host <domain> 声明自己的 authority,跨站/来源检查照旧生效。
endpoint 在 URL 路径里,body 是 {type:'client-request', rpcId, method, payload}:
curl -sX POST http://127.0.0.1:3080/rpc-scheduled-tasks/list \
-H 'content-type: application/json' \
-d '{"type":"client-request","rpcId":"1","method":"list","payload":{}}'
endpoint:list / create / update / delete / toggle / runNow / notifyGet / notifySet / notifyTest。list 顺带返回 permissionPresets / agentPresets 目录和 failureCount,前端据此渲染下拉框与入口红点。
update 只移动表单里的六个字段(名称、指令、时/分、时区、工作目录);enabled、createdAt 和运行记录属于任务自身的生命周期,不随编辑变化。改完必定重排定时器——编辑很可能挪动了触发时刻,旧的等待一定是过期的。
第三方插件无法注册自己的 Host→Client 推送事件(API_REMOTE_FORWARDED_EVENTS 是写死的白名单,里面没有任何 session / workspace 事件),所以面板打开时 5 秒轮询、关闭时 30 秒轮询。
对话的即时呈现:定时运行的会话完全在宿主侧创建,浏览器不会收到任何通知——不做处理的话,新会话要刷新页面才看得见。两条路径分别处理:
- 「立即运行」:
runNow端点会等到会话创建完成(而不是整轮跑完)才返回startedSessionId,前端拿到后立刻刷新会话列表并跳进那个对话。这是用户手势,抢占当前视图是合理的。 - 定时触发:
list里带有activeRuns(进行中的运行及其会话 id),轮询发现新条目就刷新侧边栏——对话在生成过程中就会出现在列表里,但不会抢占你正在看的界面;任务跑完的那次轮询会再刷一次。refresh()不在 feature 包可用的公开契约上(上游把它留在具体类上),因此是探测调用而非直接依赖:将来某个版本移除它,面板照常工作,侧边栏退回到「刷新页面才更新」。运行记录也可以点击,走公开的sessions.open(id)直接跳过去。
通知的实现
引擎就是一次 webhook POST,各渠道只是 body 模板——把插件绑死在某家的消息 API 上,对方一改就废,而「模板 + URL」能扛过去。
插值发生在解析后的 JSON 树上,不是对模板文本做字符串替换——所以模型回复里带引号或换行也不会撑破 body。模板在保存时就要求能 JSON.parse 且顶层是对象。
「附带模型回复摘要」是三态而不是布尔:任务记录里那个字段总是被显式写入,布尔值就永远不会「缺省」,全局开关会变成死代码——早期版本正是这个 bug。存量的布尔值按「未选择」读,即跟随全局。
UI
注册在 sidebar.footer.action(kind: 'list',additive,不会顶掉任何现有占位者)。组件来自 @deepseek-ai/dsh-client-ui-primitives;该包没有 Select 和 TimePicker,所以「每天」目前是固定文案,时间用原生 <input type="time">。颜色只用 --dsw-alias-* 语义 token。
编辑表单按任务自己的时区回填(不采用当前浏览器的时区,否则从另一台机器改个提示词就会悄悄挪动触发时刻)。
开发
npm install # 只装 esbuild(peerDependencies 由 harness 提供,别装到本地)
node build.mjs # 重建 lib/client.js
⚠️ 装依赖务必用 npm install --omit=peer(或就是 npm install,因为 peer 都在 peerDependencies 里)。如果 @deepseek-ai/* 被装进本包的 node_modules,插件会拿到与 harness 不同的模块实例,服务注入会失效。
lib/client.js 有意提交进仓库:客户端模块注册表是直接从磁盘读这个文件的,没有它就是 404,克隆下来必须能直接用。.map 则被忽略。
客户端有 HMR:产物 hash 变化会被 host 轮询到并推给浏览器,刷新页面即可。host 半边没有 HMR(web 组合里被上游显式禁用),改 lib/index.js 要重启 dsh web。
lib/clock.js 是纯函数,可以直接 node --input-type=module -e "import {nextFireAt} from './lib/clock.js'; ..." 验证。
已知限制
- 无人值守的权限:
scheduled预设不发问,但它在工作区内是可写的——定时任务会真实改文件、跑命令。要更严就选read-only,代价是任何写操作都会卡在审批上直到超时。 - 自动重试只有一次,且不串联:结构性失败不会变成无限重跑。
- 通知只有一个全局目标地址和一个模板,不能按任务分发到不同的群/设备。
- 没有通知的重发或队列:一次 POST 失败就只记录,不重试。
- 触发规则支持每天 / 每周几 / 固定间隔,没有 cron 表达式——全仓库没有 cron 解析器,要支持得自己引库。
- 间隔最短 5 分钟。
- 补跑是 latest-only 且默认关闭:错过 5 次也只补 1 次。
- 同一任务的上一次运行未结束时,新的到期会被跳过(不排队)。
- 面板只在打开时轮询,关闭后不刷新。
License
MIT
No comments yet. Be the first to write one.