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

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()