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) See [FORK.md](FORK.md) for install notes. Do **not** install upstream `@dsh-external/dsh-cron-tasks` in the same profile.
![license](https://img.shields.io/badge/license-MIT-blue)
<a id="zhongwen"></a> ## Install
## 中文
到点后 **新开一条会话** 跑事先写好的提示词,记进侧栏「定时任务」历史。这类会话不会淹没日常工作区列表。
这不是官方同会话 reminder(`@deepseek-ai/dsh-schedule`),也不是系统 `crontab`。调度器住在 DSH Host 里:Host 没在跑就不会触发。
### 安装
需要已安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。一条命令装进网页端和桌面端:
```sh ```sh
dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks dsh plugin --profile web add -w "github:hansjone/dsh-im-ops"
dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
# restart dsh web
``` ```
装完后**重启对应 profile**(`dsh web` 或 Desktop)。用下面命令应能看到 `dsh-cron-tasks`: ## Delivery defaults
```sh | Created from | Default delivery |
dsh --profile web --dump-config |--------------|------------------|
``` | 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 ## License
dsh plugin --profile web remove @dsh-external/dsh-cron-tasks
dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks
```
### 做什么 MIT (upstream MIT retained).
- 左侧 **「新会话」正下方** 的 **「定时任务」** 入口:创建、暂停/恢复、删除、立即运行、搜索
- **设置 → 插件 → 插件配置** 里的调度策略(启用、默认时区、历史保留、重叠/漏跑)
- 每次触发新开 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

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: - insert:
- id: dsh-cron-tasks - id: dsh-ops-cron
name: '@dsh-external/dsh-cron-tasks' name: dsh-ops-cron

View file

@ -1,5 +1,5 @@
window.__ModuleLoader__.load({ window.__ModuleLoader__.load({
id: '@dsh-external/dsh-cron-tasks', id: 'dsh-ops-cron',
factory: (require) => { factory: (require) => {
const module = { exports: {} } const module = { exports: {} }
const exports = module.exports const exports = module.exports
@ -156,16 +156,16 @@ window.__ModuleLoader__.load({
return undefined return undefined
} }
const name = 'dsh-cron-tasks' const name = 'dsh-ops-cron'
const NS = 'dsh-cron-tasks' const NS = 'dsh-ops-cron'
const inject = ['slots', 'locale', 'settingsScope'] const inject = ['slots', 'locale', 'settingsScope']
const API = '/dsh-cron-tasks' const API = '/dsh-ops-cron'
const LOCALE_NS = 'settings.dshCronTasks' const LOCALE_NS = 'settings.dshCronTasks'
const TITLE_PREFIX = '定时任务 · ' const TITLE_PREFIX = '运维定时 · '
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } } const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
function jobStamp(rows) { 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) { 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 = { const zh = {
title: '定时任务', title: '运维定时',
description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。', description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。',
expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改', expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改',
saveFailed: '本部署没有接受这些值,已保留供你修改。', saveFailed: '本部署没有接受这些值,已保留供你修改。',
enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数', enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数',
overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过', overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过',
hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。', hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。',
entry: '定时任务', entryLabel: '打开定时任务', entry: '运维定时', entryLabel: '打开运维定时',
backToWorkspace: '返回工作区', backLabel: '返回工作区', backToWorkspace: '返回工作区', backLabel: '返回工作区',
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程', newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间', cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除', create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次', emptyJobs: '还没有运维定时。点右上角 + 新建。', next: '下次', last: '上次',
expandRuns: '展开记录', collapseRuns: '收起记录', expandRuns: '展开记录', collapseRuns: '收起记录',
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。', search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停', paused: '已暂停',
@ -313,19 +313,25 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。', cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
model: '模型', model: '模型',
modelDefault: '每次运行用当时的新会话默认', modelDefault: '每次运行用当时的新会话默认',
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。', modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
editorLead: '保存后按日程在新会话里执行。点击下方某次运行,右侧会打开那次对话,可以继续。', editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。', 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 = { const en = {
title: 'Scheduled tasks', title: 'Ops schedule',
description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.', description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.',
expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard', 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.', 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', enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention',
overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip', 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.', 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', backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace',
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule', newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time', 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', model: 'Model',
modelDefault: 'Use the New Session default at fire time', 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.', 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.', 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) { 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, timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
provider: current?.provider || '', provider: current?.provider || '',
model: current?.model || '', 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, enabled: job.enabled !== false,
provider: job.provider || '', provider: job.provider || '',
model: job.model || '', 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) { function modelKey(provider, model) {
if (!provider || !model) return '' if (!provider || !model) return ''
return `${provider}::${model}` 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.projectText },
h('span', { className: skin.title }, paused h('span', { className: skin.title }, paused
? `${job.name} · ${t('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('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) }, h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'), 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(CwdField, { t, form, setForm, workspaces }),
h(ModelField, { t, form, setForm, catalog }), 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('div', { className: 'dsh-ct-editorActs' },
h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')), 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, !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, name: form.name, prompt: form.prompt, schedule,
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled, cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
provider: form.provider, model: form.model, 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) { if (selection.type === 'job' && selection.jobId) {
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) }) 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) { function isCronTitle(text) {
const value = String(text || '').trim() 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) { function workspaceTitleFromCronFork(title) {
const value = String(title || '').trim() const value = String(title || '').trim()
if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value
if (value.startsWith('定时任务')) { if (value.startsWith('运维定时')) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim() const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value return stripped || value
} }
return 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() const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '') 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 if (previousId && ids.has(String(previousId)) && looksCron) return true
return false 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 tag.textContent = CSS
document.head.appendChild(tag) document.head.appendChild(tag)
return () => tag.remove() return () => tag.remove()
}, 'dsh-cron-tasks: styles') }, 'dsh-ops-cron: styles')
ctx.effect(() => { ctx.effect(() => {
try { 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) const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh && offZh(); offEn && offEn() } return () => { offZh && offZh(); offEn && offEn() }
} }
}, 'dsh-cron-tasks: locale') }, 'dsh-ops-cron: locale')
const scope = ctx.settingsScope.bind({ namespace: NS }) const scope = ctx.settingsScope.bind({ namespace: NS })
const form = createForm(scope) 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) sessions.open(sessionId)
} catch (error) { } catch (error) {
lastError = 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 */ } try { await sessions.refresh?.() } catch { /* ignore */ }
await sleep(80) await sleep(80)
continue 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 (sessions.list?.getSnapshot?.()?.current === sessionId) return true
} }
} }
if (lastError) console.warn('[dsh-cron-tasks] giving up open', sessionId, lastError) if (lastError) console.warn('[dsh-ops-cron] giving up open', sessionId, lastError)
else console.warn('[dsh-cron-tasks] open session timed out', sessionId) else console.warn('[dsh-ops-cron] open session timed out', sessionId)
return false return false
}, },
clearSession() { 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) if (typeof off === 'function') offs.push(off)
} catch { /* optional */ } } catch { /* optional */ }
return () => { for (const off of offs) off() } return () => { for (const off of offs) off() }
}, 'dsh-cron-tasks: session faces') }, 'dsh-ops-cron: session faces')
ctx.effect(() => { ctx.effect(() => {
const promoted = new Set() 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) if (session && typeof session.rename === 'function') await session.rename(nextTitle)
} }
} catch (error) { } 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 { try {
await api(`/sessions/${encodeURIComponent(row.id)}/adopt`, { method: 'POST' }) await api(`/sessions/${encodeURIComponent(row.id)}/adopt`, { method: 'POST' })
} catch (error) { } 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 */ } 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) clearInterval(timer)
if (typeof unsub === 'function') unsub() if (typeof unsub === 'function') unsub()
} }
}, 'dsh-cron-tasks: fork promote') }, 'dsh-ops-cron: fork promote')
ctx.effect(() => { ctx.effect(() => {
const lang = ctx.locale?.locale || ctx.locale?.current || 'zh' const lang = ctx.locale?.locale || ctx.locale?.current || 'zh'
const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en
const t = (key) => dict[key] || zh[key] || key const t = (key) => dict[key] || zh[key] || key
return installChrome(t, faces) return installChrome(t, faces)
}, 'dsh-cron-tasks: chrome') }, 'dsh-ops-cron: chrome')
} }
exports.name = name 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) { export function extractAssistantText(messages, maxChars = 4000) {
if (!Array.isArray(messages)) return '' if (!Array.isArray(messages)) return ''
@ -55,6 +55,9 @@ export function publicJob(job) {
provider: job.provider || '', provider: job.provider || '',
model: job.model || '', model: job.model || '',
reasoningEffort: job.reasoningEffort || '', 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, schedule: job.schedule,
createdAt: job.createdAt, createdAt: job.createdAt,
updatedAt: job.updatedAt, updatedAt: job.updatedAt,

View file

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

7
lib/index.d.ts vendored
View file

@ -1,8 +1,8 @@
import type { Context } from '@deepseek-ai/cordis' 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 inject: string[]
export const NS: 'dsh-cron-tasks' export const NS: 'dsh-ops-cron'
export interface Config { export interface Config {
enabled?: boolean enabled?: boolean
@ -41,3 +41,6 @@ export function extractAssistantText(messages: object[], maxChars?: number): str
export const TITLE_PREFIX: string export const TITLE_PREFIX: string
export function claimOccurrence(state: object, jobId: string, now: number, trigger: string, policies?: object): object 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 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 * 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 * Do not list @deepseek-ai/* in package.json — resolve schemastery from the
* live profile so we share the host's settings identity. * live profile so we share the host's settings identity.
@ -48,6 +49,11 @@ export {
export { wrapScheduledPrompt } from './prompt.js' export { wrapScheduledPrompt } from './prompt.js'
export { normalizeJobModel } from './store.js' export { normalizeJobModel } from './store.js'
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js' export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
export {
deliverRunToIm,
normalizeDelivery,
resolveCreateDelivery,
} from './delivery.js'
function loadPkg(id) { function loadPkg(id) {
const homes = [ const homes = [
@ -64,7 +70,7 @@ function loadPkg(id) {
errors.push(`${from}: ${error instanceof Error ? error.message : String(error)}`) 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') const SchemaMod = loadPkg('@deepseek-ai/schemastery')
@ -124,18 +130,21 @@ function registerWebRoute(ctx, handler) {
export function apply(ctx, config = {}) { export function apply(ctx, config = {}) {
const entry = resolveConfig(config) const entry = resolveConfig(config)
const getDshIm = () => tryGet(ctx, 'dshIm')
const service = createHostService({ const service = createHostService({
sessionPort: makeLiveSessionPort(ctx), sessionPort: makeLiveSessionPort(ctx),
getDshIm,
logger: ctx.logger,
}) })
ctx.inject(['settings'], (sctx) => { ctx.inject(['settings'], (sctx) => {
sctx.settings.register(NS, Config, { base: entry, applies: 'live' }) 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) => { ctx.inject(['tools'], (tctx) => {
registerCronTools(tctx, service) registerCronTools(tctx, service, { getDshIm })
tctx.logger?.info?.('[dsh-cron-tasks] tools cron_create/list/pause/resume/delete registered') tctx.logger?.info?.('[dsh-ops-cron] tools cron_create/list/pause/resume/delete registered')
}) })
ctx.inject(['skills'], (sctx) => { ctx.inject(['skills'], (sctx) => {
@ -159,7 +168,7 @@ export function apply(ctx, config = {}) {
} }
}) })
return () => off() return () => off()
}, 'dsh-cron-tasks: settings watch') }, 'dsh-ops-cron: settings watch')
ctx.effect(() => registerWebRoute(ctx, (req, res) => { ctx.effect(() => registerWebRoute(ctx, (req, res) => {
service.handleRequest(req, res).catch(() => { service.handleRequest(req, res).catch(() => {
@ -168,7 +177,7 @@ export function apply(ctx, config = {}) {
res.end(JSON.stringify({ ok: false, error: 'internal error' })) res.end(JSON.stringify({ ok: false, error: 'internal error' }))
} }
}) })
}), 'dsh-cron-tasks: web route') }), 'dsh-ops-cron: web route')
ctx.effect(() => { ctx.effect(() => {
let stopped = false let stopped = false
@ -181,13 +190,13 @@ export function apply(ctx, config = {}) {
return service.tick() return service.tick()
}) })
.catch((error) => { .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 () => { return () => {
stopped = true stopped = true
service.stopTimer() 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 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 * 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)) { if (value.startsWith(TITLE_PREFIX)) {
return value.slice(TITLE_PREFIX.length).trim() || value 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('定时任务')) { if (value.startsWith('定时任务')) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim() const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
return stripped || value return stripped || value
@ -55,7 +60,7 @@ export function shouldPromoteCronFork(row, cronSessionIds, previousId) {
const parent = String(row.parentId || row.parentSessionId || '').trim() const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '') 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 if (previousId && ids.has(String(previousId)) && looksCron) return true
return false return false
} }

View file

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

View file

@ -8,6 +8,7 @@ import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { applyRunIsolation, recordHiddenSession } from './isolation.js' import { applyRunIsolation, recordHiddenSession } from './isolation.js'
import { nextFire, validateSchedule } from './scheduler.js' import { nextFire, validateSchedule } from './scheduler.js'
import { normalizeDelivery } from './delivery.js'
export const STORE_VERSION = 1 export const STORE_VERSION = 1
@ -92,6 +93,7 @@ export function createJobRecord(input, state, now) {
const schedule = validateSchedule(input.schedule, settings.timezone) const schedule = validateSchedule(input.schedule, settings.timezone)
const timeoutMinutes = Number(input.timeoutMinutes) const timeoutMinutes = Number(input.timeoutMinutes)
const model = normalizeJobModel(input) const model = normalizeJobModel(input)
const delivery = normalizeDelivery(input.delivery)
const job = { const job = {
id: String(input.id || newId()), id: String(input.id || newId()),
name, name,
@ -104,6 +106,7 @@ export function createJobRecord(input, state, now) {
provider: model.provider, provider: model.provider,
model: model.model, model: model.model,
reasoningEffort: model.reasoningEffort, reasoningEffort: model.reasoningEffort,
delivery,
schedule: schedule.kind === 'cron' schedule: schedule.kind === 'cron'
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone } ? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }
: { kind: 'at', at: new Date(schedule.at).toISOString(), 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) { 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 { formatInZone, resolveTodayAt } from './scheduler.js'
import { deliveryLine, resolveCreateDelivery } from './delivery.js'
const JOB_SCHEMA = { const JOB_SCHEMA = {
type: 'object', type: 'object',
@ -18,6 +19,7 @@ const JOB_SCHEMA = {
provider: { type: 'string' }, provider: { type: 'string' },
model: { type: 'string' }, model: { type: 'string' },
reasoningEffort: { type: 'string' }, reasoningEffort: { type: 'string' },
delivery: { type: 'object', additionalProperties: true },
schedule: { type: 'object', additionalProperties: true }, schedule: { type: 'object', additionalProperties: true },
createdAt: { type: 'number' }, createdAt: { type: 'number' },
updatedAt: { type: 'number' }, updatedAt: { type: 'number' },
@ -93,7 +95,8 @@ function jobLine(job) {
? `at ${job.schedule.at}` ? `at ${job.schedule.at}`
: (job.schedule?.expr || 'cron') : (job.schedule?.expr || 'cron')
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model' 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) { export function callerWorkingDirectory(exec) {
@ -137,16 +140,17 @@ function requireId(args) {
return id return id
} }
export function cronToolDefinitions(service) { export function cronToolDefinitions(service, deps = {}) {
const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined
return [ return [
{ {
name: 'cron_create', 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: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { 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.' }, 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.' }, 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.' }, 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.' }, reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' }, timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' }, 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'], required: ['name', 'prompt'],
}, },
@ -183,14 +190,16 @@ export function cronToolDefinitions(service) {
const model = job?.provider && job?.model const model = job?.provider && job?.model
? ` model=${job.provider}/${job.model}` ? ` model=${job.provider}/${job.model}`
: ' model=(new-session default at fire time)' : ' 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) { async execute(args, exec) {
aborted(exec) aborted(exec)
const timeout = Number(args.timeout_minutes) const timeout = Number(args.timeout_minutes)
try { try {
const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() })
const job = await service.createJob({ const job = await service.createJob({
name: args.name, name: args.name,
prompt: args.prompt, prompt: args.prompt,
@ -199,6 +208,7 @@ export function cronToolDefinitions(service) {
...resolveCreateModel(args, exec), ...resolveCreateModel(args, exec),
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined, timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
enabled: args.enabled !== false, enabled: args.enabled !== false,
delivery,
}) })
return { job } return { job }
} catch (error) { } catch (error) {
@ -215,7 +225,7 @@ export function cronToolDefinitions(service) {
}, },
{ {
name: 'cron_list', 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: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, 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.') 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) { async execute(args, exec) {
aborted(exec) aborted(exec)
let jobs = await service.listJobs() let jobs = await service.listJobs()
@ -248,7 +258,7 @@ export function cronToolDefinitions(service) {
}, },
{ {
name: 'cron_pause', 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: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@ -259,7 +269,7 @@ export function cronToolDefinitions(service) {
schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } }, schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`), 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) { async execute(args, exec) {
aborted(exec) aborted(exec)
const job = await service.pauseJob(requireId(args), false) 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 } }, schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } },
render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`), 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) { async execute(args, exec) {
aborted(exec) aborted(exec)
const job = await service.pauseJob(requireId(args), true) 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.`), 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) { async execute(args, exec) {
aborted(exec) aborted(exec)
const id = requireId(args) const id = requireId(args)
@ -326,16 +336,17 @@ export function cronToolDefinitions(service) {
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') { export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
const now = formatInZone(nowMs, timeZone) const now = formatInZone(nowMs, timeZone)
return [ return [
'This deployment has a DSH sidebar feature named 定时任务 (scheduled tasks).', 'This deployment has a DSH sidebar feature named 运维定时 (ops scheduled tasks / dsh-ops-cron).',
'Jobs live in that list and fire a fresh session. They are NOT OS crontab and NOT in-chat schedule_create reminders.', '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.`, `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 "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.', '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.', '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. Ask which model if the user cares about billing.', 'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota.',
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:', '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.', '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.', '3. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
].join('\n') ].join('\n')
} }
@ -345,15 +356,15 @@ export const CRON_GUIDANCE = cronGuidanceText()
export function makeCronSkill() { export function makeCronSkill() {
return { return {
name: 'scheduled-tasks', name: 'scheduled-tasks',
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。不要用系统 crontab,也不是会话内 reminder。', 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.', 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', source: 'runtime',
provider: 'runtime', provider: 'runtime',
content: `${cronGuidanceText()} content: `${cronGuidanceText()}
Tools: Tools:
- cron_list — list jobs - 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 - cron_pause / cron_resume / cron_delete — by id from cron_list
`, `,
} }
@ -361,10 +372,10 @@ Tools:
export const CRON_SKILL = makeCronSkill() export const CRON_SKILL = makeCronSkill()
export function registerCronTools(ctx, service) { export function registerCronTools(ctx, service, deps = {}) {
const tools = ctx?.tools const tools = ctx?.tools
if (!tools || typeof tools.register !== 'function') return () => {} 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 () => { return () => {
for (const off of offs) { for (const off of offs) {
if (typeof off === 'function') off() if (typeof off === 'function') off()

View file

@ -1,23 +1,25 @@
{ {
"name": "@dsh-external/dsh-cron-tasks", "name": "dsh-ops-cron",
"description": "Scheduled tasks for DeepSeek Harness: sidebar entry under 新会话, plugin settings, and run history kept out of the workspace list.", "description": "Ops fork of dsh-cron-tasks: Agent cron_* tools + sidebar 运维定时, with DSH or WhatsApp/IM delivery per job.",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": false,
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/Whale-Zhang/dsh-cron-tasks.git" "url": "git+https://github.com/hansjone/dsh-ops-cron.git"
}, },
"bugs": { "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": [ "keywords": [
"dsh-plugin", "dsh-plugin",
"dsh", "dsh",
"deepseek-harness", "deepseek-harness",
"cron", "cron",
"scheduled-tasks" "scheduled-tasks",
"ops",
"whatsapp"
], ],
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",
@ -35,6 +37,7 @@
"cordis.patch.yml", "cordis.patch.yml",
"docs", "docs",
"README.md", "README.md",
"FORK.md",
"LICENSE" "LICENSE"
], ],
"scripts": { "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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const archived = [] 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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
let clock = Date.parse('2026-08-24T01:00:00.000Z') let clock = Date.parse('2026-08-24T01:00:00.000Z')
let inflight = 0 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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const archived = [] const archived = []
const service = createHostService({ const service = createHostService({
@ -210,7 +210,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
const http = await listen(service) const http = await listen(service)
t.after(() => http.close()) 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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: 'http job', 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') assert.equal(created.body.job.name, 'http job')
const jobId = created.body.job.id 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) 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.status, 200)
assert.equal(ran.body.run.sessionId, 'http-sess-1') assert.equal(ran.body.run.sessionId, 'http-sess-1')
assert.ok(['succeeded', 'running'].includes(ran.body.run.status)) assert.ok(['succeeded', 'running'].includes(ran.body.run.status))
assert.deepEqual(archived, ['http-sess-1']) 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')) 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']) assert.deepEqual(visible.body.visible, ['other'])
}) })
test('POST /runs/:id/open reveals the run session id', async (t) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const revealed = [] const revealed = []
const service = createHostService({ const service = createHostService({
@ -253,7 +253,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
}) })
const http = await listen(service) const http = await listen(service)
t.after(() => http.close()) 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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: 'open-job', 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' }, 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 ran = await jsonRequest(http.url, `/dsh-ops-cron/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 opened = await jsonRequest(http.url, `/dsh-ops-cron/runs/${ran.body.run.id}/open`, { method: 'POST' })
assert.equal(opened.status, 200) assert.equal(opened.status, 200)
assert.equal(opened.body.sessionId, 'open-sess') assert.equal(opened.body.sessionId, 'open-sess')
assert.deepEqual(revealed, ['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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const archived = [] const archived = []
const service = createHostService({ const service = createHostService({
@ -345,7 +345,7 @@ test('POST /conceal archives every run session id', async (t) => {
}) })
const http = await listen(service) const http = await listen(service)
t.after(() => http.close()) 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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: 'hide-job', 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' }, 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 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.equal(hidden.status, 200)
assert.ok(archived.includes('hide-sess')) assert.ok(archived.includes('hide-sess'))
}) })
test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const at = Date.parse('2026-08-23T14:57:00.000Z') const at = Date.parse('2026-08-23T14:57:00.000Z')
let clock = at - 60_000 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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({ const service = createHostService({
filePath: join(dir, 'store.json'), filePath: join(dir, 'store.json'),
@ -520,7 +520,7 @@ test('GET /models lists session-port catalog', async (t) => {
}) })
const http = await listen(service) const http = await listen(service)
t.after(() => http.close()) 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.status, 200)
assert.equal(res.body.current.model, 'deepseek-chat') assert.equal(res.body.current.model, 'deepseek-chat')
assert.equal(res.body.groups[0].models[0].id, '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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({ const service = createHostService({
filePath: join(dir, 'store.json'), filePath: join(dir, 'store.json'),
@ -559,13 +559,13 @@ test('GET /workspaces lists session-port choices', async (t) => {
}) })
const http = await listen(service) const http = await listen(service)
t.after(() => http.close()) 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.equal(res.status, 200)
assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }]) assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }])
}) })
test('POST /sessions/:id/adopt uses the session port', async (t) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const adopted = [] const adopted = []
const service = createHostService({ const service = createHostService({
@ -579,7 +579,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => {
}) })
const http = await listen(service) const http = await listen(service)
t.after(() => http.close()) 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.status, 200)
assert.equal(res.body.attached, true) assert.equal(res.body.attached, true)
assert.deepEqual(adopted, ['child-9']) 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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const archived = [] const archived = []
const sessionPort = makeLiveSessionPort(fakeLiveCtx(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) => { 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 })) t.after(() => rm(dir, { recursive: true, force: true }))
const archived = [] const archived = []
let clock = Date.parse('2026-08-24T01:00:00.000Z') 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 () => { 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')) 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.bundle.patch, './cordis.patch.yml')
assert.equal(pkg.dsh.client.platform, 'web') assert.equal(pkg.dsh.client.platform, 'web')
assert.ok(pkg.exports['./client']) assert.ok(pkg.exports['./client'])
assert.equal(pkg.main, 'lib/index.js') assert.equal(pkg.main, 'lib/index.js')
assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/'))) assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/')))
const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8') 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') const index = await readFile(join(root, 'lib/index.js'), 'utf8')
assert.match(index, /export function apply\(/) assert.match(index, /export function apply\(/)
assert.match(index, /registerCronTools/) assert.match(index, /registerCronTools/)
assert.match(index, /inject\(\['tools'\]/) assert.match(index, /inject\(\['tools'\]/)
assert.match(index, /getDshIm/)
const tools = await readFile(join(root, 'lib/tools.js'), 'utf8') const tools = await readFile(join(root, 'lib/tools.js'), 'utf8')
assert.match(tools, /name: 'cron_create'/) assert.match(tools, /name: 'cron_create'/)
assert.match(tools, /name: 'cron_list'/) 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, /hour/)
assert.match(tools, /Current local time/) assert.match(tools, /Current local time/)
assert.match(tools, /source: 'runtime'/) 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, /resolveCreateCwd/)
assert.match(tools, /Working directory: if the user is chatting in a workspace/)
assert.match(tools, /after_minutes/) assert.match(tools, /after_minutes/)
assert.match(tools, /Extra schedule fields are ignored/) assert.match(tools, /Extra schedule fields are ignored/)
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/) 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\//) assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//)
const host = await readFile(join(root, 'lib/host.js'), 'utf8') const host = await readFile(join(root, 'lib/host.js'), 'utf8')
assert.match(host, /agentDefaultModel/) assert.match(host, /agentDefaultModel/)
assert.match(host, /system-prompt\/assemble/) assert.match(host, /deliverRunToIm/)
assert.match(host, /agentOptions/) assert.match(host, /PLUGIN_NAME = 'dsh-ops-cron'/)
assert.match(host, /nextRunAt === null/) assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/)
assert.match(host, /resolveSessionPlacement/) 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.match(host, /\/models/)
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//) assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
const client = await readFile(join(root, 'lib/client.js'), 'utf8') 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, /settings\.plugin\.item/)
assert.match(client, /新建会话/) assert.match(client, /deliveryKind/)
assert.match(client, /logoRow/) assert.match(client, /imBotId/)
assert.match(client, /dsh-ct-entry/) assert.match(client, /imTargetId/)
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, /CwdField/) assert.match(client, /CwdField/)
assert.match(client, /ModelField/) assert.match(client, /ModelField/)
assert.match(client, /api\('\/models'\)/) assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
assert.match(tools, /resolveCreateModel/) const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')
assert.match(tools, /snapshots the current session model/) assert.match(delivery, /resolveCreateDelivery/)
assert.match(client, /api\('\/workspaces'\)/) assert.match(delivery, /deliverRunToIm/)
assert.match(client, /atToLocalInput/)
assert.match(client, /localInputToAt/)
const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8') const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8')
assert.match(prd, /左侧侧边栏「新会话」按钮正下方的「定时任务」入口/) 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, /dsh-schedule/) assert.match(prd, /dsh-schedule/)
assert.match(prd, /非目标/)
}) })