Inherit agent presets and auto-bind IM delivery targets (0.1.7).

Cron jobs created from WhatsApp can omit bot/target ids and reuse chat overrides; sidebar/agent preset pins persist on fire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 02:31:43 +08:00
parent 2f08136adf
commit 2e690c30c8
15 changed files with 830 additions and 82 deletions

View file

@ -161,8 +161,13 @@ window.__ModuleLoader__.load({
const inject = ['slots', 'locale', 'settingsScope']
const API = '/dsh-ops-cron'
const LOCALE_NS = 'settings.dshCronTasks'
const TITLE_PREFIX = '运维定时 · '
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
const TITLE_PREFIX = '定时任务 · '
const listSnapshot = {
jobs: [], runs: [], workspaces: [],
catalog: { groups: [], current: null },
presets: { items: [], current: null },
imCatalog: { available: true, options: [], loading: false },
}
function jobStamp(rows) {
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('|')
@ -291,19 +296,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,7 +318,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
model: '模型',
modelDefault: '每次运行用当时的新会话默认',
modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
agentPreset: 'Agent Preset',
agentPresetDefault: '每次运行用当时的 Host 默认',
agentPresetHint: '指定后每次触发都挂载该 Preset;留空则跟随 Host 默认(创建时若从 WhatsApp 继承会自动写入)。',
editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
delivery: '投递',
@ -321,17 +329,22 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
deliveryIm: 'WhatsApp / IM',
imBotId: 'IM Bot ID',
imTargetId: 'IM Target ID',
deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。',
imTarget: '投递目标',
imTargetNone: '(请先在 IM「投递设置」新建目标)',
imTargetManual: '手动填写…',
imCatalogLoading: '正在加载投递目标…',
imCatalogUnavailable: '无法加载投递目标(需 dsh-im-ops ≥ops.24,且已配置投递目标)',
deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。',
}
const en = {
title: 'Ops schedule',
title: 'Scheduled tasks',
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: 'Ops schedule', entryLabel: 'Open ops schedule',
entry: 'Scheduled tasks', entryLabel: 'Open scheduled tasks',
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',
@ -347,6 +360,9 @@ 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.',
agentPreset: 'Agent preset',
agentPresetDefault: 'Use the Host default at fire time',
agentPresetHint: 'Pin a preset for every fire. Leave empty to follow the Host default (WhatsApp-created jobs inherit the chat preset automatically).',
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',
@ -354,7 +370,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
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.',
imTarget: 'Delivery target',
imTargetNone: '(Create a target in IM delivery settings first)',
imTargetManual: 'Enter manually…',
imCatalogLoading: 'Loading delivery targets…',
imCatalogUnavailable: 'Cannot load targets (need dsh-im-ops ≥ops.24 with saved targets)',
deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.',
}
function normalizePrefs(value) {
@ -597,6 +618,7 @@ 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 || '',
agentPreset: '',
deliveryKind: 'dsh',
imBotId: '',
imTargetId: '',
@ -616,6 +638,7 @@ 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 || '',
agentPreset: job.agentPreset || '',
deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh',
imBotId: job.delivery?.botId || '',
imTargetId: job.delivery?.targetId || '',
@ -858,7 +881,110 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
)
}
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, onSave, onRun, onPause, onRemove }) {
function PresetField({ t, form, setForm, presets }) {
const items = Array.isArray(presets?.items) ? presets.items : []
const current = presets?.current
const selected = form.agentPreset || ''
const known = items.some((row) => row.id === selected)
const defaultLabel = current?.id
? `${t('agentPresetDefault')}(${current.name || current.id})`
: t('agentPresetDefault')
return h('label', null, t('agentPreset'),
h('select', {
value: selected,
onChange: (e) => setForm({ ...form, agentPreset: e.target.value }),
},
h('option', { value: '' }, defaultLabel),
!known && selected
? h('option', { value: selected }, selected)
: null,
...items.map((row) => h('option', {
value: row.id,
key: row.id,
}, row.name || row.id)),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('agentPresetHint')),
)
}
function imOptionKey(botId, targetId) {
return `${botId || ''}::${targetId || ''}`
}
function parseImOptionKey(value) {
const raw = String(value || '')
const at = raw.indexOf('::')
if (at <= 0) return { botId: '', targetId: '' }
return { botId: raw.slice(0, at), targetId: raw.slice(at + 2) }
}
function ImDeliveryFields({ t, form, setForm, imCatalog }) {
const options = Array.isArray(imCatalog?.options) ? imCatalog.options : []
const loading = !!imCatalog?.loading
const available = imCatalog?.available !== false
const selectedKey = form.imBotId && form.imTargetId
? imOptionKey(form.imBotId, form.imTargetId)
: ''
const matched = options.some((row) => imOptionKey(row.botId, row.targetId) === selectedKey)
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
useEffect(() => {
if (selectedKey && !matched && !loading) setManual(true)
if (selectedKey && matched) setManual(false)
}, [selectedKey, matched, loading])
const selectValue = manual
? '__manual__'
: (matched ? selectedKey : '')
const showManual = manual || (selectedKey && !matched && !loading)
return h(React.Fragment, null,
h('label', null, t('imTarget'),
h('select', {
value: selectValue,
disabled: loading,
onChange: (e) => {
const value = e.target.value
if (value === '__manual__') {
setManual(true)
return
}
setManual(false)
if (!value) {
setForm({ ...form, imBotId: '', imTargetId: '' })
return
}
const next = parseImOptionKey(value)
setForm({ ...form, imBotId: next.botId, imTargetId: next.targetId })
},
},
h('option', { value: '' }, loading ? t('imCatalogLoading') : (
options.length === 0 ? t('imTargetNone') : `— ${t('imTarget')} —`
)),
...options.map((row) => h('option', {
key: imOptionKey(row.botId, row.targetId),
value: imOptionKey(row.botId, row.targetId),
}, `${row.name || row.targetId} · ${row.channel || 'im'} · ${row.targetId}`)),
h('option', { value: '__manual__' }, t('imTargetManual')),
),
),
!available || (options.length === 0 && !loading)
? h('span', { className: 'dsh-ct-cwdHint' }, imCatalog?.hint || t('imCatalogUnavailable'))
: null,
showManual
? 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,
)
}
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, presets, imCatalog, onSave, onRun, onPause, onRemove }) {
return h('div', { className: 'dsh-ct-editor' },
h('h1', null, isNew ? t('newJob') : (form.name || t('title'))),
h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')),
@ -890,6 +1016,7 @@ 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(PresetField, { t, form, setForm, presets }),
h('label', null, t('delivery'),
h('select', {
value: form.deliveryKind || 'dsh',
@ -900,16 +1027,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
),
),
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 }),
})),
)
? h(ImDeliveryFields, { t, form, setForm, imCatalog })
: null,
h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
h('div', { className: 'dsh-ct-editorActs' },
@ -935,6 +1053,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const [cronMode, setCronMode] = useState(false)
const [workspaces, setWorkspaces] = useState(() => listSnapshot.workspaces)
const [catalog, setCatalog] = useState(() => listSnapshot.catalog)
const [presets, setPresets] = useState(() => listSnapshot.presets || { items: [], current: null })
const [imCatalog, setImCatalog] = useState(() => listSnapshot.imCatalog || { available: true, options: [], loading: false })
const [paneHost, setPaneHost] = useState(() => (typeof document === 'undefined' ? null : document.body))
const skipAutoSelect = useRef(false)
@ -957,18 +1077,44 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
}, [])
const loadImCatalog = useCallback(async () => {
setImCatalog((prev) => ({ ...prev, loading: true }))
try {
const body = await api('/im-catalog')
const next = {
available: body.available !== false,
options: Array.isArray(body.options) ? body.options : [],
hint: body.hint || '',
loading: false,
}
listSnapshot.imCatalog = next
setImCatalog(next)
} catch (err) {
const next = {
available: false,
options: [],
hint: err instanceof Error ? err.message : String(err),
loading: false,
}
listSnapshot.imCatalog = next
setImCatalog(next)
}
}, [])
const load = useCallback(async () => {
try {
const [jobBody, histBody, wsBody, modelBody] = await Promise.all([
const [jobBody, histBody, wsBody, modelBody, presetBody] = await Promise.all([
api('/jobs'),
api('/history'),
api('/workspaces').catch(() => ({ workspaces: [] })),
api('/models').catch(() => ({ groups: [], current: null })),
api('/presets').catch(() => ({ items: [], current: null })),
])
const nextJobs = jobBody.jobs || []
const nextRuns = histBody.runs || []
const nextWorkspaces = wsBody.workspaces || []
const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null }
const nextPresets = { items: presetBody.items || [], current: presetBody.current || null }
if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) {
listSnapshot.jobs = nextJobs
setJobs(nextJobs)
@ -979,8 +1125,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
listSnapshot.workspaces = nextWorkspaces
listSnapshot.catalog = nextCatalog
listSnapshot.presets = nextPresets
setWorkspaces(nextWorkspaces)
setCatalog(nextCatalog)
setPresets(nextPresets)
faces.cronSessionIds = new Set(nextRuns.map((run) => run.sessionId).filter(Boolean))
setError('')
} catch (err) {
@ -1002,6 +1150,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
}, [load, cronMode])
useEffect(() => {
if (!cronMode || form.deliveryKind !== 'im') return
loadImCatalog()
}, [cronMode, form.deliveryKind, loadImCatalog])
useEffect(() => {
if (!cronMode) {
skipAutoSelect.current = false
@ -1038,9 +1191,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) {
next = { ...next, provider: catalog.current.provider, model: catalog.current.model }
}
if (!next.agentPreset && presets.current?.id) {
next = { ...next, agentPreset: presets.current.id }
}
return next
})
}, [cronMode, selection.type, catalog.current])
}, [cronMode, selection.type, catalog.current, presets.current])
function selectJob(jobId) {
const job = jobs.find((row) => row.id === jobId)
@ -1084,6 +1240,7 @@ 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,
agentPreset: form.agentPreset || '',
delivery: form.deliveryKind === 'im'
? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
: { kind: 'dsh' },
@ -1132,6 +1289,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
lastRunAt: selectedJob?.lastRunAt,
workspaces,
catalog,
presets,
imCatalog,
onSave: save,
onRun: () => selection.jobId && run(selection.jobId),
onPause: () => {
@ -1197,7 +1356,10 @@ 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('运维定时')
|| value.startsWith(TITLE_PREFIX.trim())
|| value.includes(TITLE_PREFIX)
}
function workspaceTitleFromCronFork(title) {
@ -1207,6 +1369,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
if (value.startsWith('定时任务')) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
return value
}
@ -1219,7 +1385,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('定时任务') || title.startsWith('运维定时')
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}

View file

@ -1,5 +1,6 @@
/**
* Job delivery: DSH sidebar session vs proactive IM (botId + targetId).
* Group jobs may also pin mentionJid so delivery @s the creator.
*/
export function normalizeDelivery(input = {}) {
@ -13,14 +14,121 @@ export function normalizeDelivery(input = {}) {
error.code = 'INVALID_DELIVERY'
throw error
}
return { kind: 'im', botId, targetId }
const mentionJid = typeof src.mentionJid === 'string' ? src.mentionJid.trim() : ''
const mentionName = typeof src.mentionName === 'string' ? src.mentionName.trim().slice(0, 80) : ''
const delivery = { kind: 'im', botId, targetId }
if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) {
delivery.mentionJid = mentionJid
if (mentionName) delivery.mentionName = mentionName
}
return delivery
}
return { kind: 'dsh' }
}
/**
* Keep creator @mention when the UI re-saves the same IM target without mention fields.
* @param {object|null|undefined} previous
* @param {object} next
*/
export function mergeDeliveryMention(previous, next) {
const normalized = normalizeDelivery(next)
if (normalized.kind !== 'im' || normalized.mentionJid) return normalized
if (previous?.kind !== 'im') return normalized
if (previous.botId !== normalized.botId || previous.targetId !== normalized.targetId) {
return normalized
}
if (!previous.mentionJid) return normalized
return normalizeDelivery({
...normalized,
mentionJid: previous.mentionJid,
mentionName: previous.mentionName,
})
}
export function deliveryLine(delivery) {
if (!delivery || delivery.kind !== 'im') return 'delivery=dsh'
return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}`
const mention = delivery.mentionJid ? ` mention=@${delivery.mentionName || delivery.mentionJid}` : ''
return `delivery=im botId=${delivery.botId} targetId=${delivery.targetId}${mention}`
}
function peerIsGroup(peer) {
if (!peer || typeof peer !== 'object') return false
if (peer.kind === 'group') return true
const conversationId = String(peer.conversationId || '').trim()
if (conversationId.endsWith('@g.us')) return true
const key = String(peer.conversationKey || '').trim()
return key.startsWith('group:')
}
/**
* Group creator identity for WhatsApp @mention on delivery.
* Prefer phone JID so WhatsApp shows the contact nickname and notifies;
* LID-only identities are a last resort (often render as opaque digits).
* @param {object|null} peer
* @returns {{ mentionJid: string, mentionName?: string } | null}
*/
export function mentionFromPeer(peer) {
if (!peerIsGroup(peer)) return null
const phone = typeof peer.phone === 'string' ? peer.phone.trim() : ''
const senderId = typeof peer.senderId === 'string' ? peer.senderId.trim() : ''
const phoneJid = phone ? `${phone}@s.whatsapp.net` : ''
const senderIsPhone = /@s\.whatsapp\.net$/i.test(senderId)
const senderIsLid = /@lid$/i.test(senderId)
const mentionJid = phoneJid
|| (senderIsPhone ? senderId : '')
|| (senderIsLid ? senderId : '')
if (!mentionJid) return null
const pushName = typeof peer.pushName === 'string' ? peer.pushName.trim() : ''
const mentionName = (pushName || phone || mentionJid.split('@')[0] || '').slice(0, 80)
return mentionName
? { mentionJid, mentionName }
: { mentionJid }
}
function withPeerMention(delivery, peer) {
if (!delivery || delivery.kind !== 'im') return delivery
const mention = mentionFromPeer(peer)
if (!mention) return delivery
return normalizeDelivery({ ...delivery, ...mention })
}
/**
* JID candidates for matching saved 投递目标.
* Groups must NEVER include the sender phone — that would bind cron delivery to a DM.
* @param {object|null} peer
* @returns {string[]}
*/
function peerJidCandidates(peer) {
if (!peer || typeof peer !== 'object') return []
const conversationId = String(peer.conversationId || '').trim()
const key = String(peer.conversationKey || '').trim()
if (peerIsGroup(peer)) {
const groupFromKey = key.startsWith('group:')
? key.slice('group:'.length).split(':user:')[0].trim()
: ''
return [
peer.jid,
peer.route?.jid,
conversationId,
groupFromKey,
]
.map((value) => String(value || '').trim().toLowerCase())
.filter((value) => value.endsWith('@g.us'))
}
const jidFromKey = key.startsWith('direct:')
? key.slice('direct:'.length).trim()
: (key.includes(':') ? key.slice(key.indexOf(':') + 1).split(':user:')[0] : key)
const phoneJid = peer.phone ? `${String(peer.phone).trim()}@s.whatsapp.net` : ''
return [
peer.jid,
peer.route?.jid,
conversationId,
phoneJid,
jidFromKey,
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
}
/**
@ -30,20 +138,123 @@ export function deliveryLine(delivery) {
*/
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
const candidates = peerJidCandidates(peer)
if (candidates.length === 0) return null
const wantGroup = peerIsGroup(peer)
for (const target of targets) {
const targetKind = typeof target?.kind === 'string' ? target.kind.trim() : ''
if (wantGroup && targetKind === 'user') continue
if (!wantGroup && targetKind === 'group') continue
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))) {
if (!jid) continue
if (wantGroup && !jid.endsWith('@g.us')) continue
if (candidates.some((peerJid) => (
jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid)
))) {
return target
}
}
return null
}
function slugId(value, fallback) {
const raw = String(value || '').trim().toLowerCase()
const slug = raw.replace(/@.*/, '').replace(/[^a-z0-9_-]+/g, '').slice(0, 40)
return slug || fallback
}
/**
* Build a WhatsApp (or JID-shaped) delivery target draft for the current peer.
* Group chats always target the group JID; DMs prefer phone JID when known.
* @param {object|null} peer
* @returns {{ botId: string, target: object } | null}
*/
export function deliveryDraftFromPeer(peer) {
if (!peer?.botId || !peer.conversationId) return null
const conversationId = String(peer.conversationId).trim()
const isGroup = peerIsGroup(peer)
const routeJid = (!isGroup && peer.phone)
? `${String(peer.phone).trim()}@s.whatsapp.net`
: conversationId
if (!routeJid) return null
if (isGroup && !routeJid.endsWith('@g.us')) return null
if (!isGroup && !/@(s\.whatsapp\.net|lid)$/i.test(routeJid)) {
// Non-WhatsApp peers are not auto-materialized yet.
return null
}
const name = String(peer.label || (isGroup ? 'WhatsApp 群聊' : 'WhatsApp 私聊')).trim().slice(0, 80)
const targetId = isGroup
? `auto-group-${slugId(routeJid, 'chat')}`
: `auto-dm-${slugId(peer.phone || routeJid, 'user')}`
return {
botId: String(peer.botId).trim(),
target: {
targetId,
name: name || targetId,
kind: isGroup ? 'group' : 'user',
route: { jid: routeJid },
},
}
}
/**
* Reuse or create a delivery target for the WhatsApp/IM peer that owns this session.
* @param {object|null} peer
* @param {object} dshIm
* @returns {Promise<{ kind: 'im', botId: string, targetId: string } | null>}
*/
export async function ensureImDeliveryForPeer(peer, dshIm) {
if (!peer?.botId || !dshIm || typeof dshIm.listTargets !== 'function') return null
let targets = []
try {
const listed = await dshIm.listTargets(peer.botId)
targets = Array.isArray(listed) ? listed : []
} catch {
return null
}
const match = matchTargetForPeer(targets, peer)
if (match?.targetId) {
return withPeerMention(
normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(match.targetId) }),
peer,
)
}
const draft = deliveryDraftFromPeer(peer)
if (!draft || typeof dshIm.createTarget !== 'function') return null
try {
const created = await dshIm.createTarget(draft.botId, draft.target)
const targetId = created?.targetId || draft.target.targetId
return withPeerMention(
normalizeDelivery({ kind: 'im', botId: draft.botId, targetId: String(targetId) }),
peer,
)
} catch (error) {
if (error?.code !== 'target-conflict') return null
try {
const again = await dshIm.listTargets(peer.botId)
const rows = Array.isArray(again) ? again : []
const byId = rows.find((row) => row?.targetId === draft.target.targetId)
if (byId?.targetId) {
return withPeerMention(
normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(byId.targetId) }),
peer,
)
}
const rematch = matchTargetForPeer(rows, peer)
if (rematch?.targetId) {
return withPeerMention(
normalizeDelivery({ kind: 'im', botId: peer.botId, targetId: String(rematch.targetId) }),
peer,
)
}
} catch {
return null
}
return null
}
}
export function callerSessionId(exec) {
const session = exec?.agent?.session
return String(
@ -56,7 +267,8 @@ export function callerSessionId(exec) {
}
/**
* Resolve delivery for cron_create: explicit args win; else IM peer → matching target; else dsh.
* Resolve delivery for cron_create: explicit args win; else IM peer → matching
* or auto-created 投递目标; else dsh.
* @param {object} args
* @param {object} exec
* @param {{ dshIm?: object }} deps
@ -78,17 +290,8 @@ export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) {
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),
})
}
}
const ensured = await ensureImDeliveryForPeer(peer, dshIm)
if (ensured) return ensured
} catch {
// fall through to dsh
}
@ -100,6 +303,7 @@ const IM_MAX_CHARS = 3500
/**
* Send run summary to IM when job.delivery.kind === 'im'.
* Group deliveries @mention the creator when mentionJid was captured at create time.
*/
export async function deliverRunToIm(job, summary, deps = {}) {
const delivery = job?.delivery
@ -112,7 +316,15 @@ export async function deliverRunToIm(job, summary, deps = {}) {
}
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 }
const header = `【定时任务 · ${job.name}】\n`
const mentionJid = typeof delivery.mentionJid === 'string' ? delivery.mentionJid.trim() : ''
let body = `${header}${clipped}`
const options = {}
if (mentionJid && /@(s\.whatsapp\.net|lid)$/i.test(mentionJid)) {
const token = mentionJid.slice(0, mentionJid.indexOf('@'))
body = `@${token}\n${header}${clipped}`
options.mentions = [mentionJid]
}
await dshIm.send(delivery.botId, delivery.targetId, body, options)
return { sent: true, mentioned: Boolean(options.mentions) }
}

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,8 +55,15 @@ export function publicJob(job) {
provider: job.provider || '',
model: job.model || '',
reasoningEffort: job.reasoningEffort || '',
agentPreset: job.agentPreset || '',
delivery: job.delivery && job.delivery.kind === 'im'
? { kind: 'im', botId: job.delivery.botId, targetId: job.delivery.targetId }
? {
kind: 'im',
botId: job.delivery.botId,
targetId: job.delivery.targetId,
...job.delivery.mentionJid ? { mentionJid: job.delivery.mentionJid } : {},
...job.delivery.mentionName ? { mentionName: job.delivery.mentionName } : {},
}
: { kind: 'dsh' },
schedule: job.schedule,
createdAt: job.createdAt,

View file

@ -8,7 +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 { deliverRunToIm, mergeDeliveryMention } from './delivery.js'
import { workspaceVisibleIds } from './isolation.js'
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
import {
@ -174,7 +174,10 @@ 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,
agentPreset: patch.agentPreset !== undefined ? patch.agentPreset : job.agentPreset,
delivery: patch.delivery !== undefined
? mergeDeliveryMention(job.delivery, patch.delivery)
: job.delivery,
}
const record = createJobRecord(nextInput, current, t)
record.createdAt = job.createdAt
@ -375,6 +378,14 @@ export function createHostService(options = {}) {
return
}
if (path === `${API_PREFIX}/presets` && method === 'GET') {
const catalog = typeof sessionPort?.listPresets === 'function'
? await sessionPort.listPresets()
: { items: [], current: null }
write(200, { ok: true, ...catalog })
return
}
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
? await sessionPort.listWorkspaces()
@ -383,6 +394,35 @@ export function createHostService(options = {}) {
return
}
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
const dshIm = getDshIm()
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
write(200, {
ok: true,
available: false,
options: [],
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
})
return
}
try {
const options = await dshIm.listDeliveryCatalog()
write(200, {
ok: true,
available: true,
options: Array.isArray(options) ? options : [],
})
} catch (error) {
write(200, {
ok: true,
available: false,
options: [],
hint: error instanceof Error ? error.message : String(error),
})
}
return
}
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
const state = await snapshot()
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
@ -766,6 +806,35 @@ export async function listModelChoices(ctx) {
return { groups, current: currentDefaultModel(ctx) }
}
export async function listPresetChoices(ctx) {
const presets = tryGet(ctx, 'agentPresets')
if (!presets || typeof presets.list !== 'function') {
return { items: [], current: null }
}
let items = []
try {
const listed = await presets.list()
const rows = Array.isArray(listed) ? listed : (Array.isArray(listed?.items) ? listed.items : [])
items = rows.map((row) => ({
id: String(row?.id || '').trim(),
name: String(row?.name || row?.displayName || row?.id || '').trim(),
})).filter((row) => row.id)
} catch {
items = []
}
let current = null
if (typeof presets.resolve === 'function') {
try {
const resolved = await presets.resolve()
const id = typeof resolved?.id === 'string' ? resolved.id.trim() : ''
if (id) current = { id, name: String(resolved?.name || id) }
} catch {
current = null
}
}
return { items, current }
}
export async function resolveJobModel(ctx, job) {
const provider = typeof job?.provider === 'string' ? job.provider.trim() : ''
const model = typeof job?.model === 'string' ? job.model.trim() : ''
@ -838,7 +907,7 @@ export function bindModelSelection(agentCtx, selection) {
})
}
async function composeCronAgent(ctx, selection) {
async function composeCronAgent(ctx, selection, job) {
const presets = tryGet(ctx, 'agentPresets')
if (!presets || typeof presets.resolve !== 'function' || typeof presets.mount !== 'function') {
return {
@ -847,7 +916,10 @@ async function composeCronAgent(ctx, selection) {
},
}
}
const resolved = await presets.resolve()
const wanted = typeof job?.agentPreset === 'string' ? job.agentPreset.trim() : ''
const resolved = wanted
? await presets.resolve(wanted)
: await presets.resolve()
const presetId = resolved?.id
return {
agentPreset: presetId,
@ -871,7 +943,7 @@ export function makeLiveSessionPort(ctx) {
const cwd = placement.cwd || defaultCwd()
await mkdir(cwd, { recursive: true })
const selection = await resolveJobModel(ctx, job)
const composition = await composeCronAgent(ctx, selection)
const composition = await composeCronAgent(ctx, selection, job)
const handle = await agents.create({
sessionId,
agentOptions: {
@ -935,6 +1007,9 @@ export function makeLiveSessionPort(ctx) {
async listModels() {
return listModelChoices(ctx)
},
async listPresets() {
return listPresetChoices(ctx)
},
async waitForTurn(sessionId) {
const entry = handles.get(sessionId)
const agents = tryGet(ctx, 'agents')

View file

@ -2,7 +2,7 @@
* 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
@ -131,6 +131,7 @@ function registerWebRoute(ctx, handler) {
export function apply(ctx, config = {}) {
const entry = resolveConfig(config)
const getDshIm = () => tryGet(ctx, 'dshIm')
const getAgentPresets = () => tryGet(ctx, 'agentPresets')
const service = createHostService({
sessionPort: makeLiveSessionPort(ctx),
getDshIm,
@ -143,7 +144,7 @@ export function apply(ctx, config = {}) {
})
ctx.inject(['tools'], (tctx) => {
registerCronTools(tctx, service, { getDshIm })
registerCronTools(tctx, service, { getDshIm, getAgentPresets })
tctx.logger?.info?.('[dsh-ops-cron] tools cron_create/list/pause/resume/delete registered')
})

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

79
lib/preset.js Normal file
View file

@ -0,0 +1,79 @@
/**
* Resolve which Agent Preset a cron job should mount.
* Priority: explicit args → IM session peer → creating session meta → Host default.
*/
import { callerSessionId } from './delivery.js'
const PRESET_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
/**
* @param {unknown} value
* @returns {string}
*/
export function normalizeAgentPresetId(value) {
if (typeof value !== 'string') return ''
const id = value.trim()
if (!id) return ''
if (!PRESET_ID.test(id)) {
const error = new Error('agentPreset must be a short alphanumeric id')
error.code = 'INVALID_JOB'
throw error
}
return id
}
function sessionMetaPreset(exec) {
const meta = exec?.agent?.session?.meta
|| exec?.agent?.meta
|| exec?.session?.meta
|| null
if (!meta || typeof meta !== 'object') return ''
const raw = meta.agentPreset ?? meta.agent_preset
return typeof raw === 'string' ? raw.trim() : ''
}
/**
* @param {object} args
* @param {object} exec
* @param {{ dshIm?: object, agentPresets?: object }} deps
* @returns {Promise<string>} empty string means "Host default at fire time"
*/
export async function resolveCreateAgentPreset(args = {}, exec = {}, deps = {}) {
const explicit = typeof args.agent_preset === 'string' ? args.agent_preset.trim()
: (typeof args.agentPreset === 'string' ? args.agentPreset.trim() : '')
if (explicit) return normalizeAgentPresetId(explicit)
const dshIm = deps.dshIm
const sessionId = callerSessionId(exec)
if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') {
try {
const peer = await dshIm.resolveSessionPeer(sessionId)
const fromPeer = typeof peer?.agentPreset === 'string' ? peer.agentPreset.trim() : ''
if (fromPeer) return normalizeAgentPresetId(fromPeer)
} catch {
// fall through
}
}
const fromSession = sessionMetaPreset(exec)
if (fromSession) {
try {
return normalizeAgentPresetId(fromSession)
} catch {
// ignore invalid session meta
}
}
const presets = deps.agentPresets
if (presets && typeof presets.resolve === 'function') {
try {
const resolved = await presets.resolve()
const id = typeof resolved?.id === 'string' ? resolved.id.trim() : ''
return id ? normalizeAgentPresetId(id) : ''
} catch {
return ''
}
}
return ''
}

View file

@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'
import { applyRunIsolation, recordHiddenSession } from './isolation.js'
import { nextFire, validateSchedule } from './scheduler.js'
import { normalizeDelivery } from './delivery.js'
import { normalizeAgentPresetId } from './preset.js'
export const STORE_VERSION = 1
@ -94,6 +95,7 @@ export function createJobRecord(input, state, now) {
const timeoutMinutes = Number(input.timeoutMinutes)
const model = normalizeJobModel(input)
const delivery = normalizeDelivery(input.delivery)
const agentPreset = normalizeAgentPresetId(input.agentPreset ?? input.agent_preset)
const job = {
id: String(input.id || newId()),
name,
@ -106,6 +108,7 @@ export function createJobRecord(input, state, now) {
provider: model.provider,
model: model.model,
reasoningEffort: model.reasoningEffort,
agentPreset,
delivery,
schedule: schedule.kind === 'cron'
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }

View file

@ -5,6 +5,7 @@
import { formatInZone, resolveTodayAt } from './scheduler.js'
import { deliveryLine, resolveCreateDelivery } from './delivery.js'
import { resolveCreateAgentPreset } from './preset.js'
const JOB_SCHEMA = {
type: 'object',
@ -19,6 +20,7 @@ const JOB_SCHEMA = {
provider: { type: 'string' },
model: { type: 'string' },
reasoningEffort: { type: 'string' },
agentPreset: { type: 'string' },
delivery: { type: 'object', additionalProperties: true },
schedule: { type: 'object', additionalProperties: true },
createdAt: { type: 'number' },
@ -142,15 +144,16 @@ function requireId(args) {
export function cronToolDefinitions(service, deps = {}) {
const getDshIm = typeof deps.getDshIm === 'function' ? deps.getDshIm : () => undefined
const getAgentPresets = typeof deps.getAgentPresets === 'function' ? deps.getAgentPresets : () => undefined
return [
{
name: 'cron_create',
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 投递设置.',
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. Agent preset: omit to inherit (WhatsApp chat preset → session → Host default); or pass agent_preset. Delivery: omit for auto — on WhatsApp/IM creates/reuses a 投递目标 for the current chat (group→group, DM→DM) and sets delivery=im; on Web/DSH defaults to dsh. Or pass delivery=dsh|im with im_bot_id+im_target_id.',
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.' },
@ -170,6 +173,7 @@ export function cronToolDefinitions(service, deps = {}) {
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.' },
agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' },
},
required: ['name', 'prompt'],
},
@ -194,12 +198,16 @@ export function cronToolDefinitions(service, deps = {}) {
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 agentPreset = await resolveCreateAgentPreset(args, exec, {
dshIm: getDshIm(),
agentPresets: getAgentPresets(),
})
const job = await service.createJob({
name: args.name,
prompt: args.prompt,
@ -209,6 +217,7 @@ export function cronToolDefinitions(service, deps = {}) {
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
enabled: args.enabled !== false,
delivery,
agentPreset,
})
return { job }
} catch (error) {
@ -225,7 +234,7 @@ export function cronToolDefinitions(service, deps = {}) {
},
{
name: 'cron_list',
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.',
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,
@ -248,7 +257,7 @@ export function cronToolDefinitions(service, deps = {}) {
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()
@ -258,7 +267,7 @@ export function cronToolDefinitions(service, deps = {}) {
},
{
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,
@ -269,7 +278,7 @@ export function cronToolDefinitions(service, deps = {}) {
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)
@ -289,7 +298,7 @@ export function cronToolDefinitions(service, deps = {}) {
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)
@ -317,7 +326,7 @@ export function cronToolDefinitions(service, deps = {}) {
},
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)
@ -336,17 +345,18 @@ export function cronToolDefinitions(service, deps = {}) {
export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') {
const now = formatInZone(nowMs, timeZone)
return [
'This deployment has a DSH sidebar feature named 运维定时 (ops scheduled tasks / dsh-ops-cron).',
'This deployment has a DSH sidebar feature named 定时任务 (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.',
'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:',
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im for the current chat (group→same group, DM→same DM). A 投递目标 is reused or auto-created. On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id.',
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',
'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')
}
@ -356,15 +366,15 @@ export const CRON_GUIDANCE = cronGuidanceText()
export function makeCronSkill() {
return {
name: 'scheduled-tasks',
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.',
description: '定时任务: 查看、创建、暂停、恢复、删除 DSH 侧栏定时任务(今天晚上几点、一次性执行、scheduled job、cron)。WhatsApp 里创建默认回投 IM;Web 里创建进侧栏会话。不要用系统 crontab,也不是会话内 reminder。',
whenToUse: 'User asks to list or create 定时任务 / scheduled tasks, schedule something for tonight/today, pause a job, or mentions cron 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. Delivery auto from session, or delivery=im + im_bot_id + im_target_id.
- 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 and agent preset auto from session, or pass delivery=im / agent_preset explicitly.
- cron_pause / cron_resume / cron_delete — by id from cron_list
`,
}