From 2f08136adf7100586ba0f23841673576dbb2605e Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 5 Sep 2026 21:48:17 +0800 Subject: [PATCH] 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 --- FORK.md | 33 ++++++++++++ README.md | 123 +++++++----------------------------------- cordis.patch.yml | 7 +-- lib/client.js | 108 +++++++++++++++++++++++++++---------- lib/delivery.js | 118 ++++++++++++++++++++++++++++++++++++++++ lib/fire.js | 5 +- lib/host.js | 26 +++++++-- lib/index.d.ts | 7 ++- lib/index.js | 31 +++++++---- lib/isolation.js | 9 +++- lib/prompt.js | 4 +- lib/store.js | 5 +- lib/tools.js | 57 ++++++++++++-------- package.json | 17 +++--- test/delivery.test.js | 68 +++++++++++++++++++++++ test/host.test.js | 50 ++++++++--------- test/package.test.js | 89 +++++++----------------------- 17 files changed, 472 insertions(+), 285 deletions(-) create mode 100644 FORK.md create mode 100644 lib/delivery.js create mode 100644 test/delivery.test.js diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000..fac6db4 --- /dev/null +++ b/FORK.md @@ -0,0 +1,33 @@ +# dsh-ops-cron(运维 fork) + +本仓库是 [Whale-Zhang/dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) 的 **运维 fork**,用于: + +- Agent 自建定时任务(`cron_create` / `cron_list` / …) +- 按创建来源默认投递:**WhatsApp 会话 → IM**;**Web/DSH 会话 → 侧栏新会话** +- 与 [dsh-im-ops](https://github.com/hansjone/dsh-im-ops) 的 `ctx.dshIm.send(botId, targetId, text)` 打通 + +不要与上游 `@dsh-external/dsh-cron-tasks` 装进同一 profile(工具名冲突)。 + +## 安装 + +```powershell +# 建议顺序:先 im,再 cron +dsh plugin --profile web add -w "github:hansjone/dsh-im-ops" +dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron" +# 重启 dsh web +``` + +本地开发: + +```powershell +dsh plugin --profile web add -w "D:\project\chatgpt\dsh-ops-cron" +``` + +## 相对上游的改动 + +1. 包名 / cordis id / HTTP 前缀 / 侧栏文案 → `dsh-ops-cron` /「运维定时」 +2. Job 增加 `delivery: { kind: 'dsh'|'im', botId?, targetId? }` +3. `cron_create` 支持 `delivery` / `im_bot_id` / `im_target_id`;未显式指定时,若当前会话能 `resolveSessionPeer` 且已有匹配投递目标 → 默认 IM +4. 开火后若 `delivery.kind === 'im'`,把 assistant 摘要经 `ctx.dshIm.send` 投回 + +关键告警推送仍在 **netxops**,不经过本插件。 diff --git a/README.md b/README.md index b9f04f8..f3797e4 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,31 @@ -# dsh-cron-tasks +# dsh-ops-cron -[English](#english) · [中文](#zhongwen) +Ops fork of [dsh-cron-tasks](https://github.com/Whale-Zhang/dsh-cron-tasks) for DeepSeek Harness. -A DeepSeek Harness (`dsh`) plugin for **scheduled tasks**. Sidebar entry under **New Session**, plugin settings, isolated run history, and model-facing `cron_*` tools. +- Sidebar **运维定时** under New Session +- Agent tools: `cron_create` / `cron_list` / `cron_pause` / `cron_resume` / `cron_delete` +- Per-job delivery: **DSH** (new root session) or **IM** (`ctx.dshIm.send`) -![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7c3aed) -![license](https://img.shields.io/badge/license-MIT-blue) +See [FORK.md](FORK.md) for install notes. Do **not** install upstream `@dsh-external/dsh-cron-tasks` in the same profile. - - -## 中文 - -到点后 **新开一条会话** 跑事先写好的提示词,记进侧栏「定时任务」历史。这类会话不会淹没日常工作区列表。 - -这不是官方同会话 reminder(`@deepseek-ai/dsh-schedule`),也不是系统 `crontab`。调度器住在 DSH Host 里:Host 没在跑就不会触发。 - -### 安装 - -需要已安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。一条命令装进网页端和桌面端: +## Install ```sh -dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks -dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks +dsh plugin --profile web add -w "github:hansjone/dsh-im-ops" +dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron" +# restart dsh web ``` -装完后**重启对应 profile**(`dsh web` 或 Desktop)。用下面命令应能看到 `dsh-cron-tasks`: +## Delivery defaults -```sh -dsh --profile web --dump-config -``` +| Created from | Default delivery | +|--------------|------------------| +| WhatsApp / IM session (peer resolved + matching 投递目标) | `im` → that `botId`+`targetId` | +| Web / plain DSH session | `dsh` → sidebar history session | +| Explicit `delivery` / `im_bot_id`+`im_target_id` | as specified | -### 卸载 +Create IM targets first in IM → 投递设置 → 复制调用参数 when you need a stable target for WhatsApp. -```sh -dsh plugin --profile web remove @dsh-external/dsh-cron-tasks -dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks -``` +## License -### 做什么 - -- 左侧 **「新会话」正下方** 的 **「定时任务」** 入口:创建、暂停/恢复、删除、立即运行、搜索 -- **设置 → 插件 → 插件配置** 里的调度策略(启用、默认时区、历史保留、重叠/漏跑) -- 每次触发新开 root Session;点历史记录打开原生对话,可以继续聊 -- 每个任务可指定 **模型**(钉死后不跟着聊天模型变,额度可预期) -- 工作目录可下拉已有工作区,或自定义路径 -- 也可在对话里对 AI 说「帮我建一个定时任务」(skill `scheduled-tasks` + `cron_create`) - -### 日程 - -- 一次性 `at`,或循环 5 字段 cron,IANA 时区(默认 `Asia/Shanghai`) -- 「一分钟后」用 `after_minutes`,不要叠 `at` / `hour` / `expr` -- 重叠:同一任务已有进行中的运行则跳过 -- 漏跑:Host 宕机期间错过的节拍 **不补跑** - -### 开发 - -```sh -node --test test/*.test.js -``` - -本地 `file:` 安装(开发用,不要用 `link:`): - -```sh -dsh plugin --profile web add file:$HOME/.dsh/plugins/dsh-cron-tasks -``` - -产品契约见 [docs/PRD.md](docs/PRD.md)。实现经验见 [docs/NOTES.md](docs/NOTES.md)。 - ---- - - - -## English - -At the scheduled time, Harness **opens a fresh root session**, runs a self-contained prompt, and records the run in the **Scheduled tasks** sidebar. Those sessions stay out of the ordinary workspace list. - -This is not the official same-chat reminder (`@deepseek-ai/dsh-schedule`) and not OS crontab. The scheduler lives in the DSH Host process; nothing fires while Host is down. - -### Install - -Requires [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). - -```sh -dsh plugin --profile web add github:Whale-Zhang/dsh-cron-tasks -dsh plugin --profile desktop add github:Whale-Zhang/dsh-cron-tasks -``` - -Restart the profile after installing. `dsh --profile web --dump-config` should list `dsh-cron-tasks`. - -### Uninstall - -```sh -dsh plugin --profile web remove @dsh-external/dsh-cron-tasks -dsh plugin --profile desktop remove @dsh-external/dsh-cron-tasks -``` - -### What it does - -- Sidebar entry **under New Session**: create, pause/resume, delete, run now, search -- Scheduler policy in **Settings → Plugins → Plugin configuration** -- Each fire is a new root session; open a history row to continue in the native chat -- Each job can pin a **model** so quota does not follow the chat selector -- Working directory: existing workspace or a custom path -- Ask the agent to create a job (`scheduled-tasks` skill + `cron_create`) - -### Schedule - -- One-shot `at` or 5-field cron, IANA time zone (default `Asia/Shanghai`) -- “In N minutes” → `after_minutes` -- Overlap: skip if the same job is already running -- Misfire: missed beats while Host was down are **not** replayed - -### License - -MIT +MIT (upstream MIT retained). diff --git a/cordis.patch.yml b/cordis.patch.yml index db093ae..6a4e8c1 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,4 +1,5 @@ -# Insert this plugin into a profile's layer stack. +# Insert this ops cron plugin into a profile's layer stack. +# Do not install upstream @dsh-external/dsh-cron-tasks alongside this package. - insert: - - id: dsh-cron-tasks - name: '@dsh-external/dsh-cron-tasks' + - id: dsh-ops-cron + name: dsh-ops-cron diff --git a/lib/client.js b/lib/client.js index a8c28bd..9133612 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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 diff --git a/lib/delivery.js b/lib/delivery.js new file mode 100644 index 0000000..25cbf28 --- /dev/null +++ b/lib/delivery.js @@ -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} 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 } +} diff --git a/lib/fire.js b/lib/fire.js index ef263a4..1c32113 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -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, diff --git a/lib/host.js b/lib/host.js index 63154f6..c7c34a7 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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) diff --git a/lib/index.d.ts b/lib/index.d.ts index c96af76..f117b0a 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -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 +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 diff --git a/lib/index.js b/lib/index.js index a8bb3b1..ac61847 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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') } diff --git a/lib/isolation.js b/lib/isolation.js index 44e6f73..05295c5 100644 --- a/lib/isolation.js +++ b/lib/isolation.js @@ -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 } diff --git a/lib/prompt.js b/lib/prompt.js index 2ceac62..bc0bdd4 100644 --- a/lib/prompt.js +++ b/lib/prompt.js @@ -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, diff --git a/lib/store.js b/lib/store.js index b1c0a4b..204c7c7 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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') } diff --git a/lib/tools.js b/lib/tools.js index ad85159..abb7a68 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -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() diff --git a/package.json b/package.json index 1a8fc17..533a13d 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,25 @@ { - "name": "@dsh-external/dsh-cron-tasks", - "description": "Scheduled tasks for DeepSeek Harness: sidebar entry under 新会话, plugin settings, and run history kept out of the workspace list.", + "name": "dsh-ops-cron", + "description": "Ops fork of dsh-cron-tasks: Agent cron_* tools + sidebar 运维定时, with DSH or WhatsApp/IM delivery per job.", "version": "0.1.0", - "private": true, + "private": false, "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/Whale-Zhang/dsh-cron-tasks.git" + "url": "git+https://github.com/hansjone/dsh-ops-cron.git" }, "bugs": { - "url": "https://github.com/Whale-Zhang/dsh-cron-tasks/issues" + "url": "https://github.com/hansjone/dsh-ops-cron/issues" }, - "homepage": "https://github.com/Whale-Zhang/dsh-cron-tasks#readme", + "homepage": "https://github.com/hansjone/dsh-ops-cron#readme", "keywords": [ "dsh-plugin", "dsh", "deepseek-harness", "cron", - "scheduled-tasks" + "scheduled-tasks", + "ops", + "whatsapp" ], "main": "lib/index.js", "types": "lib/index.d.ts", @@ -35,6 +37,7 @@ "cordis.patch.yml", "docs", "README.md", + "FORK.md", "LICENSE" ], "scripts": { diff --git a/test/delivery.test.js b/test/delivery.test.js new file mode 100644 index 0000000..07a9cf2 --- /dev/null +++ b/test/delivery.test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + matchTargetForPeer, + normalizeDelivery, + resolveCreateDelivery, + deliverRunToIm, +} from '../lib/delivery.js' + +test('normalizeDelivery defaults to dsh', () => { + assert.deepEqual(normalizeDelivery({}), { kind: 'dsh' }) +}) + +test('normalizeDelivery requires botId+targetId for im', () => { + assert.throws(() => normalizeDelivery({ kind: 'im', botId: 'b' }), /im delivery/) + assert.deepEqual( + normalizeDelivery({ kind: 'im', botId: 'bot_1', targetId: 'alerts' }), + { kind: 'im', botId: 'bot_1', targetId: 'alerts' }, + ) +}) + +test('matchTargetForPeer matches whatsapp jid from conversationKey', () => { + const peer = { botId: 'bot_1', conversationKey: 'direct:8613800000000@s.whatsapp.net' } + const targets = [ + { targetId: 'other', route: { jid: '1@s.whatsapp.net' } }, + { targetId: 'alerts', route: { jid: '8613800000000@s.whatsapp.net' } }, + ] + assert.equal(matchTargetForPeer(targets, peer).targetId, 'alerts') +}) + +test('resolveCreateDelivery uses explicit im args', async () => { + const delivery = await resolveCreateDelivery({ + delivery: 'im', + im_bot_id: 'bot_x', + im_target_id: 'tgt_y', + }, {}) + assert.deepEqual(delivery, { kind: 'im', botId: 'bot_x', targetId: 'tgt_y' }) +}) + +test('resolveCreateDelivery auto-matches peer target', async () => { + const dshIm = { + resolveSessionPeer: async () => ({ + botId: 'bot_1', + conversationKey: 'direct:86138@s.whatsapp.net', + }), + listTargets: async () => ([ + { targetId: 'wa-dm', route: { jid: '86138@s.whatsapp.net' } }, + ]), + } + const delivery = await resolveCreateDelivery({}, { + agent: { session: { id: 'sess-1' } }, + }, { dshIm }) + assert.deepEqual(delivery, { kind: 'im', botId: 'bot_1', targetId: 'wa-dm' }) +}) + +test('deliverRunToIm sends via dshIm', async () => { + const sent = [] + await deliverRunToIm( + { name: 'ping', delivery: { kind: 'im', botId: 'b', targetId: 't' } }, + 'hello world', + { dshIm: { send: async (...args) => { sent.push(args) } } }, + ) + assert.equal(sent.length, 1) + assert.equal(sent[0][0], 'b') + assert.equal(sent[0][1], 't') + assert.match(sent[0][2], /运维定时/) + assert.match(sent[0][2], /hello world/) +}) diff --git a/test/host.test.js b/test/host.test.js index 9dd1e5c..89c263a 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -89,7 +89,7 @@ async function jsonRequest(base, path, options = {}) { } test('host service: create, list, run-now, history, and workspace isolation', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] @@ -163,7 +163,7 @@ test('host service: create, list, run-now, history, and workspace isolation', as }) test('overlap skip writes a skipped history row instead of a second session', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) let clock = Date.parse('2026-08-24T01:00:00.000Z') let inflight = 0 @@ -192,7 +192,7 @@ test('overlap skip writes a skipped history row instead of a second session', as }) test('shipped HTTP handler: create, list, run-now, history', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] const service = createHostService({ @@ -210,7 +210,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => { const http = await listen(service) t.after(() => http.close()) - const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', { + const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { method: 'POST', body: JSON.stringify({ name: 'http job', @@ -222,23 +222,23 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => { assert.equal(created.body.job.name, 'http job') const jobId = created.body.job.id - const listed = await jsonRequest(http.url, '/dsh-cron-tasks/jobs') + const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs') assert.equal(listed.body.jobs.length, 1) - const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${jobId}/run`, { method: 'POST' }) + const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, { method: 'POST' }) assert.equal(ran.status, 200) assert.equal(ran.body.run.sessionId, 'http-sess-1') assert.ok(['succeeded', 'running'].includes(ran.body.run.status)) assert.deepEqual(archived, ['http-sess-1']) - const history = await jsonRequest(http.url, '/dsh-cron-tasks/history') + const history = await jsonRequest(http.url, '/dsh-ops-cron/history') assert.ok(history.body.runs.some((row) => row.sessionId === 'http-sess-1')) - const visible = await jsonRequest(http.url, '/dsh-cron-tasks/workspace-visible?id=http-sess-1&id=other') + const visible = await jsonRequest(http.url, '/dsh-ops-cron/workspace-visible?id=http-sess-1&id=other') assert.deepEqual(visible.body.visible, ['other']) }) test('POST /runs/:id/open reveals the run session id', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const revealed = [] const service = createHostService({ @@ -253,7 +253,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => { }) const http = await listen(service) t.after(() => http.close()) - const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', { + const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { method: 'POST', body: JSON.stringify({ name: 'open-job', @@ -261,8 +261,8 @@ test('POST /runs/:id/open reveals the run session id', async (t) => { schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' }, }), }) - const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' }) - const opened = await jsonRequest(http.url, `/dsh-cron-tasks/runs/${ran.body.run.id}/open`, { method: 'POST' }) + const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' }) + const opened = await jsonRequest(http.url, `/dsh-ops-cron/runs/${ran.body.run.id}/open`, { method: 'POST' }) assert.equal(opened.status, 200) assert.equal(opened.body.sessionId, 'open-sess') assert.deepEqual(revealed, ['open-sess']) @@ -330,7 +330,7 @@ test('resolveDefaultModel fails loud when Models has no selection', async () => }) test('POST /conceal archives every run session id', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] const service = createHostService({ @@ -345,7 +345,7 @@ test('POST /conceal archives every run session id', async (t) => { }) const http = await listen(service) t.after(() => http.close()) - const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', { + const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { method: 'POST', body: JSON.stringify({ name: 'hide-job', @@ -353,15 +353,15 @@ test('POST /conceal archives every run session id', async (t) => { schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' }, }), }) - await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' }) + await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' }) archived.length = 0 - const hidden = await jsonRequest(http.url, '/dsh-cron-tasks/conceal', { method: 'POST' }) + const hidden = await jsonRequest(http.url, '/dsh-ops-cron/conceal', { method: 'POST' }) assert.equal(hidden.status, 200) assert.ok(archived.includes('hide-sess')) }) test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const at = Date.parse('2026-08-23T14:57:00.000Z') let clock = at - 60_000 @@ -508,7 +508,7 @@ test('listModelChoices maps llm providers and the current default', async () => }) test('GET /models lists session-port catalog', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const service = createHostService({ filePath: join(dir, 'store.json'), @@ -520,7 +520,7 @@ test('GET /models lists session-port catalog', async (t) => { }) const http = await listen(service) t.after(() => http.close()) - const res = await jsonRequest(http.url, '/dsh-cron-tasks/models') + const res = await jsonRequest(http.url, '/dsh-ops-cron/models') assert.equal(res.status, 200) assert.equal(res.body.current.model, 'deepseek-chat') assert.equal(res.body.groups[0].models[0].id, 'deepseek-chat') @@ -547,7 +547,7 @@ test('listWorkspaceChoices maps registry entries to title and path', () => { }) test('GET /workspaces lists session-port choices', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const service = createHostService({ filePath: join(dir, 'store.json'), @@ -559,13 +559,13 @@ test('GET /workspaces lists session-port choices', async (t) => { }) const http = await listen(service) t.after(() => http.close()) - const res = await jsonRequest(http.url, '/dsh-cron-tasks/workspaces') + const res = await jsonRequest(http.url, '/dsh-ops-cron/workspaces') assert.equal(res.status, 200) assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }]) }) test('POST /sessions/:id/adopt uses the session port', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const adopted = [] const service = createHostService({ @@ -579,7 +579,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => { }) const http = await listen(service) t.after(() => http.close()) - const res = await jsonRequest(http.url, '/dsh-cron-tasks/sessions/child-9/adopt', { method: 'POST' }) + const res = await jsonRequest(http.url, '/dsh-ops-cron/sessions/child-9/adopt', { method: 'POST' }) assert.equal(res.status, 200) assert.equal(res.body.attached, true) assert.deepEqual(adopted, ['child-9']) @@ -598,7 +598,7 @@ test('waitForAgentTurn waits for running then idle, and fails if the agent never }) test('after one live-shaped dispatch, the next run-now still fires', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived)) @@ -632,7 +632,7 @@ test('after one live-shaped dispatch, the next run-now still fires', async (t) = }) test('after a live-shaped due tick settles, the next due occurrence still fires', async (t) => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-')) + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] let clock = Date.parse('2026-08-24T01:00:00.000Z') diff --git a/test/package.test.js b/test/package.test.js index 6cc6eb4..f9bdc0d 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -8,18 +8,19 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..') test('installable bundle declares host apply, client half, unique id, and no @deepseek-ai runtime deps', async () => { const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) - assert.equal(pkg.name, '@dsh-external/dsh-cron-tasks') + assert.equal(pkg.name, 'dsh-ops-cron') assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml') assert.equal(pkg.dsh.client.platform, 'web') assert.ok(pkg.exports['./client']) assert.equal(pkg.main, 'lib/index.js') assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/'))) const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8') - assert.match(patch, /id:\s*dsh-cron-tasks/) + assert.match(patch, /id:\s*dsh-ops-cron/) const index = await readFile(join(root, 'lib/index.js'), 'utf8') assert.match(index, /export function apply\(/) assert.match(index, /registerCronTools/) assert.match(index, /inject\(\['tools'\]/) + assert.match(index, /getDshIm/) const tools = await readFile(join(root, 'lib/tools.js'), 'utf8') assert.match(tools, /name: 'cron_create'/) assert.match(tools, /name: 'cron_list'/) @@ -28,9 +29,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de assert.match(tools, /hour/) assert.match(tools, /Current local time/) assert.match(tools, /source: 'runtime'/) - assert.match(tools, /current session cwd/) + assert.match(tools, /resolveCreateDelivery/) + assert.match(tools, /im_bot_id/) assert.match(tools, /resolveCreateCwd/) - assert.match(tools, /Working directory: if the user is chatting in a workspace/) assert.match(tools, /after_minutes/) assert.match(tools, /Extra schedule fields are ignored/) assert.doesNotMatch(tools, /pass only one of expr, at, or hour/) @@ -40,80 +41,26 @@ test('installable bundle declares host apply, client half, unique id, and no @de assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//) const host = await readFile(join(root, 'lib/host.js'), 'utf8') assert.match(host, /agentDefaultModel/) - assert.match(host, /system-prompt\/assemble/) - assert.match(host, /agentOptions/) - assert.match(host, /nextRunAt === null/) + assert.match(host, /deliverRunToIm/) + assert.match(host, /PLUGIN_NAME = 'dsh-ops-cron'/) + assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/) assert.match(host, /resolveSessionPlacement/) - assert.match(host, /adoptSessionIntoWorkspace/) - assert.match(host, /attachLiveSessionToWorkspace/) - assert.match(host, /listWorkspaceChoices/) - assert.match(host, /\/workspaces/) - assert.match(host, /listModelChoices/) - assert.match(host, /resolveJobModel/) assert.match(host, /\/models/) assert.doesNotMatch(host, /from ['"]@deepseek-ai\//) const client = await readFile(join(root, 'lib/client.js'), 'utf8') - assert.match(client, /定时任务/) + assert.match(client, /运维定时/) + assert.match(client, /dsh-ops-cron/) assert.match(client, /settings\.plugin\.item/) - assert.match(client, /新建会话/) - assert.match(client, /logoRow/) - assert.match(client, /dsh-ct-entry/) - assert.match(client, /centerCol/) - assert.match(client, /data-dsh-ct-host/) - assert.match(client, /_collapsed"\]\[data-dsh-ct-mode=on\] \.dsh-ct-region/) - assert.match(client, /listRoot\.parentElement !== region\) region\.appendChild\(listRoot\)/) - assert.match(client, /jobStamp/) - assert.doesNotMatch(client, /\[data-dsh-ct-mode=on\] \.dsh-ct-region\{display:flex !important;animation:/) - assert.match(client, /visibility:hidden/) - assert.match(client, /createPortal\(editor, paneHost\)/) - assert.match(client, /sidebarCollapsed/) - assert.match(client, /_collapsed/) - assert.match(client, /width:36px/) - assert.match(client, /data-collapsed/) - assert.match(client, /translate\(-50%, -50%\)/) - assert.match(client, /返回工作区/) - assert.match(client, /runs\/\$\{run\.id\}\/open/) - assert.match(client, /regionArea/) - assert.match(client, /sessions\.open\(/) - assert.match(client, /getService\(ctx, faces, 'sessions'\)/) - assert.match(client, /workspaceSkin/) - assert.match(client, /IconFolderOpen16/) - assert.match(client, /IconProjectAddOutline16/) - assert.match(client, /IconPlayOutline16/) - assert.match(client, /IconPauseOutline16/) - assert.match(client, /IconSearchOutline16/) - assert.match(client, /searchPlaceholder/) - assert.match(client, /\/conceal/) - assert.match(client, /shouldPromoteCronFork/) - assert.match(client, /workspaceTitleFromCronFork/) - assert.match(client, /leaveCronMode/) - assert.match(client, /fork rename failed/) - assert.match(client, /sessions\/\$\{encodeURIComponent\(row\.id\)\}\/adopt/) - assert.match(client, /sessions\.length > 0 && visible\.length === 0/) - assert.match(client, /未分组|未分类/) - assert.match(client, /expanded\[job\.id\] === true/) - assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/) - assert.doesNotMatch(client, /inject = \[[^\]]*sessions[^\]]*\]/) - assert.match(client, /工作目录/) - assert.match(client, /cwdRecent/) - assert.match(client, /cwdCustom/) + assert.match(client, /deliveryKind/) + assert.match(client, /imBotId/) + assert.match(client, /imTargetId/) assert.match(client, /CwdField/) assert.match(client, /ModelField/) - assert.match(client, /api\('\/models'\)/) - assert.match(tools, /resolveCreateModel/) - assert.match(tools, /snapshots the current session model/) - assert.match(client, /api\('\/workspaces'\)/) - assert.match(client, /atToLocalInput/) - assert.match(client, /localInputToAt/) + assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/) + const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8') + assert.match(delivery, /resolveCreateDelivery/) + assert.match(delivery, /deliverRunToIm/) const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8') - assert.match(prd, /左侧侧边栏「新会话」按钮正下方的「定时任务」入口/) - assert.match(prd, /设置页「插件配置」里的定时任务设置/) - assert.match(prd, /定时任务历史/) - assert.match(prd, /chicheng-cron/) - assert.match(prd, /dsh-automation/) - assert.match(prd, /cronjob-dsh-plugin/) - assert.match(prd, /dsh-cron/) - assert.match(prd, /dsh-schedule-tasks/) + assert.match(prd, /定时任务/) assert.match(prd, /dsh-schedule/) - assert.match(prd, /非目标/) })