Fork dsh-cron-tasks as dsh-ops-cron with DSH/IM delivery.

Agent cron_* jobs default to IM when created from a WhatsApp session with a matching delivery target; otherwise DSH sidebar sessions. Fire path soft-calls ctx.dshIm.send for summaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 21:48:17 +08:00
parent 4447404361
commit 2f08136adf
17 changed files with 472 additions and 285 deletions

33
FORK.md Normal file
View file

@ -0,0 +1,33 @@
# dsh-ops-cron(运维 fork)
本仓库是 [Whale-Zhang/dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) 的 **运维 fork**,用于:
- Agent 自建定时任务(`cron_create` / `cron_list` / …)
- 按创建来源默认投递:**WhatsApp 会话 → IM**;**Web/DSH 会话 → 侧栏新会话**
- 与 [dsh-im-ops](https://github.com/hansjone/dsh-im-ops) 的 `ctx.dshIm.send(botId, targetId, text)` 打通
不要与上游 `@dsh-external/dsh-cron-tasks` 装进同一 profile(工具名冲突)。
## 安装
```powershell
# 建议顺序:先 im,再 cron
dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
# 重启 dsh web
```
本地开发:
```powershell
dsh plugin --profile web add -w "D:\project\chatgpt\dsh-ops-cron"
```
## 相对上游的改动
1. 包名 / cordis id / HTTP 前缀 / 侧栏文案 → `dsh-ops-cron` /「运维定时」
2. Job 增加 `delivery: { kind: 'dsh'|'im', botId?, targetId? }`
3. `cron_create` 支持 `delivery` / `im_bot_id` / `im_target_id`;未显式指定时,若当前会话能 `resolveSessionPeer` 且已有匹配投递目标 → 默认 IM
4. 开火后若 `delivery.kind === 'im'`,把 assistant 摘要经 `ctx.dshIm.send` 投回
关键告警推送仍在 **netxops**,不经过本插件。

123
README.md
View file

@ -1,116 +1,31 @@
# dsh-cron-tasks
# dsh-ops-cron
[English](#english) · [中文](#zhongwen)
Ops fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) for DeepSeek Harness.
A DeepSeek Harness (`dsh`) plugin for **scheduled tasks**. Sidebar entry under **New Session**, plugin settings, isolated run history, and model-facing `cron_*` tools.
- Sidebar **运维定时** under New Session
- Agent tools: `cron_create` / `cron_list` / `cron_pause` / `cron_resume` / `cron_delete`
- Per-job delivery: **DSH** (new root session) or **IM** (`ctx.dshIm.send`)
![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7c3aed)
![license](https://img.shields.io/badge/license-MIT-blue)
See [FORK.md](FORK.md) for install notes. Do **not** install upstream `@dsh-external/dsh-cron-tasks` in the same profile.
<a id="zhongwen"></a>
## 中文
到点后 **新开一条会话** 跑事先写好的提示词,记进侧栏「定时任务」历史。这类会话不会淹没日常工作区列表。
这不是官方同会话 reminder(`@deepseek-ai/dsh-schedule`),也不是系统 `crontab`。调度器住在 DSH Host 里:Host 没在跑就不会触发。
### 安装
需要已安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。一条命令装进网页端和桌面端:
## Install
```sh
dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks
dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks
dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
# restart dsh web
```
装完后**重启对应 profile**(`dsh web` 或 Desktop)。用下面命令应能看到 `dsh-cron-tasks`:
## Delivery defaults
```sh
dsh --profile web --dump-config
```
| Created from | Default delivery |
|--------------|------------------|
| WhatsApp / IM session (peer resolved + matching 投递目标) | `im` → that `botId`+`targetId` |
| Web / plain DSH session | `dsh` → sidebar history session |
| Explicit `delivery` / `im_bot_id`+`im_target_id` | as specified |
### 卸载
Create IM targets first in IM → 投递设置 → 复制调用参数 when you need a stable target for WhatsApp.
```sh
dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
```
## License
### 做什么
- 左侧 **「新会话」正下方** 的 **「定时任务」** 入口:创建、暂停/恢复、删除、立即运行、搜索
- **设置 → 插件 → 插件配置** 里的调度策略(启用、默认时区、历史保留、重叠/漏跑)
- 每次触发新开 root Session;点历史记录打开原生对话,可以继续聊
- 每个任务可指定 **模型**(钉死后不跟着聊天模型变,额度可预期)
- 工作目录可下拉已有工作区,或自定义路径
- 也可在对话里对 AI 说「帮我建一个定时任务」(skill `scheduled-tasks` + `cron_create`)
### 日程
- 一次性 `at`,或循环 5 字段 cron,IANA 时区(默认 `Asia/Shanghai`)
- 「一分钟后」用 `after_minutes`,不要叠 `at` / `hour` / `expr`
- 重叠:同一任务已有进行中的运行则跳过
- 漏跑:Host 宕机期间错过的节拍 **不补跑**
### 开发
```sh
node --test test/*.test.js
```
本地 `file:` 安装(开发用,不要用 `link:`):
```sh
dsh plugin --profile web add file:$HOME/.dsh/plugins/dsh-cron-tasks
```
产品契约见 [docs/PRD.md](docs/PRD.md)。实现经验见 [docs/NOTES.md](docs/NOTES.md)。
---
<a id="english"></a>
## English
At the scheduled time, Harness **opens a fresh root session**, runs a self-contained prompt, and records the run in the **Scheduled tasks** sidebar. Those sessions stay out of the ordinary workspace list.
This is not the official same-chat reminder (`@deepseek-ai/dsh-schedule`) and not OS crontab. The scheduler lives in the DSH Host process; nothing fires while Host is down.
### Install
Requires [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
```sh
dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks
dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks
```
Restart the profile after installing. `dsh --profile web --dump-config` should list `dsh-cron-tasks`.
### Uninstall
```sh
dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
```
### What it does
- Sidebar entry **under New Session**: create, pause/resume, delete, run now, search
- Scheduler policy in **Settings → Plugins → Plugin configuration**
- Each fire is a new root session; open a history row to continue in the native chat
- Each job can pin a **model** so quota does not follow the chat selector
- Working directory: existing workspace or a custom path
- Ask the agent to create a job (`scheduled-tasks` skill + `cron_create`)
### Schedule
- One-shot `at` or 5-field cron, IANA time zone (default `Asia/Shanghai`)
- “In N minutes” → `after_minutes`
- Overlap: skip if the same job is already running
- Misfire: missed beats while Host was down are **not** replayed
### License
MIT
MIT (upstream MIT retained).

View file

@ -1,4 +1,5 @@
# Insert this plugin into a profile's layer stack.
# Insert this ops cron plugin into a profile's layer stack.
# Do not install upstream @dsh-external/dsh-cron-tasks alongside this package.
- insert:
- id: dsh-cron-tasks
name: '@dsh-external/dsh-cron-tasks'
- id: dsh-ops-cron
name: dsh-ops-cron

View file

@ -1,5 +1,5 @@
window.__ModuleLoader__.load({
id: '@dsh-external/dsh-cron-tasks',
id: 'dsh-ops-cron',
factory: (require) => {
const module = { exports: {} }
const exports = module.exports
@ -156,16 +156,16 @@ window.__ModuleLoader__.load({
return undefined
}
const name = 'dsh-cron-tasks'
const NS = 'dsh-cron-tasks'
const name = 'dsh-ops-cron'
const NS = 'dsh-ops-cron'
const inject = ['slots', 'locale', 'settingsScope']
const API = '/dsh-cron-tasks'
const API = '/dsh-ops-cron'
const LOCALE_NS = 'settings.dshCronTasks'
const TITLE_PREFIX = '定时任务 · '
const TITLE_PREFIX = '运维定时 · '
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
function jobStamp(rows) {
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}`).join('|')
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}`).join('|')
}
function runStamp(rows) {
@ -291,19 +291,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
`
const zh = {
title: '定时任务',
title: '运维定时',
description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。',
expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改',
saveFailed: '本部署没有接受这些值,已保留供你修改。',
enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数',
overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过',
hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。',
entry: '定时任务', entryLabel: '打开定时任务',
entry: '运维定时', entryLabel: '打开运维定时',
backToWorkspace: '返回工作区', backLabel: '返回工作区',
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
emptyJobs: '还没有运维定时。点右上角 + 新建。', next: '下次', last: '上次',
expandRuns: '展开记录', collapseRuns: '收起记录',
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停',
@ -313,19 +313,25 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
model: '模型',
modelDefault: '每次运行用当时的新会话默认',
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
editorLead: '保存后按日程在新会话里执行。点击下方某次运行,右侧会打开那次对话,可以继续。',
modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
delivery: '投递',
deliveryDsh: 'DSH 侧栏会话',
deliveryIm: 'WhatsApp / IM',
imBotId: 'IM Bot ID',
imTargetId: 'IM Target ID',
deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。',
}
const en = {
title: 'Scheduled tasks',
title: 'Ops schedule',
description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.',
expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention',
overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip',
hint: 'Open from under New Session. Run output appears in history; click a run to continue in the normal chat view.',
entry: 'Scheduled tasks', entryLabel: 'Open scheduled tasks',
entry: 'Ops schedule', entryLabel: 'Open ops schedule',
backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace',
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
@ -341,8 +347,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
model: 'Model',
modelDefault: 'Use the New Session default at fire time',
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
editorLead: 'Runs start a new session. Click a run below to open that conversation and continue it.',
editorLead: 'Runs start a new session. IM delivery also sends a summary via dshIm after the turn. Click a run below to open that conversation.',
lastOutput: 'Last output', noOutput: 'No output yet. Run it once.',
delivery: 'Delivery',
deliveryDsh: 'DSH sidebar session',
deliveryIm: 'WhatsApp / IM',
imBotId: 'IM Bot ID',
imTargetId: 'IM Target ID',
deliveryHint: 'Web defaults to DSH. WhatsApp sessions can auto-match a delivery target. Or paste botId/targetId from IM delivery settings.',
}
function normalizePrefs(value) {
@ -585,6 +597,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
provider: current?.provider || '',
model: current?.model || '',
deliveryKind: 'dsh',
imBotId: '',
imTargetId: '',
}
}
@ -601,9 +616,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
enabled: job.enabled !== false,
provider: job.provider || '',
model: job.model || '',
deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh',
imBotId: job.delivery?.botId || '',
imTargetId: job.delivery?.targetId || '',
}
}
function jobDeliveryLabel(job, t) {
if (job?.delivery?.kind === 'im') {
return `${t('deliveryIm')}:${job.delivery.targetId || '?'}`
}
return t('deliveryDsh')
}
function modelKey(provider, model) {
if (!provider || !model) return ''
return `${provider}::${model}`
@ -723,7 +748,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
: (jobModelLabel(job) ? `${job.name} · ${jobModelLabel(job)}` : job.name))),
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
@ -865,6 +890,28 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
),
h(CwdField, { t, form, setForm, workspaces }),
h(ModelField, { t, form, setForm, catalog }),
h('label', null, t('delivery'),
h('select', {
value: form.deliveryKind || 'dsh',
onChange: (e) => setForm({ ...form, deliveryKind: e.target.value }),
},
h('option', { value: 'dsh' }, t('deliveryDsh')),
h('option', { value: 'im' }, t('deliveryIm')),
),
),
form.deliveryKind === 'im'
? h('div', { className: 'dsh-ct-editorRow' },
h('label', null, t('imBotId'), h('input', {
value: form.imBotId || '',
onChange: (e) => setForm({ ...form, imBotId: e.target.value }),
})),
h('label', null, t('imTargetId'), h('input', {
value: form.imTargetId || '',
onChange: (e) => setForm({ ...form, imTargetId: e.target.value }),
})),
)
: null,
h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
h('div', { className: 'dsh-ct-editorActs' },
h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')),
!isNew ? h('button', { type: 'button', className: 'dsh-ct-secondary', onClick: onRun }, t('runNow')) : null,
@ -1037,6 +1084,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
name: form.name, prompt: form.prompt, schedule,
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
provider: form.provider, model: form.model,
delivery: form.deliveryKind === 'im'
? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
: { kind: 'dsh' },
}
if (selection.type === 'job' && selection.jobId) {
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) })
@ -1147,14 +1197,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
function isCronTitle(text) {
const value = String(text || '').trim()
return value.startsWith('定时任务') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
return value.startsWith('运维定时') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
}
function workspaceTitleFromCronFork(title) {
const value = String(title || '').trim()
if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value
if (value.startsWith('定时任务')) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
if (value.startsWith('运维定时')) {
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
return value
@ -1169,7 +1219,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时')
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}
@ -1411,7 +1461,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
tag.textContent = CSS
document.head.appendChild(tag)
return () => tag.remove()
}, 'dsh-cron-tasks: styles')
}, 'dsh-ops-cron: styles')
ctx.effect(() => {
try {
@ -1421,7 +1471,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh && offZh(); offEn && offEn() }
}
}, 'dsh-cron-tasks: locale')
}, 'dsh-ops-cron: locale')
const scope = ctx.settingsScope.bind({ namespace: NS })
const form = createForm(scope)
@ -1473,7 +1523,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
sessions.open(sessionId)
} catch (error) {
lastError = error
console.warn('[dsh-cron-tasks] open session failed', sessionId, error)
console.warn('[dsh-ops-cron] open session failed', sessionId, error)
try { await sessions.refresh?.() } catch { /* ignore */ }
await sleep(80)
continue
@ -1484,8 +1534,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if (sessions.list?.getSnapshot?.()?.current === sessionId) return true
}
}
if (lastError) console.warn('[dsh-cron-tasks] giving up open', sessionId, lastError)
else console.warn('[dsh-cron-tasks] open session timed out', sessionId)
if (lastError) console.warn('[dsh-ops-cron] giving up open', sessionId, lastError)
else console.warn('[dsh-ops-cron] open session timed out', sessionId)
return false
},
clearSession() {
@ -1519,7 +1569,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if (typeof off === 'function') offs.push(off)
} catch { /* optional */ }
return () => { for (const off of offs) off() }
}, 'dsh-cron-tasks: session faces')
}, 'dsh-ops-cron: session faces')
ctx.effect(() => {
const promoted = new Set()
@ -1543,12 +1593,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if (session && typeof session.rename === 'function') await session.rename(nextTitle)
}
} catch (error) {
console.warn('[dsh-cron-tasks] fork rename failed', row.id, error)
console.warn('[dsh-ops-cron] fork rename failed', row.id, error)
}
try {
await api(`/sessions/${encodeURIComponent(row.id)}/adopt`, { method: 'POST' })
} catch (error) {
console.warn('[dsh-cron-tasks] fork adopt failed', row.id, error)
console.warn('[dsh-ops-cron] fork adopt failed', row.id, error)
}
try { faces.leaveCronMode?.() } catch { /* ignore */ }
}
@ -1573,14 +1623,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
clearInterval(timer)
if (typeof unsub === 'function') unsub()
}
}, 'dsh-cron-tasks: fork promote')
}, 'dsh-ops-cron: fork promote')
ctx.effect(() => {
const lang = ctx.locale?.locale || ctx.locale?.current || 'zh'
const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en
const t = (key) => dict[key] || zh[key] || key
return installChrome(t, faces)
}, 'dsh-cron-tasks: chrome')
}, 'dsh-ops-cron: chrome')
}
exports.name = name

118
lib/delivery.js Normal file
View file

@ -0,0 +1,118 @@
/**
* Job delivery: DSH sidebar session vs proactive IM (botId + targetId).
*/
export function normalizeDelivery(input = {}) {
const src = input && typeof input === 'object' ? input : {}
const kind = String(src.kind || 'dsh').trim().toLowerCase()
if (kind === 'im') {
const botId = typeof src.botId === 'string' ? src.botId.trim() : ''
const targetId = typeof src.targetId === 'string' ? src.targetId.trim() : ''
if (!botId || !targetId) {
const error = new Error('im delivery requires botId and targetId')
error.code = 'INVALID_DELIVERY'
throw error
}
return { kind: 'im', botId, targetId }
}
return { kind: 'dsh' }
}
export function deliveryLine(delivery) {
if (!delivery || delivery.kind !== 'im') return 'delivery=dsh'
return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}`
}
/**
* Match a delivery target whose route overlaps the channel peer conversation.
* @param {Array<object>} targets
* @param {object|null} peer - from dshIm.resolveSessionPeer
*/
export function matchTargetForPeer(targets, peer) {
if (!peer || !Array.isArray(targets) || targets.length === 0) return null
const key = String(peer.conversationKey || '').trim()
const jidFromKey = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key
const peerJid = String(peer.jid || peer.route?.jid || jidFromKey || '').trim().toLowerCase()
if (!peerJid) return null
for (const target of targets) {
const route = target?.route && typeof target.route === 'object' ? target.route : {}
const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase()
if (jid && (jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid))) {
return target
}
}
return null
}
export function callerSessionId(exec) {
const session = exec?.agent?.session
return String(
session?.id
|| session?.sessionId
|| exec?.agent?.sessionId
|| exec?.sessionId
|| '',
).trim()
}
/**
* Resolve delivery for cron_create: explicit args win; else IM peer → matching target; else dsh.
* @param {object} args
* @param {object} exec
* @param {{ dshIm?: object }} deps
*/
export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) {
const explicit = typeof args.delivery === 'string' ? args.delivery.trim().toLowerCase() : ''
const botId = typeof args.im_bot_id === 'string' ? args.im_bot_id.trim()
: (typeof args.imBotId === 'string' ? args.imBotId.trim() : '')
const targetId = typeof args.im_target_id === 'string' ? args.im_target_id.trim()
: (typeof args.imTargetId === 'string' ? args.imTargetId.trim() : '')
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
if (explicit === 'im' || (botId && targetId)) {
return normalizeDelivery({ kind: 'im', botId, targetId })
}
const dshIm = deps.dshIm
const sessionId = callerSessionId(exec)
if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') {
try {
const peer = await dshIm.resolveSessionPeer(sessionId)
if (peer?.botId && typeof dshIm.listTargets === 'function') {
const targets = await dshIm.listTargets(peer.botId)
const match = matchTargetForPeer(targets, peer)
if (match?.targetId) {
return normalizeDelivery({
kind: 'im',
botId: peer.botId,
targetId: String(match.targetId),
})
}
}
} catch {
// fall through to dsh
}
}
return normalizeDelivery({ kind: 'dsh' })
}
const IM_MAX_CHARS = 3500
/**
* Send run summary to IM when job.delivery.kind === 'im'.
*/
export async function deliverRunToIm(job, summary, deps = {}) {
const delivery = job?.delivery
if (!delivery || delivery.kind !== 'im') return { sent: false, skipped: true }
const dshIm = deps.dshIm
if (!dshIm || typeof dshIm.send !== 'function') {
const error = new Error('ctx.dshIm unavailable — install dsh-im-ops for IM delivery')
error.code = 'IM_UNAVAILABLE'
throw error
}
const text = String(summary || '').trim() || `(定时任务「${job.name}」已完成,无文本摘要)`
const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text
const header = `【运维定时 · ${job.name}】\n`
await dshIm.send(delivery.botId, delivery.targetId, `${header}${clipped}`)
return { sent: true }
}

View file

@ -23,7 +23,7 @@ export function createRunRecord(job, decision, now, trigger) {
}
}
export const TITLE_PREFIX = '定时任务 · '
export const TITLE_PREFIX = '运维定时 · '
export function extractAssistantText(messages, maxChars = 4000) {
if (!Array.isArray(messages)) return ''
@ -55,6 +55,9 @@ export function publicJob(job) {
provider: job.provider || '',
model: job.model || '',
reasoningEffort: job.reasoningEffort || '',
delivery: job.delivery && job.delivery.kind === 'im'
? { kind: 'im', botId: job.delivery.botId, targetId: job.delivery.targetId }
: { kind: 'dsh' },
schedule: job.schedule,
createdAt: job.createdAt,
updatedAt: job.updatedAt,

View file

@ -8,6 +8,7 @@ import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
import { deliverRunToIm } from './delivery.js'
import { workspaceVisibleIds } from './isolation.js'
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
import {
@ -23,15 +24,15 @@ import {
upsertJob,
} from './store.js'
export const PLUGIN_NAME = 'dsh-cron-tasks'
export const API_PREFIX = '/dsh-cron-tasks'
export const PLUGIN_NAME = 'dsh-ops-cron'
export const API_PREFIX = '/dsh-ops-cron'
export function dshHome() {
return process.env.DSH_HOME || join(homedir(), '.dsh')
}
export function defaultCwd() {
return join(dshHome(), 'cron-tasks', 'workspace')
return join(dshHome(), 'ops-cron', 'workspace')
}
function json(res, status, body) {
@ -110,6 +111,8 @@ function runView(run) {
* @param {() => number} [options.now]
* @param {string} [options.filePath]
* @param {object} [options.sessionPort] { createAndPrompt, archiveSession, waitForTurn }
* @param {() => object|undefined} [options.getDshIm] soft-injected proactive IM API
* @param {{ warn?: Function, info?: Function }} [options.logger]
* @param {number} [options.tickIntervalMs]
*/
export function createHostService(options = {}) {
@ -117,6 +120,8 @@ export function createHostService(options = {}) {
const filePath = options.filePath || storePath(dshHome())
const store = options.store || createStore({ filePath })
const sessionPort = options.sessionPort || null
const getDshIm = typeof options.getDshIm === 'function' ? options.getDshIm : () => undefined
const logger = options.logger || console
const tickIntervalMs = Number(options.tickIntervalMs) > 0 ? Number(options.tickIntervalMs) : 15_000
let timer = null
let ticking = false
@ -129,6 +134,16 @@ export function createHostService(options = {}) {
return store.read()
}
async function maybeDeliverIm(job, run) {
if (!job || job.delivery?.kind !== 'im') return
try {
await deliverRunToIm(job, run?.summary, { dshIm: getDshIm() })
logger.info?.(`[dsh-ops-cron] im delivery sent for job ${job.id} → ${job.delivery.targetId}`)
} catch (error) {
logger.warn?.(`[dsh-ops-cron] im delivery failed for job ${job.id}: ${error instanceof Error ? error.message : error}`)
}
}
async function createJob(input) {
const t = now()
let created
@ -159,6 +174,7 @@ export function createHostService(options = {}) {
provider: patch.provider !== undefined ? patch.provider : job.provider,
model: patch.model !== undefined ? patch.model : job.model,
reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort,
delivery: patch.delivery !== undefined ? patch.delivery : job.delivery,
}
const record = createJobRecord(nextInput, current, t)
record.createdAt = job.createdAt
@ -228,9 +244,11 @@ export function createHostService(options = {}) {
const settled = await store.mutate((current) => settleRun(current, run.id, terminal, now()))
run = (settled.runs || []).find((row) => row.id === claimed.run.id)
const job = getJob(settled, jobId)
await maybeDeliverIm(job, run)
return { job: jobView(job), run: runView(run), decision: claimed.decision }
}
const job = getJob(executed, jobId)
await maybeDeliverIm(job, run)
return { job: jobView(job), run: runView(run), decision: claimed.decision }
}
@ -295,7 +313,7 @@ export function createHostService(options = {}) {
}
async function recover() {
await mkdir(join(dshHome(), 'cron-tasks'), { recursive: true })
await mkdir(join(dshHome(), 'ops-cron'), { recursive: true })
const t = now()
await withState((current) => {
let next = interruptActiveRuns(current, t)

7
lib/index.d.ts vendored
View file

@ -1,8 +1,8 @@
import type { Context } from '@deepseek-ai/cordis'
export const name: 'dsh-cron-tasks'
export const name: 'dsh-ops-cron'
export const inject: string[]
export const NS: 'dsh-cron-tasks'
export const NS: 'dsh-ops-cron'
export interface Config {
enabled?: boolean
@ -41,3 +41,6 @@ export function extractAssistantText(messages: object[], maxChars?: number): str
export const TITLE_PREFIX: string
export function claimOccurrence(state: object, jobId: string, now: number, trigger: string, policies?: object): object
export function executeClaimedRun(state: object, runId: string, deps: object): Promise<object>
export function normalizeDelivery(input?: object): { kind: 'dsh' | 'im', botId?: string, targetId?: string }
export function resolveCreateDelivery(args?: object, exec?: object, deps?: object): Promise<{ kind: 'dsh' | 'im', botId?: string, targetId?: string }>
export function deliverRunToIm(job: object, summary?: string, deps?: object): Promise<object>

View file

@ -1,8 +1,9 @@
/**
* dsh-cron-tasks, node half.
* dsh-ops-cron, node half.
*
* Durable scheduled jobs: create a fresh root Session on fire, hide that
* session from the workspace list, and keep history under 定时任务.
* session from the workspace list, and keep history under 运维定时.
* Jobs may deliver summaries to IM via soft-injected ctx.dshIm.
*
* Do not list @deepseek-ai/* in package.json — resolve schemastery from the
* live profile so we share the host's settings identity.
@ -48,6 +49,11 @@ export {
export { wrapScheduledPrompt } from './prompt.js'
export { normalizeJobModel } from './store.js'
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
export {
deliverRunToIm,
normalizeDelivery,
resolveCreateDelivery,
} from './delivery.js'
function loadPkg(id) {
const homes = [
@ -64,7 +70,7 @@ function loadPkg(id) {
errors.push(`${from}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`[dsh-cron-tasks] cannot resolve ${id}\n${errors.join('\n')}`)
throw new Error(`[dsh-ops-cron] cannot resolve ${id}\n${errors.join('\n')}`)
}
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
@ -124,18 +130,21 @@ function registerWebRoute(ctx, handler) {
export function apply(ctx, config = {}) {
const entry = resolveConfig(config)
const getDshIm = () => tryGet(ctx, 'dshIm')
const service = createHostService({
sessionPort: makeLiveSessionPort(ctx),
getDshIm,
logger: ctx.logger,
})
ctx.inject(['settings'], (sctx) => {
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
sctx.logger?.info?.(`[dsh-cron-tasks] settings namespace "${NS}" registered`)
sctx.logger?.info?.(`[dsh-ops-cron] settings namespace "${NS}" registered`)
})
ctx.inject(['tools'], (tctx) => {
registerCronTools(tctx, service)
tctx.logger?.info?.('[dsh-cron-tasks] tools cron_create/list/pause/resume/delete registered')
registerCronTools(tctx, service, { getDshIm })
tctx.logger?.info?.('[dsh-ops-cron] tools cron_create/list/pause/resume/delete registered')
})
ctx.inject(['skills'], (sctx) => {
@ -159,7 +168,7 @@ export function apply(ctx, config = {}) {
}
})
return () => off()
}, 'dsh-cron-tasks: settings watch')
}, 'dsh-ops-cron: settings watch')
ctx.effect(() => registerWebRoute(ctx, (req, res) => {
service.handleRequest(req, res).catch(() => {
@ -168,7 +177,7 @@ export function apply(ctx, config = {}) {
res.end(JSON.stringify({ ok: false, error: 'internal error' }))
}
})
}), 'dsh-cron-tasks: web route')
}), 'dsh-ops-cron: web route')
ctx.effect(() => {
let stopped = false
@ -181,13 +190,13 @@ export function apply(ctx, config = {}) {
return service.tick()
})
.catch((error) => {
ctx.logger?.warn?.(`[dsh-cron-tasks] recover failed: ${error instanceof Error ? error.message : error}`)
ctx.logger?.warn?.(`[dsh-ops-cron] recover failed: ${error instanceof Error ? error.message : error}`)
})
return () => {
stopped = true
service.stopTimer()
}
}, 'dsh-cron-tasks: scheduler')
}, 'dsh-ops-cron: scheduler')
ctx.logger?.info?.('[dsh-cron-tasks] host ready')
ctx.logger?.info?.('[dsh-ops-cron] host ready')
}

View file

@ -5,7 +5,7 @@
*/
export const HIDDEN_REASON = 'cron-run'
export const TITLE_PREFIX = '定时任务 · '
export const TITLE_PREFIX = '运维定时 · '
/**
* Strip the scheduler title prefix so a forked child can sit in the
@ -17,6 +17,11 @@ export function workspaceTitleFromCronFork(title) {
if (value.startsWith(TITLE_PREFIX)) {
return value.slice(TITLE_PREFIX.length).trim() || value
}
if (value.startsWith('运维定时')) {
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
// Legacy upstream prefix (dsh-cron-tasks).
if (value.startsWith('定时任务')) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
@ -55,7 +60,7 @@ export function shouldPromoteCronFork(row, cronSessionIds, previousId) {
const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时') || title.startsWith('定时任务')
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}

View file

@ -13,7 +13,7 @@ export function wrapScheduledPrompt(prompt, meta = {}) {
const runId = meta.runId ? String(meta.runId) : ''
const scheduledAt = meta.scheduledAt != null ? String(meta.scheduledAt) : ''
const lines = [
'This message is a scheduled task dispatched by the dsh-cron-tasks plugin.',
'This message is a scheduled task dispatched by the dsh-ops-cron plugin.',
'It is not a human message typed in this conversation.',
jobName || jobId ? `Job: ${jobName}${jobId ? ` (${jobId})` : ''}` : null,
runId ? `Run: ${runId}` : null,
@ -27,7 +27,7 @@ export function wrapScheduledPrompt(prompt, meta = {}) {
return lines.filter((line) => line !== null).join('\n')
}
export function scheduledPromptSource(pluginName = 'dsh-cron-tasks') {
export function scheduledPromptSource(pluginName = 'dsh-ops-cron') {
return {
kind: 'plugin',
plugin: pluginName,

View file

@ -8,6 +8,7 @@ import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { applyRunIsolation, recordHiddenSession } from './isolation.js'
import { nextFire, validateSchedule } from './scheduler.js'
import { normalizeDelivery } from './delivery.js'
export const STORE_VERSION = 1
@ -92,6 +93,7 @@ export function createJobRecord(input, state, now) {
const schedule = validateSchedule(input.schedule, settings.timezone)
const timeoutMinutes = Number(input.timeoutMinutes)
const model = normalizeJobModel(input)
const delivery = normalizeDelivery(input.delivery)
const job = {
id: String(input.id || newId()),
name,
@ -104,6 +106,7 @@ export function createJobRecord(input, state, now) {
provider: model.provider,
model: model.model,
reasoningEffort: model.reasoningEffort,
delivery,
schedule: schedule.kind === 'cron'
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }
: { kind: 'at', at: new Date(schedule.at).toISOString(), timezone: schedule.timezone },
@ -253,5 +256,5 @@ function hydrate(parsed) {
}
export function storePath(dshHome) {
return join(dshHome, 'cron-tasks', 'store.json')
return join(dshHome, 'ops-cron', 'store.json')
}

View file

@ -4,6 +4,7 @@
*/
import { formatInZone, resolveTodayAt } from './scheduler.js'
import { deliveryLine, resolveCreateDelivery } from './delivery.js'
const JOB_SCHEMA = {
type: 'object',
@ -18,6 +19,7 @@ const JOB_SCHEMA = {
provider: { type: 'string' },
model: { type: 'string' },
reasoningEffort: { type: 'string' },
delivery: { type: 'object', additionalProperties: true },
schedule: { type: 'object', additionalProperties: true },
createdAt: { type: 'number' },
updatedAt: { type: 'number' },
@ -93,7 +95,8 @@ function jobLine(job) {
? `at ${job.schedule.at}`
: (job.schedule?.expr || 'cron')
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model'
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} next=${when} id=${job.id}`
const delivery = deliveryLine(job.delivery)
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} ${delivery} next=${when} id=${job.id}`
}
export function callerWorkingDirectory(exec) {
@ -137,16 +140,17 @@ function requireId(args) {
return id
}
export function cronToolDefinitions(service) {
export function cronToolDefinitions(service, deps = {}) {
const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined
return [
{
name: 'cron_create',
description: 'Create a DSH 定时任务 (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Scheduled runs consume that model\'s quota, not whatever the user happens to have selected later.',
description: 'Create a DSH 运维定时 job (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Delivery: omit for auto (WhatsApp/IM session → im if a matching 投递目标 exists; else dsh). Or pass delivery=dsh|im with im_bot_id+im_target_id from IM 投递设置.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', description: 'Short display name in the 定时任务 list.' },
name: { type: 'string', description: 'Short display name in the 运维定时 list.' },
prompt: { type: 'string', description: 'Self-contained instructions for the new session when the job fires.' },
expr: { type: 'string', description: '5-field cron in the given timezone: "minute hour day-of-month month day-of-week". Recurring jobs only. 22:55 every day = "55 22 * * *". Do not send expr together with after_minutes/hour/at — extras are ignored.' },
cron: { type: 'string', description: 'Alias of expr.' },
@ -163,6 +167,9 @@ export function cronToolDefinitions(service) {
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session.' },
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im.' },
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im.' },
},
required: ['name', 'prompt'],
},
@ -183,14 +190,16 @@ export function cronToolDefinitions(service) {
const model = job?.provider && job?.model
? ` model=${job.provider}/${job.model}`
: ' model=(new-session default at fire time)'
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
const delivery = ` ${deliveryLine(job?.delivery)}`
return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`)
},
},
presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }),
presentCall: (args) => ({ card: 'generic', title: '创建运维定时', content: String(args?.name || '') }),
async execute(args, exec) {
aborted(exec)
const timeout = Number(args.timeout_minutes)
try {
const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() })
const job = await service.createJob({
name: args.name,
prompt: args.prompt,
@ -199,6 +208,7 @@ export function cronToolDefinitions(service) {
...resolveCreateModel(args, exec),
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
enabled: args.enabled !== false,
delivery,
})
return { job }
} catch (error) {
@ -215,7 +225,7 @@ export function cronToolDefinitions(service) {
},
{
name: 'cron_list',
description: 'List DSH 定时任务 (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
description: 'List DSH 运维定时 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
parameters: {
type: 'object',
additionalProperties: false,
@ -238,7 +248,7 @@ export function cronToolDefinitions(service) {
return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.')
},
},
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
presentCall: () => ({ card: 'generic', title: '列出运维定时' }),
async execute(args, exec) {
aborted(exec)
let jobs = await service.listJobs()
@ -248,7 +258,7 @@ export function cronToolDefinitions(service) {
},
{
name: 'cron_pause',
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 定时任务 but will not fire until resumed.',
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 运维定时 but will not fire until resumed.',
parameters: {
type: 'object',
additionalProperties: false,
@ -259,7 +269,7 @@ export function cronToolDefinitions(service) {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`),
},
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
presentCall: (args) => ({ card: 'generic', title: '暂停运维定时', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const job = await service.pauseJob(requireId(args), false)
@ -279,7 +289,7 @@ export function cronToolDefinitions(service) {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`),
},
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
presentCall: (args) => ({ card: 'generic', title: '恢复运维定时', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const job = await service.pauseJob(requireId(args), true)
@ -307,7 +317,7 @@ export function cronToolDefinitions(service) {
},
render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`),
},
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
presentCall: (args) => ({ card: 'generic', title: '删除运维定时', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const id = requireId(args)
@ -326,16 +336,17 @@ export function cronToolDefinitions(service) {
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
const now = formatInZone(nowMs, timeZone)
return [
'This deployment has a DSH sidebar feature named 定时任务 (scheduled tasks).',
'Jobs live in that list and fire a fresh session. They are NOT OS crontab and NOT in-chat schedule_create reminders.',
'This deployment has a DSH sidebar feature named 运维定时 (ops scheduled tasks / dsh-ops-cron).',
'Jobs live in that list and fire a fresh session (and optionally deliver a summary to WhatsApp/IM). They are NOT OS crontab and NOT in-chat schedule_create reminders.',
`Current local time for scheduling: ${now}. Use this calendar date for "today"/"tonight". Never invent another month or day.`,
'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).',
'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.',
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd. Do not invent ~/.dsh/cron-tasks/workspace unless they asked for an isolated folder.',
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota. Ask which model if the user cares about billing.',
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:',
'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd.',
'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.',
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im (requires a matching 投递目标 for this peer). On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id from IM 投递设置 → 复制调用参数.',
'When the user asks to look at, create, pause, resume, or delete 运维定时 / 定时任务 / scheduled tasks / cron jobs:',
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
'2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
'2. If they are not listed, search/unlock tools or skills with query "运维定时" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.',
'3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
].join('\n')
}
@ -345,15 +356,15 @@ export const CRON_GUIDANCE = cronGuidanceText()
export function makeCronSkill() {
return {
name: 'scheduled-tasks',
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。不要用系统 crontab,也不是会话内 reminder。',
whenToUse: 'User asks to list or create 定时任务, schedule something for tonight/today, pause a job, or mentions cron / scheduled tasks in DeepSeek Harness.',
description: '运维定时: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
whenToUse: 'User asks to list or create 运维定时 / 定时任务, schedule something for tonight/today, pause a job, or mentions cron / scheduled tasks in DeepSeek Harness.',
source: 'runtime',
provider: 'runtime',
content: `${cronGuidanceText()}
Tools:
- cron_list — list jobs
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model.
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery auto from session, or delivery=im + im_bot_id + im_target_id.
- cron_pause / cron_resume / cron_delete — by id from cron_list
`,
}
@ -361,10 +372,10 @@ Tools:
export const CRON_SKILL = makeCronSkill()
export function registerCronTools(ctx, service) {
export function registerCronTools(ctx, service, deps = {}) {
const tools = ctx?.tools
if (!tools || typeof tools.register !== 'function') return () => {}
const offs = cronToolDefinitions(service).map((definition) => tools.register(definition))
const offs = cronToolDefinitions(service, deps).map((definition) => tools.register(definition))
return () => {
for (const off of offs) {
if (typeof off === 'function') off()

View file

@ -1,23 +1,25 @@
{
"name": "@dsh-external/dsh-cron-tasks",
"description": "Scheduled tasks for DeepSeek Harness: sidebar entry under 新会话, plugin settings, and run history kept out of the workspace list.",
"name": "dsh-ops-cron",
"description": "Ops fork of dsh-cron-tasks: Agent cron_* tools + sidebar 运维定时, with DSH or WhatsApp/IM delivery per job.",
"version": "0.1.0",
"private": true,
"private": false,
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/Whale-Zhang/dsh-cron-tasks.git"
"url": "git+https://github.com/hansjone/dsh-ops-cron.git"
},
"bugs": {
"url": "https://github.com/Whale-Zhang/dsh-cron-tasks/issues"
"url": "https://github.com/hansjone/dsh-ops-cron/issues"
},
"homepage": "https://github.com/Whale-Zhang/dsh-cron-tasks#readme",
"homepage": "https://github.com/hansjone/dsh-ops-cron#readme",
"keywords": [
"dsh-plugin",
"dsh",
"deepseek-harness",
"cron",
"scheduled-tasks"
"scheduled-tasks",
"ops",
"whatsapp"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
@ -35,6 +37,7 @@
"cordis.patch.yml",
"docs",
"README.md",
"FORK.md",
"LICENSE"
],
"scripts": {

68
test/delivery.test.js Normal file
View file

@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
matchTargetForPeer,
normalizeDelivery,
resolveCreateDelivery,
deliverRunToIm,
} from '../lib/delivery.js'
test('normalizeDelivery defaults to dsh', () => {
assert.deepEqual(normalizeDelivery({}), { kind: 'dsh' })
})
test('normalizeDelivery requires botId+targetId for im', () => {
assert.throws(() => normalizeDelivery({ kind: 'im', botId: 'b' }), /im delivery/)
assert.deepEqual(
normalizeDelivery({ kind: 'im', botId: 'bot_1', targetId: 'alerts' }),
{ kind: 'im', botId: 'bot_1', targetId: 'alerts' },
)
})
test('matchTargetForPeer matches whatsapp jid from conversationKey', () => {
const peer = { botId: 'bot_1', conversationKey: 'direct:8613800000000@s.whatsapp.net' }
const targets = [
{ targetId: 'other', route: { jid: '1@s.whatsapp.net' } },
{ targetId: 'alerts', route: { jid: '8613800000000@s.whatsapp.net' } },
]
assert.equal(matchTargetForPeer(targets, peer).targetId, 'alerts')
})
test('resolveCreateDelivery uses explicit im args', async () => {
const delivery = await resolveCreateDelivery({
delivery: 'im',
im_bot_id: 'bot_x',
im_target_id: 'tgt_y',
}, {})
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_x', targetId: 'tgt_y' })
})
test('resolveCreateDelivery auto-matches peer target', async () => {
const dshIm = {
resolveSessionPeer: async () => ({
botId: 'bot_1',
conversationKey: 'direct:86138@s.whatsapp.net',
}),
listTargets: async () => ([
{ targetId: 'wa-dm', route: { jid: '86138@s.whatsapp.net' } },
]),
}
const delivery = await resolveCreateDelivery({}, {
agent: { session: { id: 'sess-1' } },
}, { dshIm })
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_1', targetId: 'wa-dm' })
})
test('deliverRunToIm sends via dshIm', async () => {
const sent = []
await deliverRunToIm(
{ name: 'ping', delivery: { kind: 'im', botId: 'b', targetId: 't' } },
'hello world',
{ dshIm: { send: async (...args) => { sent.push(args) } } },
)
assert.equal(sent.length, 1)
assert.equal(sent[0][0], 'b')
assert.equal(sent[0][1], 't')
assert.match(sent[0][2], /运维定时/)
assert.match(sent[0][2], /hello world/)
})

View file

@ -89,7 +89,7 @@ async function jsonRequest(base, path, options = {}) {
}
test('host service: create, list, run-now, history, and workspace isolation', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
@ -163,7 +163,7 @@ test('host service: create, list, run-now, history, and workspace isolation', as
})
test('overlap skip writes a skipped history row instead of a second session', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
let clock = Date.parse('2026-08-24T01:00:00.000Z')
let inflight = 0
@ -192,7 +192,7 @@ test('overlap skip writes a skipped history row instead of a second session', as
})
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const service = createHostService({
@ -210,7 +210,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
const http = await listen(service)
t.after(() => http.close())
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
body: JSON.stringify({
name: 'http job',
@ -222,23 +222,23 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
assert.equal(created.body.job.name, 'http job')
const jobId = created.body.job.id
const listed = await jsonRequest(http.url, '/dsh-cron-tasks/jobs')
const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs')
assert.equal(listed.body.jobs.length, 1)
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${jobId}/run`, { method: 'POST' })
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, { method: 'POST' })
assert.equal(ran.status, 200)
assert.equal(ran.body.run.sessionId, 'http-sess-1')
assert.ok(['succeeded', 'running'].includes(ran.body.run.status))
assert.deepEqual(archived, ['http-sess-1'])
const history = await jsonRequest(http.url, '/dsh-cron-tasks/history')
const history = await jsonRequest(http.url, '/dsh-ops-cron/history')
assert.ok(history.body.runs.some((row) => row.sessionId === 'http-sess-1'))
const visible = await jsonRequest(http.url, '/dsh-cron-tasks/workspace-visible?id=http-sess-1&id=other')
const visible = await jsonRequest(http.url, '/dsh-ops-cron/workspace-visible?id=http-sess-1&id=other')
assert.deepEqual(visible.body.visible, ['other'])
})
test('POST /runs/:id/open reveals the run session id', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const revealed = []
const service = createHostService({
@ -253,7 +253,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
body: JSON.stringify({
name: 'open-job',
@ -261,8 +261,8 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
}),
})
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
const opened = await jsonRequest(http.url, `/dsh-cron-tasks/runs/${ran.body.run.id}/open`, { method: 'POST' })
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' })
const opened = await jsonRequest(http.url, `/dsh-ops-cron/runs/${ran.body.run.id}/open`, { method: 'POST' })
assert.equal(opened.status, 200)
assert.equal(opened.body.sessionId, 'open-sess')
assert.deepEqual(revealed, ['open-sess'])
@ -330,7 +330,7 @@ test('resolveDefaultModel fails loud when Models has no selection', async () =>
})
test('POST /conceal archives every run session id', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const service = createHostService({
@ -345,7 +345,7 @@ test('POST /conceal archives every run session id', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
body: JSON.stringify({
name: 'hide-job',
@ -353,15 +353,15 @@ test('POST /conceal archives every run session id', async (t) => {
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
}),
})
await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' })
archived.length = 0
const hidden = await jsonRequest(http.url, '/dsh-cron-tasks/conceal', { method: 'POST' })
const hidden = await jsonRequest(http.url, '/dsh-ops-cron/conceal', { method: 'POST' })
assert.equal(hidden.status, 200)
assert.ok(archived.includes('hide-sess'))
})
test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const at = Date.parse('2026-08-23T14:57:00.000Z')
let clock = at - 60_000
@ -508,7 +508,7 @@ test('listModelChoices maps llm providers and the current default', async () =>
})
test('GET /models lists session-port catalog', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
@ -520,7 +520,7 @@ test('GET /models lists session-port catalog', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/models')
const res = await jsonRequest(http.url, '/dsh-ops-cron/models')
assert.equal(res.status, 200)
assert.equal(res.body.current.model, 'deepseek-chat')
assert.equal(res.body.groups[0].models[0].id, 'deepseek-chat')
@ -547,7 +547,7 @@ test('listWorkspaceChoices maps registry entries to title and path', () => {
})
test('GET /workspaces lists session-port choices', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
@ -559,13 +559,13 @@ test('GET /workspaces lists session-port choices', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/workspaces')
const res = await jsonRequest(http.url, '/dsh-ops-cron/workspaces')
assert.equal(res.status, 200)
assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }])
})
test('POST /sessions/:id/adopt uses the session port', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const adopted = []
const service = createHostService({
@ -579,7 +579,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/sessions/child-9/adopt', { method: 'POST' })
const res = await jsonRequest(http.url, '/dsh-ops-cron/sessions/child-9/adopt', { method: 'POST' })
assert.equal(res.status, 200)
assert.equal(res.body.attached, true)
assert.deepEqual(adopted, ['child-9'])
@ -598,7 +598,7 @@ test('waitForAgentTurn waits for running then idle, and fails if the agent never
})
test('after one live-shaped dispatch, the next run-now still fires', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
@ -632,7 +632,7 @@ test('after one live-shaped dispatch, the next run-now still fires', async (t) =
})
test('after a live-shaped due tick settles, the next due occurrence still fires', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
let clock = Date.parse('2026-08-24T01:00:00.000Z')

View file

@ -8,18 +8,19 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('installable bundle declares host apply, client half, unique id, and no @deepseek-ai runtime deps', async () => {
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
assert.equal(pkg.name, '@dsh-external/dsh-cron-tasks')
assert.equal(pkg.name, 'dsh-ops-cron')
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
assert.equal(pkg.dsh.client.platform, 'web')
assert.ok(pkg.exports['./client'])
assert.equal(pkg.main, 'lib/index.js')
assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/')))
const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
assert.match(patch, /id:\s*dsh-cron-tasks/)
assert.match(patch, /id:\s*dsh-ops-cron/)
const index = await readFile(join(root, 'lib/index.js'), 'utf8')
assert.match(index, /export function apply\(/)
assert.match(index, /registerCronTools/)
assert.match(index, /inject\(\['tools'\]/)
assert.match(index, /getDshIm/)
const tools = await readFile(join(root, 'lib/tools.js'), 'utf8')
assert.match(tools, /name: 'cron_create'/)
assert.match(tools, /name: 'cron_list'/)
@ -28,9 +29,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.match(tools, /hour/)
assert.match(tools, /Current local time/)
assert.match(tools, /source: 'runtime'/)
assert.match(tools, /current session cwd/)
assert.match(tools, /resolveCreateDelivery/)
assert.match(tools, /im_bot_id/)
assert.match(tools, /resolveCreateCwd/)
assert.match(tools, /Working directory: if the user is chatting in a workspace/)
assert.match(tools, /after_minutes/)
assert.match(tools, /Extra schedule fields are ignored/)
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/)
@ -40,80 +41,26 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//)
const host = await readFile(join(root, 'lib/host.js'), 'utf8')
assert.match(host, /agentDefaultModel/)
assert.match(host, /system-prompt\/assemble/)
assert.match(host, /agentOptions/)
assert.match(host, /nextRunAt === null/)
assert.match(host, /deliverRunToIm/)
assert.match(host, /PLUGIN_NAME = 'dsh-ops-cron'/)
assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/)
assert.match(host, /resolveSessionPlacement/)
assert.match(host, /adoptSessionIntoWorkspace/)
assert.match(host, /attachLiveSessionToWorkspace/)
assert.match(host, /listWorkspaceChoices/)
assert.match(host, /\/workspaces/)
assert.match(host, /listModelChoices/)
assert.match(host, /resolveJobModel/)
assert.match(host, /\/models/)
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
assert.match(client, /定时任务/)
assert.match(client, /运维定时/)
assert.match(client, /dsh-ops-cron/)
assert.match(client, /settings\.plugin\.item/)
assert.match(client, /新建会话/)
assert.match(client, /logoRow/)
assert.match(client, /dsh-ct-entry/)
assert.match(client, /centerCol/)
assert.match(client, /data-dsh-ct-host/)
assert.match(client, /_collapsed"\]\[data-dsh-ct-mode=on\] \.dsh-ct-region/)
assert.match(client, /listRoot\.parentElement !== region\) region\.appendChild\(listRoot\)/)
assert.match(client, /jobStamp/)
assert.doesNotMatch(client, /\[data-dsh-ct-mode=on\] \.dsh-ct-region\{display:flex !important;animation:/)
assert.match(client, /visibility:hidden/)
assert.match(client, /createPortal\(editor, paneHost\)/)
assert.match(client, /sidebarCollapsed/)
assert.match(client, /_collapsed/)
assert.match(client, /width:36px/)
assert.match(client, /data-collapsed/)
assert.match(client, /translate\(-50%, -50%\)/)
assert.match(client, /返回工作区/)
assert.match(client, /runs\/\$\{run\.id\}\/open/)
assert.match(client, /regionArea/)
assert.match(client, /sessions\.open\(/)
assert.match(client, /getService\(ctx, faces, 'sessions'\)/)
assert.match(client, /workspaceSkin/)
assert.match(client, /IconFolderOpen16/)
assert.match(client, /IconProjectAddOutline16/)
assert.match(client, /IconPlayOutline16/)
assert.match(client, /IconPauseOutline16/)
assert.match(client, /IconSearchOutline16/)
assert.match(client, /searchPlaceholder/)
assert.match(client, /\/conceal/)
assert.match(client, /shouldPromoteCronFork/)
assert.match(client, /workspaceTitleFromCronFork/)
assert.match(client, /leaveCronMode/)
assert.match(client, /fork rename failed/)
assert.match(client, /sessions\/\$\{encodeURIComponent\(row\.id\)\}\/adopt/)
assert.match(client, /sessions\.length > 0 && visible\.length === 0/)
assert.match(client, /未分组|未分类/)
assert.match(client, /expanded\[job\.id\] === true/)
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
assert.doesNotMatch(client, /inject = \[[^\]]*sessions[^\]]*\]/)
assert.match(client, /工作目录/)
assert.match(client, /cwdRecent/)
assert.match(client, /cwdCustom/)
assert.match(client, /deliveryKind/)
assert.match(client, /imBotId/)
assert.match(client, /imTargetId/)
assert.match(client, /CwdField/)
assert.match(client, /ModelField/)
assert.match(client, /api\('\/models'\)/)
assert.match(tools, /resolveCreateModel/)
assert.match(tools, /snapshots the current session model/)
assert.match(client, /api\('\/workspaces'\)/)
assert.match(client, /atToLocalInput/)
assert.match(client, /localInputToAt/)
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')
assert.match(delivery, /resolveCreateDelivery/)
assert.match(delivery, /deliverRunToIm/)
const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8')
assert.match(prd, /左侧侧边栏「新会话」按钮正下方的「定时任务」入口/)
assert.match(prd, /设置页「插件配置」里的定时任务设置/)
assert.match(prd, /定时任务历史/)
assert.match(prd, /chicheng-cron/)
assert.match(prd, /dsh-automation/)
assert.match(prd, /cronjob-dsh-plugin/)
assert.match(prd, /dsh-cron/)
assert.match(prd, /dsh-schedule-tasks/)
assert.match(prd, /定时任务/)
assert.match(prd, /dsh-schedule/)
assert.match(prd, /非目标/)
})