opencode-natural-scheduler 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lrj01232
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # opencode-natural-scheduler
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Natural-language scheduled tasks for [OpenCode](https://opencode.ai). Ask for a recurring or one-off task in plain language, and the plugin schedules it, then injects the task back into the session that created it.
4
+
5
+ ```
6
+ You: every 20 minutes, check the invoice service error log and summarize anomalies
7
+ Agent: (calls schedule_create) Scheduled task created: task-... next run 2026-11-01 12:30
8
+ ```
9
+
10
+ ## Why another scheduler?
11
+
12
+ | | opencode-natural-scheduler | opencode-scheduler | opencode-cron |
13
+ |---|---|---|---|
14
+ | Create tasks by asking | Yes | Cron config | Cron config |
15
+ | Runs while OpenCode is closed | No | Yes (launchd/systemd) | No |
16
+ | Cross-platform | Yes | macOS/Linux | Yes |
17
+ | Injects into the originating session | Yes | Job-defined | Yes |
18
+ | OS daemon/timer setup | None | Required | None |
19
+
20
+ This plugin is intentionally in-process: no launchd, systemd, or Windows service. It is the simplest option when you want conversational scheduling inside a running OpenCode instance.
21
+
22
+ ## Install
23
+
24
+ Add the package to your OpenCode config:
25
+
26
+ ```json
27
+ {
28
+ "$schema": "https://opencode.ai/config.json",
29
+ "plugin": ["opencode-natural-scheduler"]
30
+ }
31
+ ```
32
+
33
+ Or run:
34
+
35
+ ```bash
36
+ opencode plugin opencode-natural-scheduler
37
+ ```
38
+
39
+ Restart OpenCode after installing. The plugin starts a scheduler for each OpenCode process and elects a single leader, so multiple open windows do not fire the same task twice.
40
+
41
+ ## Usage
42
+
43
+ Just describe the task and the schedule:
44
+
45
+ - `Every 20 minutes, check the invoice module error log and report anomalies.`
46
+ - `Every day at 09:00, summarize yesterday's failed jobs.`
47
+ - `Remind me in 30 minutes to review the deploy.`
48
+ - `Run this at 18:00 on weekdays: collect the daily metrics.`
49
+
50
+ The agent calls `schedule_create` with a self-contained instruction, then confirms the task id and next run time.
51
+
52
+ Management is conversational too:
53
+
54
+ - `List my scheduled tasks.`
55
+ - `Pause task-abc123.`
56
+ - `Change task-abc123 to every hour.`
57
+ - `Run task-abc123 now.`
58
+ - `Delete task-abc123.`
59
+
60
+ ## Schedule types
61
+
62
+ | Type | Tool argument | Example |
63
+ |---|---|---|
64
+ | Interval | `every_minutes` | `every_minutes: 20` |
65
+ | Daily | `daily_at` | `daily_at: "09:30"` |
66
+ | Cron | `cron` | `cron: "0 9 * * 1-5"` |
67
+ | One-off | `once_at` or `once_in_minutes` | `once_in_minutes: 30` |
68
+
69
+ Cron uses five fields (`minute hour day month weekday`) in the machine's local timezone. When both day-of-month and weekday are restricted, either match counts, as in standard cron.
70
+
71
+ ## Tools
72
+
73
+ The plugin registers five tools:
74
+
75
+ - `schedule_create`
76
+ - `schedule_list`
77
+ - `schedule_update`
78
+ - `schedule_delete`
79
+ - `schedule_run_now`
80
+
81
+ Tools default to the current session. Pass `session_id` to target another session, or `all: true` to list every session's tasks.
82
+
83
+ ## Behavior details
84
+
85
+ - A task is bound to the session where it was created. When it fires, the plugin calls `session.promptAsync` on that session with a message starting with `[Scheduled task]`.
86
+ - If the target session is busy, the run is deferred by one minute.
87
+ - If the target session no longer exists, the task is disabled and the last error is recorded.
88
+ - One-off tasks are disabled after they fire.
89
+ - Task state, run counters, and the last error persist across restarts.
90
+ - Tasks only fire while at least one OpenCode process is running.
91
+
92
+ ## Storage
93
+
94
+ Default location:
95
+
96
+ ```
97
+ ~/.local/share/opencode/scheduled-tasks/tasks.json
98
+ ```
99
+
100
+ On Windows:
101
+
102
+ ```
103
+ C:\Users\<you>\.local\share\opencode\scheduled-tasks\tasks.json
104
+ ```
105
+
106
+ `leader.json` in the same directory is a short-lived lease used to avoid duplicate firing across OpenCode instances.
107
+
108
+ ## Configuration
109
+
110
+ | Environment variable | Default | Purpose |
111
+ |---|---|---|
112
+ | `OPENCODE_SCHEDULER_DIR` | `~/.local/share/opencode/scheduled-tasks` | Store directory |
113
+ | `OPENCODE_SCHEDULER_LOCALE` | System locale | `zh` or `en`; controls tool messages and injected instructions |
114
+ | `OPENCODE_SCHEDULER_TICK_MS` | `15000` | Scheduler tick interval in milliseconds |
115
+
116
+ ## Security
117
+
118
+ A scheduled task runs the instruction you asked for, with the permissions of the session that created it. Only create tasks whose instructions you trust, and review the task text when it is created.
119
+
120
+ ## Development
121
+
122
+ Requires Node.js 20 or newer.
123
+
124
+ ```bash
125
+ npm install
126
+ npm test
127
+ npm run check
128
+ ```
129
+
130
+ The test suite uses a temporary store directory and a fast scheduler tick. It covers cron/daily parsing, task creation and management, manual runs, and the scheduler firing path.
131
+
132
+ ## License
133
+
134
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,134 @@
1
+ # opencode-natural-scheduler
2
+
3
+ 给 [OpenCode](https://opencode.ai) 用的自然语言定时任务插件。你只要说清楚"做什么、多久一次",插件就会创建任务,并在到点时把任务指令注入回创建任务的那个会话。
4
+
5
+ ```
6
+ 你:每 20 分钟检查一次发票模块的错误日志,有异常就分析根因
7
+ Agent:(调用 schedule_create)已创建定时任务 task-...,下次运行 2026-11-01 12:30
8
+ ```
9
+
10
+ ## 和其他调度插件的区别
11
+
12
+ | | opencode-natural-scheduler | opencode-scheduler | opencode-cron |
13
+ |---|---|---|---|
14
+ | 自然语言创建任务 | 支持 | cron 配置 | cron 配置 |
15
+ | OpenCode 关闭后仍运行 | 不支持 | 支持(launchd/systemd) | 不支持 |
16
+ | 跨平台 | 支持 | macOS/Linux | 支持 |
17
+ | 注入回原会话 | 支持 | 由任务定义 | 支持 |
18
+ | 需要系统级守护进程 | 不需要 | 需要 | 不需要 |
19
+
20
+ 这个插件刻意做成进程内调度,不碰 launchd、systemd 或 Windows 服务。适合"OpenCode 开着时,按对话创建任务"的场景。
21
+
22
+ ## 安装
23
+
24
+ 在 OpenCode 配置里加入:
25
+
26
+ ```json
27
+ {
28
+ "$schema": "https://opencode.ai/config.json",
29
+ "plugin": ["opencode-natural-scheduler"]
30
+ }
31
+ ```
32
+
33
+ 或者:
34
+
35
+ ```bash
36
+ opencode plugin opencode-natural-scheduler
37
+ ```
38
+
39
+ 安装后重启 OpenCode。多个 OpenCode 进程会通过 `leader.json` 选出唯一调度者,避免同一个任务被重复触发。
40
+
41
+ ## 使用
42
+
43
+ 直接描述任务和时间:
44
+
45
+ - `每 20 分钟检查一次发票模块的错误日志并汇报异常。`
46
+ - `每天 09:00 汇总昨天失败的作业。`
47
+ - `30 分钟后提醒我 review 部署。`
48
+ - `工作日 18:00 收集当天指标。`
49
+
50
+ 模型会调用 `schedule_create`,并告诉你任务 id 和下次运行时间。
51
+
52
+ 管理任务也用自然语言:
53
+
54
+ - `列出我的定时任务。`
55
+ - `暂停 task-abc123。`
56
+ - `把 task-abc123 改成每小时一次。`
57
+ - `立即执行 task-abc123。`
58
+ - `删除 task-abc123。`
59
+
60
+ ## 支持的频率
61
+
62
+ | 类型 | 工具参数 | 示例 |
63
+ |---|---|---|
64
+ | 固定间隔 | `every_minutes` | `every_minutes: 20` |
65
+ | 每天 | `daily_at` | `daily_at: "09:30"` |
66
+ | Cron | `cron` | `cron: "0 9 * * 1-5"` |
67
+ | 一次性 | `once_at` 或 `once_in_minutes` | `once_in_minutes: 30` |
68
+
69
+ Cron 为 5 字段(分 时 日 月 周),使用本机时区。日和周同时受限时,任一匹配即可,符合标准 cron 语义。
70
+
71
+ ## 工具
72
+
73
+ 插件注册五个工具:
74
+
75
+ - `schedule_create`
76
+ - `schedule_list`
77
+ - `schedule_update`
78
+ - `schedule_delete`
79
+ - `schedule_run_now`
80
+
81
+ 默认作用于当前会话;传 `session_id` 可指定其他会话,`all: true` 可列出全部任务。
82
+
83
+ ## 行为细节
84
+
85
+ - 任务绑定创建它的会话。触发时调用 `session.promptAsync`,注入以 `[定时任务触发]` 开头的消息。
86
+ - 目标会话正忙时,延后 1 分钟重试。
87
+ - 目标会话已删除时,任务自动停用并记录错误。
88
+ - 一次性任务执行后自动停用。
89
+ - 任务状态、运行次数、最近错误都会持久化。
90
+ - 至少有一个 OpenCode 进程在运行时,任务才会触发。
91
+
92
+ ## 存储位置
93
+
94
+ 默认:
95
+
96
+ ```
97
+ ~/.local/share/opencode/scheduled-tasks/tasks.json
98
+ ```
99
+
100
+ Windows:
101
+
102
+ ```
103
+ C:\Users\<你>\.local\share\opencode\scheduled-tasks\tasks.json
104
+ ```
105
+
106
+ 同目录下的 `leader.json` 是短期租约,用于多实例互斥。
107
+
108
+ ## 环境变量
109
+
110
+ | 变量 | 默认值 | 作用 |
111
+ |---|---|---|
112
+ | `OPENCODE_SCHEDULER_DIR` | `~/.local/share/opencode/scheduled-tasks` | 数据目录 |
113
+ | `OPENCODE_SCHEDULER_LOCALE` | 系统语言 | `zh` 或 `en`,控制工具消息和注入指令 |
114
+ | `OPENCODE_SCHEDULER_TICK_MS` | `15000` | 调度轮询间隔(毫秒) |
115
+
116
+ ## 安全
117
+
118
+ 定时任务会按创建它的会话权限执行你写下的指令。只创建你信任的任务,并在创建时确认任务内容。
119
+
120
+ ## 开发
121
+
122
+ 需要 Node.js 20 或更新版本。
123
+
124
+ ```bash
125
+ npm install
126
+ npm test
127
+ npm run check
128
+ ```
129
+
130
+ 测试使用临时数据目录和更快的调度间隔,覆盖 cron/每日解析、任务创建与管理、立即执行、以及调度触发链路。
131
+
132
+ ## License
133
+
134
+ MIT
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "opencode-natural-scheduler",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Natural-language scheduled tasks for OpenCode: create recurring or one-off tasks by asking, with prompts injected back into the originating session.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "README.zh.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "scheduler",
20
+ "scheduled-tasks",
21
+ "cron",
22
+ "automation",
23
+ "natural-language"
24
+ ],
25
+ "author": "lrj01232",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/lrj01232/opencode-natural-scheduler.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/lrj01232/opencode-natural-scheduler/issues"
33
+ },
34
+ "homepage": "https://github.com/lrj01232/opencode-natural-scheduler#readme",
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "peerDependencies": {
39
+ "@opencode-ai/plugin": ">=1.18.0"
40
+ },
41
+ "devDependencies": {
42
+ "@opencode-ai/plugin": "^1.18.31"
43
+ },
44
+ "scripts": {
45
+ "test": "node --test test",
46
+ "check": "node --check src/core.js && node --check src/index.js",
47
+ "prepublishOnly": "npm test"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
package/src/core.js ADDED
@@ -0,0 +1,686 @@
1
+ import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ const SERVICE = "natural-scheduler"
6
+ const DEFAULT_TICK_MS = 15_000
7
+ const TICK_MS = readPositiveInt(process.env.OPENCODE_SCHEDULER_TICK_MS, DEFAULT_TICK_MS)
8
+ const LEASE_MS = Math.max(90_000, TICK_MS * 3)
9
+ const RETRY_MS = 60_000
10
+ const INSTANCE_ID = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
11
+ const LOCALE = (process.env.OPENCODE_SCHEDULER_LOCALE
12
+ || new Intl.DateTimeFormat().resolvedOptions().locale
13
+ || "en").toLowerCase()
14
+ const ZH = LOCALE.startsWith("zh")
15
+
16
+ function t(zh, en) {
17
+ return ZH ? zh : en
18
+ }
19
+
20
+ function readPositiveInt(value, fallback) {
21
+ const number = Number(value)
22
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback
23
+ }
24
+
25
+ export const INSTRUCTIONS = ZH
26
+ ? `## 定时任务
27
+ 本环境已加载 natural-scheduler 插件,提供 schedule_create / schedule_list / schedule_update / schedule_delete / schedule_run_now 工具。
28
+ 当用户提出"定时/每隔/每天/定期/到点/提醒我做…"等需求时,不要只回复计划,必须立即调用 schedule_create:从用户话中提取完整任务指令和频率;缺少关键信息时只问一个最短的问题。
29
+ 用户想查看、暂停、修改或删除任务时,使用 schedule_list / schedule_update / schedule_delete。
30
+ 定时任务触发时,消息以 "[定时任务触发]" 开头;直接执行其中的任务,不要再次创建定时任务,也不要询问是否继续。`
31
+ : `## Scheduled tasks
32
+ The natural-scheduler plugin is enabled and provides schedule_create / schedule_list / schedule_update / schedule_delete / schedule_run_now.
33
+ When the user asks for a recurring or timed action ("every 20 minutes", "daily at 9", "remind me", "run this later"), do not just describe a plan: immediately call schedule_create, extracting a self-contained task instruction and the schedule. Ask at most one short clarifying question if the frequency or task content is missing.
34
+ Use schedule_list / schedule_update / schedule_delete when the user wants to inspect, pause, modify, or remove tasks.
35
+ When a scheduled task fires, the message starts with "[Scheduled task]"; execute it directly, do not create another scheduled task, and do not ask for confirmation.`
36
+
37
+ function pad(value) {
38
+ return String(value).padStart(2, "0")
39
+ }
40
+
41
+ export function localStamp(ts) {
42
+ const date = new Date(ts)
43
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
44
+ }
45
+
46
+ function asInt(value, name, min = 1, max = Number.MAX_SAFE_INTEGER) {
47
+ const number = Number(value)
48
+ if (!Number.isInteger(number) || number < min || number > max) {
49
+ throw new Error(t(
50
+ `${name} 必须是 ${min}-${max} 之间的整数`,
51
+ `${name} must be an integer between ${min} and ${max}`,
52
+ ))
53
+ }
54
+ return number
55
+ }
56
+
57
+ function parseCronField(text, min, max, name) {
58
+ const values = new Set()
59
+ for (const chunk of String(text).split(",")) {
60
+ if (!chunk) throw new Error(t(`cron ${name} 字段为空`, `cron ${name} field is empty`))
61
+ const pieces = chunk.split("/")
62
+ if (pieces.length > 2) {
63
+ throw new Error(t(`cron ${name} 字段无效: ${chunk}`, `invalid cron ${name} field: ${chunk}`))
64
+ }
65
+ const rangePart = pieces[0]
66
+ const step = pieces[1] === undefined ? 1 : asInt(pieces[1], t(`${name} 步长`, `${name} step`))
67
+ let lo
68
+ let hi
69
+ if (rangePart === "*") {
70
+ lo = min
71
+ hi = max
72
+ } else if (rangePart.includes("-")) {
73
+ const bounds = rangePart.split("-")
74
+ if (bounds.length !== 2) {
75
+ throw new Error(t(`cron ${name} 范围无效: ${rangePart}`, `invalid cron ${name} range: ${rangePart}`))
76
+ }
77
+ lo = asInt(bounds[0], name, min, max)
78
+ hi = asInt(bounds[1], name, min, max)
79
+ if (lo > hi) {
80
+ throw new Error(t(`cron ${name} 范围颠倒: ${rangePart}`, `cron ${name} range is reversed: ${rangePart}`))
81
+ }
82
+ } else {
83
+ lo = asInt(rangePart, name, min, max)
84
+ hi = pieces[1] === undefined ? lo : max
85
+ }
86
+ for (let value = lo; value <= hi; value += step) values.add(value)
87
+ }
88
+ if (values.size === 0) {
89
+ throw new Error(t(`cron ${name} 字段没有匹配值`, `cron ${name} field matches no values`))
90
+ }
91
+ return { values, wildcard: String(text).trim() === "*" }
92
+ }
93
+
94
+ export function parseCron(expression) {
95
+ const parts = String(expression).trim().split(/\s+/)
96
+ if (parts.length !== 5) {
97
+ throw new Error(t("cron 必须是 5 个字段:分 时 日 月 周", "cron must have 5 fields: minute hour day month weekday"))
98
+ }
99
+ return {
100
+ minute: parseCronField(parts[0], 0, 59, t("分钟", "minute")),
101
+ hour: parseCronField(parts[1], 0, 23, t("小时", "hour")),
102
+ day: parseCronField(parts[2], 1, 31, t("日", "day")),
103
+ month: parseCronField(parts[3], 1, 12, t("月", "month")),
104
+ weekday: parseCronField(parts[4], 0, 7, t("周", "weekday")),
105
+ }
106
+ }
107
+
108
+ export function cronMatches(cron, date) {
109
+ if (!cron.minute.values.has(date.getMinutes())) return false
110
+ if (!cron.hour.values.has(date.getHours())) return false
111
+ if (!cron.month.values.has(date.getMonth() + 1)) return false
112
+ const dayOk = cron.day.values.has(date.getDate())
113
+ const weekday = date.getDay()
114
+ const weekdayOk = cron.weekday.values.has(weekday) || (weekday === 0 && cron.weekday.values.has(7))
115
+ if (!cron.day.wildcard && !cron.weekday.wildcard) return dayOk || weekdayOk
116
+ return dayOk && weekdayOk
117
+ }
118
+
119
+ export function nextCron(expression, from) {
120
+ const cron = parseCron(expression)
121
+ const start = new Date(from)
122
+ start.setSeconds(0, 0)
123
+ start.setMinutes(start.getMinutes() + 1)
124
+ const limit = from + 366 * 24 * 60 * 60 * 1000
125
+ for (let ts = start.getTime(); ts <= limit; ts += 60_000) {
126
+ if (cronMatches(cron, new Date(ts))) return ts
127
+ }
128
+ throw new Error(t("cron 在未来一年内没有匹配时间", "cron has no matching time within the next year"))
129
+ }
130
+
131
+ function normalizeDaily(text) {
132
+ const match = /^(\d{1,2}):(\d{2})$/.exec(String(text).trim())
133
+ if (!match) {
134
+ throw new Error(t("daily_at 格式应为 HH:MM,例如 09:30", "daily_at must use HH:MM, for example 09:30"))
135
+ }
136
+ const hour = asInt(match[1], t("小时", "hour"), 0, 23)
137
+ const minute = asInt(match[2], t("分钟", "minute"), 0, 59)
138
+ return `${pad(hour)}:${pad(minute)}`
139
+ }
140
+
141
+ export function nextDaily(time, from) {
142
+ const [hour, minute] = time.split(":").map(Number)
143
+ const next = new Date(from)
144
+ next.setSeconds(0, 0)
145
+ next.setHours(hour, minute, 0, 0)
146
+ if (next.getTime() <= from) next.setDate(next.getDate() + 1)
147
+ return next.getTime()
148
+ }
149
+
150
+ function hasScheduleArgs(args) {
151
+ return [
152
+ args.every_minutes,
153
+ args.daily_at,
154
+ args.cron,
155
+ args.once_at,
156
+ args.once_in_minutes,
157
+ ].some((value) => value !== undefined && value !== null && value !== "")
158
+ }
159
+
160
+ export function parseScheduleArgs(args) {
161
+ const given = [
162
+ ["every_minutes", args.every_minutes],
163
+ ["daily_at", args.daily_at],
164
+ ["cron", args.cron],
165
+ ["once_at", args.once_at],
166
+ ["once_in_minutes", args.once_in_minutes],
167
+ ].filter(([, value]) => value !== undefined && value !== null && value !== "")
168
+
169
+ if (given.length !== 1) {
170
+ throw new Error(t(
171
+ "必须且只能指定一种频率:every_minutes / daily_at / cron / once_at / once_in_minutes",
172
+ "specify exactly one schedule: every_minutes / daily_at / cron / once_at / once_in_minutes",
173
+ ))
174
+ }
175
+
176
+ const [key, value] = given[0]
177
+ if (key === "every_minutes") return { type: "interval", minutes: asInt(value, "every_minutes") }
178
+ if (key === "daily_at") return { type: "daily", time: normalizeDaily(value) }
179
+ if (key === "cron") {
180
+ parseCron(value)
181
+ return { type: "cron", expression: String(value).trim() }
182
+ }
183
+ if (key === "once_in_minutes") {
184
+ return { type: "once", at: Date.now() + asInt(value, "once_in_minutes") * 60_000 }
185
+ }
186
+ const at = Date.parse(String(value))
187
+ if (!Number.isFinite(at)) {
188
+ throw new Error(t(
189
+ "once_at 无法解析,请使用 ISO 时间,例如 2026-11-01T09:00:00+08:00",
190
+ "once_at is not parseable; use an ISO timestamp such as 2026-11-01T09:00:00+08:00",
191
+ ))
192
+ }
193
+ if (at <= Date.now()) throw new Error(t("once_at 必须晚于当前时间", "once_at must be in the future"))
194
+ return { type: "once", at }
195
+ }
196
+
197
+ export function computeNext(schedule, from) {
198
+ if (schedule.type === "interval") return from + schedule.minutes * 60_000
199
+ if (schedule.type === "daily") return nextDaily(schedule.time, from)
200
+ if (schedule.type === "cron") return nextCron(schedule.expression, from)
201
+ if (schedule.type === "once") return schedule.at
202
+ throw new Error(t(`未知频率类型: ${schedule.type}`, `unknown schedule type: ${schedule.type}`))
203
+ }
204
+
205
+ export function nextAfterRun(task, now) {
206
+ const schedule = task.schedule
207
+ if (schedule.type === "once") return null
208
+ if (schedule.type === "interval") {
209
+ let next = computeNext(schedule, task.nextRunAt)
210
+ while (next <= now) next += schedule.minutes * 60_000
211
+ return next
212
+ }
213
+ return computeNext(schedule, Math.max(now, task.nextRunAt))
214
+ }
215
+
216
+ export function scheduleLabel(schedule) {
217
+ if (schedule.type === "interval") return t(`每 ${schedule.minutes} 分钟`, `every ${schedule.minutes} min`)
218
+ if (schedule.type === "daily") return t(`每天 ${schedule.time}`, `daily at ${schedule.time}`)
219
+ if (schedule.type === "cron") return `cron ${schedule.expression}`
220
+ if (schedule.type === "once") return t(`单次 ${localStamp(schedule.at)}`, `once at ${localStamp(schedule.at)}`)
221
+ return t("未知", "unknown")
222
+ }
223
+
224
+ function storeDir() {
225
+ return process.env.OPENCODE_SCHEDULER_DIR
226
+ || process.env.OPENCODE_SCHEDULE_DIR
227
+ || path.join(os.homedir(), ".local", "share", "opencode", "scheduled-tasks")
228
+ }
229
+
230
+ function tasksFile() {
231
+ return path.join(storeDir(), "tasks.json")
232
+ }
233
+
234
+ function lockFile() {
235
+ return path.join(storeDir(), "leader.json")
236
+ }
237
+
238
+ function ensureDir() {
239
+ fs.mkdirSync(storeDir(), { recursive: true })
240
+ }
241
+
242
+ function readJson(file, fallback) {
243
+ try {
244
+ return JSON.parse(fs.readFileSync(file, "utf8"))
245
+ } catch {
246
+ return fallback
247
+ }
248
+ }
249
+
250
+ function writeJson(file, value) {
251
+ ensureDir()
252
+ const tmp = `${file}.${INSTANCE_ID}.tmp`
253
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2))
254
+ fs.renameSync(tmp, file)
255
+ }
256
+
257
+ export function readStore(file = tasksFile()) {
258
+ const value = readJson(file, null)
259
+ if (value && Array.isArray(value.tasks)) return value
260
+ return { version: 1, tasks: [] }
261
+ }
262
+
263
+ export function writeStore(store, file = tasksFile()) {
264
+ writeJson(file, store)
265
+ }
266
+
267
+ function pushRun(task, entry) {
268
+ if (!Array.isArray(task.runs)) task.runs = []
269
+ task.runs.push(entry)
270
+ if (task.runs.length > 50) task.runs = task.runs.slice(-50)
271
+ }
272
+
273
+ function taskLine(task) {
274
+ const state = task.enabled
275
+ ? t("开启", "enabled")
276
+ : task.done
277
+ ? t("已完成", "done")
278
+ : t("已暂停", "paused")
279
+ const next = task.nextRunAt ? localStamp(task.nextRunAt) : "-"
280
+ const last = task.lastRunAt ? localStamp(task.lastRunAt) : "-"
281
+ const status = task.lastStatus ? ` | ${t("上次状态", "last status")} ${task.lastStatus}` : ""
282
+ return `- ${task.id} | ${task.name} | ${state} | ${scheduleLabel(task.schedule)} | ${t("下次", "next")} ${next} | ${t("上次", "last")} ${last}${status}`
283
+ }
284
+
285
+ async function sessionBusy(client, sessionID) {
286
+ try {
287
+ const result = await client.session.status({})
288
+ const map = result?.data ?? result
289
+ const status = map?.[sessionID]
290
+ return status?.type === "busy" || status?.type === "retry"
291
+ } catch {
292
+ return false
293
+ }
294
+ }
295
+
296
+ function renderPrompt(task) {
297
+ return t(
298
+ `[定时任务触发] ${task.name}(${localStamp(Date.now())})
299
+
300
+ ${task.prompt}
301
+
302
+ (这是 natural-scheduler 插件自动注入的消息:请直接执行上面的任务,不要为此再次创建定时任务,也不要询问是否继续。完成后简要汇报结果。)`,
303
+ `[Scheduled task] ${task.name} (${localStamp(Date.now())})
304
+
305
+ ${task.prompt}
306
+
307
+ (This message was injected automatically by the natural-scheduler plugin. Execute the task directly; do not create another scheduled task and do not ask for confirmation. Report the result briefly.)`,
308
+ )
309
+ }
310
+
311
+ function findTask(store, id) {
312
+ const task = store.tasks.find((item) => item.id === id)
313
+ if (!task) throw new Error(t(`找不到定时任务: ${id}`, `scheduled task not found: ${id}`))
314
+ return task
315
+ }
316
+
317
+ function createTask(args, sessionID) {
318
+ const name = String(args.name ?? "").trim()
319
+ const prompt = String(args.prompt ?? "").trim()
320
+ if (!name) throw new Error(t("name 不能为空", "name must not be empty"))
321
+ if (!prompt) throw new Error(t("prompt 不能为空", "prompt must not be empty"))
322
+ const schedule = parseScheduleArgs(args)
323
+ const now = Date.now()
324
+ const task = {
325
+ id: `task-${now.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
326
+ name,
327
+ prompt,
328
+ schedule,
329
+ sessionID: String(args.session_id || sessionID),
330
+ ...(args.agent ? { agent: String(args.agent) } : {}),
331
+ enabled: true,
332
+ done: false,
333
+ createdAt: now,
334
+ updatedAt: now,
335
+ lastRunAt: null,
336
+ lastStatus: null,
337
+ lastError: null,
338
+ runCount: 0,
339
+ nextRunAt: computeNext(schedule, now),
340
+ runs: [],
341
+ }
342
+ const store = readStore()
343
+ store.tasks.push(task)
344
+ writeStore(store)
345
+ return task
346
+ }
347
+
348
+ export function createTools(tool, client) {
349
+ const scheduleCreate = tool({
350
+ description: t(
351
+ "创建一个定时任务:到点后在目标会话自动注入任务指令并执行。支持每隔 N 分钟、每天 HH:MM、5 字段 cron、一次性指定时间。",
352
+ "Create a scheduled task that injects a prompt into a target session when it fires. Supports intervals, daily times, 5-field cron, and one-off timestamps.",
353
+ ),
354
+ args: {
355
+ name: tool.schema.string().describe(t("任务名称,简短", "Short task name")),
356
+ prompt: tool.schema.string().describe(t(
357
+ "到点后要执行的完整任务指令,需自包含",
358
+ "Self-contained instruction to execute when the task fires",
359
+ )),
360
+ every_minutes: tool.schema.number().int().min(1).optional().describe(t("每隔多少分钟执行一次", "Run every N minutes")),
361
+ daily_at: tool.schema.string().regex(/^\d{1,2}:\d{2}$/).optional().describe(t("每天本地时间 HH:MM", "Run daily at local time HH:MM")),
362
+ cron: tool.schema.string().optional().describe(t("5 字段 cron:分 时 日 月 周", "5-field cron: minute hour day month weekday")),
363
+ once_at: tool.schema.string().optional().describe(t("一次性时间,ISO 8601", "One-off ISO 8601 timestamp")),
364
+ once_in_minutes: tool.schema.number().int().min(1).optional().describe(t("N 分钟后执行一次", "Run once after N minutes")),
365
+ agent: tool.schema.string().optional().describe(t("可选:执行时使用的 agent", "Optional agent name to run with")),
366
+ session_id: tool.schema.string().optional().describe(t("可选:目标任务会话,默认当前会话", "Optional target session; defaults to the current session")),
367
+ },
368
+ async execute(args, context) {
369
+ const task = createTask(args, context.sessionID)
370
+ return [
371
+ t("定时任务已创建", "Scheduled task created"),
372
+ `id: ${task.id}`,
373
+ `${t("名称", "name")}: ${task.name}`,
374
+ `${t("频率", "schedule")}: ${scheduleLabel(task.schedule)}`,
375
+ `${t("下次运行", "next run")}: ${localStamp(task.nextRunAt)}`,
376
+ `${t("目标会话", "target session")}: ${task.sessionID}`,
377
+ task.agent ? `agent: ${task.agent}` : null,
378
+ ].filter(Boolean).join("\n")
379
+ },
380
+ })
381
+
382
+ const scheduleList = tool({
383
+ description: t(
384
+ "列出定时任务及其状态、频率、下次运行时间。默认只列当前会话,all=true 列全部。",
385
+ "List scheduled tasks with state, schedule, and next run time. Defaults to the current session; pass all=true for every session.",
386
+ ),
387
+ args: {
388
+ all: tool.schema.boolean().optional().describe(t("是否列出所有会话的任务", "List tasks from every session")),
389
+ session_id: tool.schema.string().optional().describe(t("指定会话 id", "Session id to filter by")),
390
+ },
391
+ async execute(args, context) {
392
+ const store = readStore()
393
+ const sessionID = args.session_id || context.sessionID
394
+ const tasks = args.all ? store.tasks : store.tasks.filter((task) => task.sessionID === sessionID)
395
+ if (tasks.length === 0) return t("没有定时任务。", "No scheduled tasks.")
396
+ const sorted = [...tasks].sort((a, b) => (a.nextRunAt ?? Infinity) - (b.nextRunAt ?? Infinity))
397
+ return [
398
+ t(`定时任务(${args.all ? "全部" : "本会话"} ${tasks.length} 个)`, `Scheduled tasks (${args.all ? "all" : "this session"}: ${tasks.length})`),
399
+ ...sorted.map(taskLine),
400
+ ].join("\n")
401
+ },
402
+ })
403
+
404
+ const scheduleUpdate = tool({
405
+ description: t(
406
+ "修改定时任务:名称、指令、启停、执行 agent、目标会话或频率。",
407
+ "Update a scheduled task: name, prompt, enabled state, agent, target session, or schedule.",
408
+ ),
409
+ args: {
410
+ id: tool.schema.string().describe(t("任务 id", "Task id")),
411
+ name: tool.schema.string().optional(),
412
+ prompt: tool.schema.string().optional(),
413
+ agent: tool.schema.string().optional(),
414
+ session_id: tool.schema.string().optional(),
415
+ enabled: tool.schema.boolean().optional(),
416
+ every_minutes: tool.schema.number().int().min(1).optional(),
417
+ daily_at: tool.schema.string().regex(/^\d{1,2}:\d{2}$/).optional(),
418
+ cron: tool.schema.string().optional(),
419
+ once_at: tool.schema.string().optional(),
420
+ once_in_minutes: tool.schema.number().int().min(1).optional(),
421
+ },
422
+ async execute(args) {
423
+ const store = readStore()
424
+ const task = findTask(store, args.id)
425
+ const now = Date.now()
426
+ if (args.name !== undefined) {
427
+ const name = String(args.name).trim()
428
+ if (!name) throw new Error(t("name 不能为空", "name must not be empty"))
429
+ task.name = name
430
+ }
431
+ if (args.prompt !== undefined) {
432
+ const prompt = String(args.prompt).trim()
433
+ if (!prompt) throw new Error(t("prompt 不能为空", "prompt must not be empty"))
434
+ task.prompt = prompt
435
+ }
436
+ if (args.agent !== undefined) {
437
+ if (args.agent) task.agent = String(args.agent)
438
+ else delete task.agent
439
+ }
440
+ if (args.session_id !== undefined) task.sessionID = String(args.session_id)
441
+ if (hasScheduleArgs(args)) {
442
+ task.schedule = parseScheduleArgs(args)
443
+ task.done = false
444
+ task.enabled = args.enabled ?? true
445
+ task.nextRunAt = task.enabled ? computeNext(task.schedule, now) : null
446
+ } else if (args.enabled !== undefined) {
447
+ task.enabled = args.enabled
448
+ if (args.enabled) {
449
+ if (!task.nextRunAt || task.done) {
450
+ if (task.schedule.type === "once" && task.schedule.at <= now) {
451
+ throw new Error(t(
452
+ "该单次任务已过期,请提供新的 once_at 或其他频率",
453
+ "this one-off task has expired; provide a new once_at or another schedule",
454
+ ))
455
+ }
456
+ task.nextRunAt = computeNext(task.schedule, now)
457
+ task.done = false
458
+ }
459
+ } else {
460
+ task.nextRunAt = null
461
+ }
462
+ }
463
+ task.updatedAt = now
464
+ writeStore(store)
465
+ return [
466
+ t("定时任务已更新", "Scheduled task updated"),
467
+ `id: ${task.id}`,
468
+ `${t("名称", "name")}: ${task.name}`,
469
+ `${t("状态", "state")}: ${task.enabled ? t("开启", "enabled") : t("暂停", "paused")}`,
470
+ `${t("频率", "schedule")}: ${scheduleLabel(task.schedule)}`,
471
+ `${t("下次运行", "next run")}: ${task.nextRunAt ? localStamp(task.nextRunAt) : "-"}`,
472
+ `${t("目标会话", "target session")}: ${task.sessionID}`,
473
+ ].join("\n")
474
+ },
475
+ })
476
+
477
+ const scheduleDelete = tool({
478
+ description: t("删除定时任务。", "Delete a scheduled task."),
479
+ args: {
480
+ id: tool.schema.string().describe(t("任务 id", "Task id")),
481
+ },
482
+ async execute(args) {
483
+ const store = readStore()
484
+ const task = findTask(store, args.id)
485
+ store.tasks = store.tasks.filter((item) => item.id !== task.id)
486
+ writeStore(store)
487
+ return t(`已删除定时任务: ${task.id} (${task.name})`, `Deleted scheduled task: ${task.id} (${task.name})`)
488
+ },
489
+ })
490
+
491
+ const scheduleRunNow = tool({
492
+ description: t("立即执行一次定时任务,不改变原有计划。", "Run a scheduled task immediately without changing its schedule."),
493
+ args: {
494
+ id: tool.schema.string().describe(t("任务 id", "Task id")),
495
+ },
496
+ async execute(args) {
497
+ const store = readStore()
498
+ const task = findTask(store, args.id)
499
+ if (await sessionBusy(client, task.sessionID)) {
500
+ return t(`任务 ${task.id} 的目标会话正忙,请稍后再试。`, `Target session for ${task.id} is busy; try again later.`)
501
+ }
502
+ const result = await client.session.promptAsync({
503
+ path: { id: task.sessionID },
504
+ body: {
505
+ ...(task.agent ? { agent: task.agent } : {}),
506
+ parts: [{ type: "text", text: renderPrompt(task) }],
507
+ },
508
+ })
509
+ if (result?.error) {
510
+ const detail = result.error?.data?.message || result.error?.message || JSON.stringify(result.error)
511
+ throw new Error(detail)
512
+ }
513
+ const now = Date.now()
514
+ task.lastRunAt = now
515
+ task.lastStatus = "fired"
516
+ task.lastError = null
517
+ task.runCount = (task.runCount ?? 0) + 1
518
+ task.updatedAt = now
519
+ pushRun(task, { at: now, status: "fired", detail: "manual run" })
520
+ writeStore(store)
521
+ return t(`已立即执行定时任务: ${task.id} (${task.name})`, `Ran scheduled task now: ${task.id} (${task.name})`)
522
+ },
523
+ })
524
+
525
+ return {
526
+ schedule_create: scheduleCreate,
527
+ schedule_list: scheduleList,
528
+ schedule_update: scheduleUpdate,
529
+ schedule_delete: scheduleDelete,
530
+ schedule_run_now: scheduleRunNow,
531
+ }
532
+ }
533
+
534
+ export function createScheduler(client) {
535
+ let timer = null
536
+ let leader = false
537
+ let stopped = false
538
+ let ticking = false
539
+
540
+ const log = async (level, message, extra) => {
541
+ try {
542
+ await client.app.log({ body: { service: SERVICE, level, message, extra } })
543
+ } catch {
544
+ }
545
+ }
546
+
547
+ const readLock = () => readJson(lockFile(), null)
548
+
549
+ const pidAlive = (pid) => {
550
+ if (!pid) return false
551
+ try {
552
+ process.kill(pid, 0)
553
+ return true
554
+ } catch {
555
+ return false
556
+ }
557
+ }
558
+
559
+ const claim = () => {
560
+ try {
561
+ const lock = readLock()
562
+ const fresh = lock && lock.instanceId !== INSTANCE_ID
563
+ && Date.now() - Number(lock.ts ?? 0) < LEASE_MS
564
+ && pidAlive(Number(lock.pid))
565
+ if (fresh) return false
566
+ writeJson(lockFile(), { instanceId: INSTANCE_ID, pid: process.pid, ts: Date.now() })
567
+ const check = readLock()
568
+ return check?.instanceId === INSTANCE_ID
569
+ } catch (error) {
570
+ void log("warn", `leader claim failed: ${String(error?.message ?? error)}`)
571
+ return false
572
+ }
573
+ }
574
+
575
+ const heartbeat = () => {
576
+ try {
577
+ writeJson(lockFile(), { instanceId: INSTANCE_ID, pid: process.pid, ts: Date.now() })
578
+ return true
579
+ } catch {
580
+ return false
581
+ }
582
+ }
583
+
584
+ const fire = async (sessionID, task) => {
585
+ const result = await client.session.promptAsync({
586
+ path: { id: sessionID },
587
+ body: {
588
+ ...(task.agent ? { agent: task.agent } : {}),
589
+ parts: [{ type: "text", text: renderPrompt(task) }],
590
+ },
591
+ })
592
+ if (result?.error) {
593
+ const detail = result.error?.data?.message || result.error?.message || JSON.stringify(result.error)
594
+ throw new Error(detail)
595
+ }
596
+ }
597
+
598
+ const tick = async () => {
599
+ if (stopped || ticking) return
600
+ ticking = true
601
+ try {
602
+ if (!leader) leader = claim()
603
+ if (!leader) return
604
+ heartbeat()
605
+
606
+ const now = Date.now()
607
+ const dueIds = readStore().tasks
608
+ .filter((task) => task.enabled && task.nextRunAt && task.nextRunAt <= now)
609
+ .map((task) => task.id)
610
+
611
+ for (const id of dueIds) {
612
+ const store = readStore()
613
+ const task = store.tasks.find((item) => item.id === id)
614
+ if (!task || !task.enabled || !task.nextRunAt || task.nextRunAt > Date.now()) continue
615
+
616
+ const dueAt = task.nextRunAt
617
+ if (await sessionBusy(client, task.sessionID)) {
618
+ task.nextRunAt = Date.now() + RETRY_MS
619
+ task.lastStatus = "deferred"
620
+ task.updatedAt = Date.now()
621
+ pushRun(task, { at: Date.now(), status: "deferred", detail: "session busy" })
622
+ writeStore(store)
623
+ continue
624
+ }
625
+
626
+ try {
627
+ await fire(task.sessionID, task)
628
+ task.lastRunAt = Date.now()
629
+ task.lastStatus = "fired"
630
+ task.lastError = null
631
+ task.runCount = (task.runCount ?? 0) + 1
632
+ task.updatedAt = Date.now()
633
+ pushRun(task, { at: task.lastRunAt, status: "fired" })
634
+ if (task.schedule.type === "once") {
635
+ task.enabled = false
636
+ task.done = true
637
+ task.nextRunAt = null
638
+ } else {
639
+ task.nextRunAt = nextAfterRun({ ...task, nextRunAt: dueAt }, Date.now())
640
+ }
641
+ } catch (error) {
642
+ const message = String(error?.message ?? error)
643
+ task.lastStatus = "error"
644
+ task.lastError = message
645
+ task.updatedAt = Date.now()
646
+ pushRun(task, { at: Date.now(), status: "error", detail: message })
647
+ if (/404|not found|不存在/i.test(message)) {
648
+ task.enabled = false
649
+ task.nextRunAt = null
650
+ } else {
651
+ task.nextRunAt = Date.now() + RETRY_MS
652
+ }
653
+ }
654
+ writeStore(store)
655
+ }
656
+ } catch (error) {
657
+ void log("error", `scheduler tick failed: ${String(error?.message ?? error)}`)
658
+ } finally {
659
+ ticking = false
660
+ }
661
+ }
662
+
663
+ const start = () => {
664
+ ensureDir()
665
+ leader = claim()
666
+ timer = setInterval(() => { void tick() }, TICK_MS)
667
+ if (timer && typeof timer.unref === "function") timer.unref()
668
+ void tick()
669
+ void log("info", leader ? "scheduler started as leader" : "scheduler started as follower")
670
+ }
671
+
672
+ const stop = async () => {
673
+ stopped = true
674
+ if (timer) clearInterval(timer)
675
+ if (leader) {
676
+ try {
677
+ const lock = readLock()
678
+ if (lock?.instanceId === INSTANCE_ID) fs.unlinkSync(lockFile())
679
+ } catch {
680
+ }
681
+ }
682
+ await log("info", "scheduler stopped")
683
+ }
684
+
685
+ return { start, stop }
686
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { INSTRUCTIONS, createScheduler, createTools } from "./core.js"
3
+
4
+ export const NaturalScheduler = async ({ client }) => {
5
+ const scheduler = createScheduler(client)
6
+ scheduler.start()
7
+ return {
8
+ dispose: scheduler.stop,
9
+ "experimental.chat.system.transform": async (_input, output) => {
10
+ if (!Array.isArray(output.system)) output.system = []
11
+ output.system.push(INSTRUCTIONS)
12
+ },
13
+ tool: createTools(tool, client),
14
+ }
15
+ }