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

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

118
lib/delivery.js Normal file
View file

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

View file

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

View file

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

7
lib/index.d.ts vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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