Skip to content
dsh.fish
Bundle

dsh-plugin-scheduled-tasks

Scheduled tasks for DeepSeek Harness (dsh): at a daily / weekly / fixed-interval trigger, open a new session, send a prompt, and optionally push the result to a webhook.

Source
Jeff1573
License
MIT
Updated
Updated 6 hours ago

Readme

# dsh-plugin-scheduled-tasks

给 [DeepSeek Harness](https://github.com/deepseek-ai/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 + 密码)从任何设备访问,见 [反向代理部署](docs/reverse-proxy.md)

<!-- 建议在这里插入一张侧边栏面板的截图:这是访客第一眼最想看到的东西 -->

## 安装

前提:已经用得上 `dsh`(`npx @deepseek-ai/dsh web` 或全局安装)。

### 一条命令(推荐)

```sh
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,在后面加 `#`:

```sh
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/*` 单例;放在别处会加载到重复实例,插件起不来。

```sh
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 之后**,层的顺序就是数组顺序):

```json
{
  "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`——注意出厂那三个必须原样重述:

```yaml
- 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: 限定在工作区内,且不会停下来征求批准——供无人值守的定时运行使用。
```

### 确认与重启

```sh
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` 里这一段删除:

```yaml
- insert:
    - id: scheduled-tasks
      name: 'dsh-plugin-scheduled-tasks'
```

`permission` 那一段(如果加过)保留不动。然后按上面的「确认与重启」核对只剩一条。

### 卸载

```sh
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}`:

```sh
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。

编辑表单按任务自己的时区回填(不采用当前浏览器的时区,否则从另一台机器改个提示词就会悄悄挪动触发时刻)。

## 开发

```sh
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

Install

dsh plugin --profile web add github:Jeff1573/dsh-plugin-scheduled-tasks#dcdb0be9ba52a70c5b9fe122c5b1667280d5b314

Profile: web

Source