my-opencode-toolkit 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -1,6 +1,35 @@
1
1
  # my-opencode-toolkit
2
2
 
3
- OpenCode 插件:系统通知、持久目标、定时任务与循环、TUI 状态面板。
3
+ [![ci](https://github.com/1cyberlangke1/my-opencode-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/1cyberlangke1/my-opencode-toolkit/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/my-opencode-toolkit)](https://www.npmjs.com/package/my-opencode-toolkit)
5
+
6
+ OpenCode v2 插件:系统通知、持久目标、定时任务与循环、TUI 状态面板。
7
+
8
+ ## 要求
9
+
10
+ - OpenCode `2.x`
11
+ - Node `>= 20`(OpenCode 自带 Bun 运行时,插件本身也用 Node API)
12
+ - Windows / macOS / Linux
13
+
14
+ ## 安装
15
+
16
+ 在全局或项目级 `opencode.json` 里把包名加进 **`plugins`** 数组(注意是 `plugins`,不是 `plugin`):
17
+
18
+ ```json
19
+ {
20
+ "plugins": ["my-opencode-toolkit"]
21
+ }
22
+ ```
23
+
24
+ 本地开发时可以直接指向目录:
25
+
26
+ ```json
27
+ {
28
+ "plugins": ["D:\\Git_Repository\\my-opencode-toolkit"]
29
+ }
30
+ ```
31
+
32
+ 重启 OpenCode 后生效。TUI 面板和 `/my-opencode-toolkit` 设置菜单由包内的 `tui` 入口提供,不需要额外配置。
4
33
 
5
34
  ## 功能
6
35
 
@@ -8,15 +37,17 @@ OpenCode 插件:系统通知、持久目标、定时任务与循环、TUI 状
8
37
 
9
38
  - 会话空闲(任务完成)、权限请求(`permission.asked`)、代理提问(`question` 工具)三种通知
10
39
  - 平台:Windows toast(PowerShell)、macOS(osascript)、Linux(notify-send)
11
- - 标题带会话名,正文按设置语言本地化,长度截断避免 toast 溢出
40
+ - 标题带会话名;正文按设置语言本地化;完成通知附带最后一条用户/助手文本
41
+ - 通知是尽力而为:系统关闭通知、专注助手拦截或缺少对应命令时静默跳过,不影响会话
12
42
 
13
43
  ### 持久目标(Goal)
14
44
 
15
45
  - 工具:`create_goal`、`get_goal`、`update_goal`
16
46
  - 命令:`/goal <objective>`、`/goal pause|resume|clear|edit`、裸 `/goal` 查看
17
- - 目标 active 时会话空闲自动注入续作提示;同一原因连续 3 次阻塞才标记 `blocked`
47
+ - 目标 active 时会话空闲自动注入续作提示;同一阻塞原因连续 3 次才标记 `blocked`
18
48
  - 记账:每步的输入、输出、缓存读写都计入;达到 token 预算自动转 `budget_limited`
19
49
  - 用户打断自动暂停;余额不足暂停并锁死,需显式 `/goal resume`
50
+ - 子代理会话看不到 goal 工具,也不会被注入续作
20
51
 
21
52
  ### 定时任务与循环
22
53
 
@@ -26,88 +57,127 @@ OpenCode 插件:系统通知、持久目标、定时任务与循环、TUI 状
26
57
  - `/cron once <expr> <prompt>` 一次性任务
27
58
  - `/cron list`、`/cron delete <id>`
28
59
  - `/loop [interval] [prompt]` 启动循环,`/loop stop` 停止
29
- - 5 字段 cron(本地时区),支持 `*`、步进、范围、列表;每会话上限 50;重复任务 7 天过期
60
+ - 5 字段 cron(本地时区):`分 时 日 月 周`
61
+ - 支持 `*`、步进 `*/n`、范围 `a-b`、范围步进 `a-b/n`、列表 `a,b`
62
+ - 周字段 `0` 和 `7` 都表示周日
63
+ - 下一次触发在四年窗口内搜索,支持 `2 月 29 日`;永远不存在的日期会被拒绝
64
+ - 每会话最多 50 个任务;重复任务 7 天后自动过期
30
65
  - durable 任务存插件存储,重启恢复并重新武装 timer;非 durable 只留内存
66
+ - 循环只存在内存,重启后消失;需要跨重启请用 cron
31
67
  - 循环:固定间隔或动态(模型用 `schedule_wakeup` 决定下次,60–3600 秒,20 分钟兜底)
32
- - 裸 `/loop` 使用内置维护提示词;项目根目录有非空 `loop.md` 时优先用它
68
+ - 固定间隔范围 60 秒 – 7 天;裸 `/loop` 使用内置维护提示词,项目根目录有非空 `loop.md` 时优先用它
69
+
70
+ #### 触发语义
71
+
72
+ - 任务只在会话空闲时注入;会话忙或末条是用户输入时**顺延重试**,不会静默丢失
73
+ - 功能关闭或目标进行中时任务让路,同样只顺延、不消费;重新开启后一分钟内恢复
74
+ - 重启后已过点的任务会补触发一次,不会把错过的每个时间点都补一遍
75
+ - 重复任务错过的时间点只补一次;一次性任务会一直重试到成功注入或会话被删除
33
76
 
34
77
  ### TUI 面板
35
78
 
36
79
  - 侧栏显示目标、缓存命中率、工具使用 Top3、子代理数量、循环与定时任务数量
37
- - 统计不单独存一份:每个会话第一次打开面板时,从 OpenCode 已存消息(`session.context`)重建一次,
38
- 之后用事件增量维护;插件重载或进程重启后再重建
39
- - 设置项可控制显示内容与折叠
40
- - `/my-opencode-toolkit` 打开设置菜单:启用、语言、通知、Goal、定时任务、侧栏
80
+ - 统计不单独存一份:每个会话第一次打开面板时,从 OpenCode 已存消息(`session.context`)重建
81
+ - 设置菜单:`/my-opencode-toolkit`,可热改语言、通知、Goal、定时任务与侧栏显示项
82
+ - 设置写入失败会回滚本地显示
83
+
84
+ ## 命令与工具
85
+
86
+ | 命令 | 作用 |
87
+ |---|---|
88
+ | `/my-opencode-toolkit` | 打开设置菜单(TUI) |
89
+ | `/goal <objective>` | 设置/覆盖当前会话的持久目标 |
90
+ | `/goal` | 查看当前目标 |
91
+ | `/goal pause` / `resume` / `clear` / `edit` | 暂停 / 恢复 / 清除 / 编辑目标 |
92
+ | `/cron <expr> <prompt>` | 创建重复任务 |
93
+ | `/cron once <expr> <prompt>` | 创建一次性任务 |
94
+ | `/cron list` | 列出任务 |
95
+ | `/cron delete <id>` | 删除任务 |
96
+ | `/loop [interval] [prompt]` | 启动循环;不带间隔为 dynamic 模式 |
97
+ | `/loop stop` | 停止当前会话全部循环 |
98
+
99
+ | 工具 | 作用 |
100
+ |---|---|
101
+ | `toolkit_ping` | 返回插件是否启用与当前语言,用于验证加载 |
102
+ | `create_goal` / `get_goal` / `update_goal` | 目标读写与完成/阻塞标记 |
103
+ | `cron_create` / `cron_list` / `cron_delete` | 定时任务管理 |
104
+ | `schedule_wakeup` | dynamic 循环的下次唤醒时间 |
41
105
 
42
- ### 子代理隔离
106
+ ## 设置
43
107
 
44
- 子代理会话里不会出现 goal / scheduling 工具,也不会收到任何续作或定时注入。
45
- 实现方式是在 `context` 钩子里从工具表删除,并在注入前检查 `session.parentID`。
108
+ 设置存在 OpenCode 插件存储里,通过 TUI 菜单修改;插件 `options` 只在首次运行时作为初始值。
46
109
 
47
- ## 安装
110
+ | 字段 | 默认 | 说明 |
111
+ |---|---|---|
112
+ | `lang` | `auto` | `auto` / `zh` / `en` |
113
+ | `enabled` | `true` | 插件总开关;关闭后隐藏工具、停止注入与通知 |
114
+ | `notification.enabled` | `true` | 通知总开关 |
115
+ | `notification.onComplete` | `true` | 会话完成通知 |
116
+ | `notification.onQuestion` | `true` | 代理提问通知 |
117
+ | `notification.onPermission` | `true` | 权限请求通知 |
118
+ | `notification.onlyMainSession` | `true` | 只通知主会话,忽略子代理 |
119
+ | `goal.enabled` | `true` | 持久目标功能 |
120
+ | `scheduling.enabled` | `true` | 定时任务与循环功能 |
121
+ | `sidebar.enabled` | `true` | 侧栏面板 |
122
+ | `sidebar.showCache` | `true` | 显示缓存命中率 |
123
+ | `sidebar.showTools` | `true` | 显示工具使用 Top3 |
124
+ | `sidebar.showSubagents` | `true` | 显示子代理数量 |
125
+ | `sidebar.collapsed` | `false` | 折叠侧栏内容 |
48
126
 
49
- 在 `~/.config/opencode/opencode.json` 的 `plugins` 数组里加包名:
127
+ ## 数据与隐私
50
128
 
51
- ```jsonc
52
- {
53
- "plugins": ["my-opencode-toolkit"]
54
- }
55
- ```
129
+ - 目标、任务、设置都写在 OpenCode 的插件存储里,不额外建数据库
130
+ - 通知预览会读取当前会话最后一条用户/助手文本,只用于本地系统通知
131
+ - 不发送任何遥测;不访问网络(通知命令与 OpenCode 自身除外)
132
+ - 删除会话时会清掉该会话的 goal、定时任务、循环、统计与角色缓存
56
133
 
57
- 本地开发可以直接指向仓库目录:
134
+ ## 已知限制
58
135
 
59
- ```jsonc
60
- {
61
- "plugins": ["D:\\Git_Repository\\my-opencode-toolkit"]
62
- }
63
- ```
136
+ - 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)
137
+ - 通知依赖系统命令:Windows 用 PowerShell toast,macOS 用 `osascript`,Linux 用 `notify-send`
138
+ - 定时任务按 OpenCode 进程的本地时区解析;不支持秒级字段与 `L` / `W` / `?` 等扩展语法
139
+ - 夏令时切换当天,本地时间的 cron 可能跳过或重复一次(标准 cron 语义的固有问题)
140
+ - 同一会话不要同时开多个 OpenCode 实例执行目标/定时任务;插件存储没有跨进程锁
141
+ - 目标进行中时 cron/loop 让路,避免和续作抢同一轮对话
142
+ - 子代理会话不提供 goal/scheduling 工具,也不会被注入续作
64
143
 
65
- 重启 OpenCode 后生效。
144
+ ## 排障
66
145
 
67
- 要求 Node 20+;Windows 通知走 PowerShell toast,macOS 走 `osascript`,Linux 走 `notify-send`。
146
+ **插件没加载**
68
147
 
69
- ## 设置
148
+ 1. 确认 `opencode.json` 用的是 `plugins` 数组,且包名拼写正确
149
+ 2. 跑 `opencode plugin list` 看是否列出 `my-opencode-toolkit`
150
+ 3. 重启 OpenCode;已存在的会话不会重建工具表,建议新开会话验证
70
151
 
71
- 设置存在插件存储里,首次启动用默认值;通过 `/my-opencode-toolkit` 菜单修改,立即生效。
152
+ **TUI 没有侧栏/设置菜单**
72
153
 
73
- | 字段 | 默认 | 说明 |
74
- |---|---|---|
75
- | `lang` | `auto` | `auto` / `zh` / `en` |
76
- | `enabled` | `true` | 插件总开关 |
77
- | `notification.enabled` | `true` | 通知总开关 |
78
- | `notification.onComplete` | `true` | 任务完成通知 |
79
- | `notification.onQuestion` | `true` | 提问通知 |
80
- | `notification.onPermission` | `true` | 权限通知 |
81
- | `notification.onlyMainSession` | `true` | 跳过子代理会话 |
82
- | `goal.enabled` | `true` | Goal 功能 |
83
- | `scheduling.enabled` | `true` | 定时任务与循环 |
84
- | `sidebar.enabled` | `true` | 侧栏面板 |
85
- | `sidebar.showCache` | `true` | 显示缓存命中 |
86
- | `sidebar.showTools` | `true` | 显示工具统计 |
87
- | `sidebar.showSubagents` | `true` | 显示子代理数量 |
88
- | `sidebar.collapsed` | `false` | 折叠侧栏 |
154
+ - TUI 插件只在交互式 TUI 里加载;`opencode run` 不渲染侧栏
155
+ - 确认包内 `tui.ts`(或 npm 包的 `./tui` 导出)存在
156
+ - 重启 TUI;插件热重载不会重放已注册的 slot
89
157
 
90
- ## 优先级
158
+ **没有系统通知**
91
159
 
92
- 会话空闲时:Goal active > 定时任务 / 循环。Goal 进行中,定时任务到点只重排不注入。
160
+ - 检查系统通知权限与专注助手
161
+ - Windows 确认 `powershell.exe` 在 PATH;macOS 确认 `osascript`;Linux 确认 `notify-send`
162
+ - 用 `toolkit_ping` 确认插件本身已加载
93
163
 
94
- ## 已知限制
164
+ **定时任务没触发**
95
165
 
96
- - 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)。
97
- - 通知是尽力而为:系统关闭通知、专注助手拦截或缺少对应命令时静默跳过,不影响会话。
98
- - 续作与定时注入只在主会话生效;子代理会话既看不到相关工具,也不会被注入。
99
- - 定时任务按本地时区解析 5 字段 cron,不支持秒级字段。
166
+ - 检查会话是否长期处于目标 active 状态(会主动让路)
167
+ - 检查任务是否已超过 7 天过期
168
+ - 检查时区:cron 按本地时区解析
169
+ - 用 `/cron list` 确认任务还在
100
170
 
101
171
  ## 开发
102
172
 
103
173
  ```bash
104
174
  pnpm install
105
- pnpm check # typecheck + lint + 单测 + TUI 渲染测试
106
- pnpm test:e2e # 隔离环境启动真实 opencode,假 provider 驱动工具调用
175
+ pnpm check # typecheck + lint + 175 单测 + TUI 测试 + 发布包内容校验
176
+ pnpm test:e2e # 需要本机 opencode 2.x 与 bun,使用隔离的 HOME/DB/config
107
177
  ```
108
178
 
109
- 要求 Node 20+(OpenCode v2 要求),测试用 vitest + bun。
179
+ e2e 用假 provider 驱动工具调用,不会请求真实模型;它会在临时目录里启动一个隔离的 OpenCode server,不读写日常数据。
110
180
 
111
- ## License
181
+ ## 许可证
112
182
 
113
- MIT,见 [LICENSE](LICENSE)。
183
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-opencode-toolkit",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "OpenCode 插件:系统通知、持久目标、定时任务与 TUI 面板",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,8 @@
45
45
  "scripts": {
46
46
  "typecheck": "tsc --noEmit",
47
47
  "lint": "eslint .",
48
- "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm test:tui",
48
+ "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm test:tui && pnpm check:package",
49
+ "check:package": "node scripts/check-package.mjs",
49
50
  "test": "vitest run",
50
51
  "test:tui": "cd tui-test && bun test",
51
52
  "test:e2e": "bun scripts/e2e-load.mjs",
@@ -70,8 +70,11 @@ export async function registerGoalCommand(
70
70
  description: DESCRIPTION,
71
71
  execute: async (input) => {
72
72
  const settings = await loadSettings(ctx.storage)
73
+ const parsed = parseGoalCommand(input.prompt.text)
74
+ // clear / show 是清理与查看,功能关闭时也允许,避免目标被永久锁在存储里。
75
+ const readOnly = parsed.kind === "clear" || parsed.kind === "show"
73
76
  const text =
74
- settings.enabled && settings.goal.enabled
77
+ (settings.enabled && settings.goal.enabled) || readOnly
75
78
  ? await applyGoalCommand(controller, continuation, input.sessionID, input.prompt.text)
76
79
  : "Error: the goal feature is disabled in settings."
77
80
  // /goal 只关心文本参数;附件不回传,避免把命令附件重复塞进会话。
@@ -123,6 +123,8 @@ export function createGoalContinuation(args: {
123
123
 
124
124
  /** 输入:会话 ID + 错误载荷;输出:无。预期:余额不足/超长锁死,其余只记录。 */
125
125
  const handleFailure = async (sessionID: string, error: unknown): Promise<void> => {
126
+ // 这一轮已经失败,清掉待确认注入,下一次 idle 才能重试。
127
+ pending.delete(sessionID)
126
128
  if (!(await enabled())) return
127
129
 
128
130
  const state = stateOf(sessionID)
@@ -139,6 +141,8 @@ export function createGoalContinuation(args: {
139
141
 
140
142
  /** 输入:会话 ID + 打断原因;输出:无。预期:用户打断时暂停目标。 */
141
143
  const handleInterrupted = async (sessionID: string, reason: string): Promise<void> => {
144
+ // 任何打断都意味着这一轮结束,清掉待确认注入。
145
+ pending.delete(sessionID)
142
146
  if (reason !== "user") return
143
147
  if (!(await enabled())) return
144
148
  const state = stateOf(sessionID)
@@ -33,8 +33,11 @@ export function createGoalController(store: JsonStore): GoalController {
33
33
  async setGoal(sessionID, objective, tokenBudget) {
34
34
  await clearGoal(store, sessionID)
35
35
  const goal = await createGoal(store, sessionID, objective)
36
- if (tokenBudget === undefined) return goal
37
- const withBudget: Goal = { ...goal, tokenBudget }
36
+ // 非法预算(0/负数/NaN)当作没设置,避免第一次记账就直接 budget_limited。
37
+ const budget =
38
+ tokenBudget !== undefined && Number.isFinite(tokenBudget) && tokenBudget > 0 ? tokenBudget : undefined
39
+ if (budget === undefined) return goal
40
+ const withBudget: Goal = { ...goal, tokenBudget: budget }
38
41
  await writeGoal(store, withBudget)
39
42
  return withBudget
40
43
  },
@@ -87,8 +90,8 @@ export function createGoalController(store: JsonStore): GoalController {
87
90
  const goal = await readGoal(store, sessionID)
88
91
  if (goal?.status !== "active") return goal
89
92
 
90
- const tokenDelta = Math.max(0, usage.tokens)
91
- const timeDelta = Math.max(0, usage.elapsedSeconds)
93
+ const tokenDelta = Number.isFinite(usage.tokens) ? Math.max(0, usage.tokens) : 0
94
+ const timeDelta = Number.isFinite(usage.elapsedSeconds) ? Math.max(0, usage.elapsedSeconds) : 0
92
95
  if (tokenDelta === 0 && timeDelta === 0) return goal
93
96
 
94
97
  const nextTokens = goal.tokensUsed + tokenDelta
package/src/goal/tools.ts CHANGED
@@ -74,6 +74,17 @@ export async function registerGoalTools(
74
74
  const blocked = await unavailable(sessionID)
75
75
  if (blocked !== null) return { output: {}, content: blocked }
76
76
 
77
+ const objective = input.objective.trim()
78
+ if (objective.length === 0) {
79
+ return { output: {}, content: "Error: objective cannot be empty." }
80
+ }
81
+ if (
82
+ input.token_budget !== undefined &&
83
+ (!Number.isFinite(input.token_budget) || input.token_budget <= 0)
84
+ ) {
85
+ return { output: {}, content: "Error: token_budget must be a positive number." }
86
+ }
87
+
77
88
  const existing = await controller.getGoal(sessionID)
78
89
  if (existing !== null && existing.status !== "complete" && existing.status !== "blocked") {
79
90
  return {
@@ -82,7 +93,7 @@ export async function registerGoalTools(
82
93
  }
83
94
  }
84
95
 
85
- await controller.setGoal(sessionID, input.objective, input.token_budget)
96
+ await controller.setGoal(sessionID, objective, input.token_budget)
86
97
  return { output: {}, content: formatGoal(await controller.getGoal(sessionID)) }
87
98
  },
88
99
  })
@@ -4,6 +4,7 @@ import type { SessionRoles } from "../goal/session-role.ts"
4
4
  import { resolveMessages, type Messages } from "../i18n/index.ts"
5
5
  import { readEventSessionID } from "../shared/events.ts"
6
6
  import type { JsonStore } from "../shared/json.ts"
7
+ import { isSyntheticUserMessage } from "../shared/messages.ts"
7
8
  import { loadSettings } from "../shared/settings-store.ts"
8
9
  import type { Settings } from "../shared/settings.ts"
9
10
  import { createSender, type NotificationSender } from "./send.ts"
@@ -77,7 +78,9 @@ export function createNotifier(args: {
77
78
  for (let index = history.length - 1; index >= 0; index -= 1) {
78
79
  const message = history[index]
79
80
  if (message === undefined) continue
80
- if (userText === "" && message.type === "user") userText = collapse(message.text)
81
+ if (userText === "" && message.type === "user" && !isSyntheticUserMessage(message)) {
82
+ userText = collapse(message.text)
83
+ }
81
84
  if (assistantText === "" && message.type === "assistant") {
82
85
  const text = message.content
83
86
  .filter((part) => part.type === "text")
@@ -13,22 +13,42 @@ const NOTIFY_SEND = "notify-send"
13
13
  const TITLE_MAX = 100
14
14
  const MESSAGE_MAX = 800
15
15
 
16
+ /** 命令探测超时;发送超时。 */
17
+ const PROBE_TIMEOUT_MS = 3_000
18
+ const SEND_TIMEOUT_MS = 10_000
19
+
16
20
  /** 发送函数。 */
17
21
  export type NotificationSender = (title: string, message: string) => Promise<void>
18
22
 
23
+ /**
24
+ * 命令执行器。
25
+ *
26
+ * 输入:命令、参数、超时毫秒。
27
+ * 输出:命令成功退出时 resolve。
28
+ * 预期:默认实现走 `execFile`;测试可注入假实现,验证各平台的参数与转义。
29
+ */
30
+ export type CommandRunner = (command: string, args: readonly string[], timeoutMs: number) => Promise<void>
31
+
32
+ const defaultRunner: CommandRunner = (command, args, timeoutMs) =>
33
+ execFileAsync(command, [...args], { timeout: timeoutMs, windowsHide: true }).then(() => undefined)
34
+
19
35
  /** 输入:文本 + 上限;输出:截断后的文本。 */
20
36
  function truncate(text: string, max: number): string {
21
37
  return text.length > max ? `${text.slice(0, max)}...` : text
22
38
  }
23
39
 
24
40
  /**
25
- * 输入:命令名 + 该命令能快速成功退出的探测参数。
41
+ * 输入:命令名 + 该命令能快速成功退出的探测参数 + 执行器。
26
42
  * 输出:可用时返回命令名,否则 `null`。
27
43
  * 预期:结果缓存;探测超时 3 秒,不阻塞通知链路。
28
44
  * 探测参数必须按命令区分:把 PowerShell 的 `-Command` 传给 osascript/notify-send 会必然失败,
29
45
  * 导致这两个平台的命令被误判为不存在。
30
46
  */
31
- function createCommandFinder(commandName: string, probeArgs: readonly string[]): () => Promise<string | null> {
47
+ function createCommandFinder(
48
+ commandName: string,
49
+ probeArgs: readonly string[],
50
+ runner: CommandRunner,
51
+ ): () => Promise<string | null> {
32
52
  let cached: string | null | undefined
33
53
  let pending: Promise<string | null> | undefined
34
54
 
@@ -38,10 +58,7 @@ function createCommandFinder(commandName: string, probeArgs: readonly string[]):
38
58
 
39
59
  pending = (async () => {
40
60
  try {
41
- await execFileAsync(commandName, [...probeArgs], {
42
- timeout: 3_000,
43
- windowsHide: true,
44
- })
61
+ await runner(commandName, probeArgs, PROBE_TIMEOUT_MS)
45
62
  cached = commandName
46
63
  } catch {
47
64
  cached = null
@@ -52,52 +69,59 @@ function createCommandFinder(commandName: string, probeArgs: readonly string[]):
52
69
  }
53
70
  }
54
71
 
55
- const findPowerShell = createCommandFinder(POWERSHELL, ["-NoProfile", "-Command", "exit 0"])
56
- const findOsascript = createCommandFinder(OSASCRIPT, ["-e", "return"])
57
- const findNotifySend = createCommandFinder(NOTIFY_SEND, ["--help"])
58
-
59
- /** 输入:命令 + 参数;输出:无。预期:静默执行,失败由调用方忽略。 */
60
- async function runQuiet(command: string, args: readonly string[]): Promise<void> {
61
- await execFileAsync(command, [...args], { timeout: 10_000, windowsHide: true })
62
- }
63
-
64
- /** 输入:标题 + 正文;输出:无。预期:没有 powershell 时静默跳过。 */
65
- async function sendWindows(title: string, message: string): Promise<void> {
66
- const path = await findPowerShell()
67
- if (path === null) return
68
- const script = buildWindowsToastScript(truncate(title, TITLE_MAX), truncate(message, MESSAGE_MAX))
69
- await runQuiet(path, ["-NoProfile", "-NonInteractive", "-Command", script])
72
+ /** 输入:执行器;输出:Windows toast 发送函数。 */
73
+ function windowsSender(runner: CommandRunner): NotificationSender {
74
+ const find = createCommandFinder(POWERSHELL, ["-NoProfile", "-Command", "exit 0"], runner)
75
+ return async (title, message) => {
76
+ const path = await find()
77
+ if (path === null) return
78
+ const script = buildWindowsToastScript(truncate(title, TITLE_MAX), truncate(message, MESSAGE_MAX))
79
+ await runner(path, ["-NoProfile", "-NonInteractive", "-Command", script], SEND_TIMEOUT_MS)
80
+ }
70
81
  }
71
82
 
72
- /** 输入:标题 + 正文;输出:无。预期:没有 osascript 时静默跳过。 */
73
- async function sendMacos(title: string, message: string): Promise<void> {
74
- const path = await findOsascript()
75
- if (path === null) return
76
- const safeTitle = escapeAppleScriptText(truncate(title, TITLE_MAX))
77
- const safeMessage = escapeAppleScriptText(truncate(message, MESSAGE_MAX))
78
- await runQuiet(path, ["-e", `display notification "${safeMessage}" with title "${safeTitle}"`])
83
+ /** 输入:执行器;输出:macOS 通知发送函数。 */
84
+ function macosSender(runner: CommandRunner): NotificationSender {
85
+ const find = createCommandFinder(OSASCRIPT, ["-e", "return"], runner)
86
+ return async (title, message) => {
87
+ const path = await find()
88
+ if (path === null) return
89
+ const safeTitle = escapeAppleScriptText(truncate(title, TITLE_MAX))
90
+ const safeMessage = escapeAppleScriptText(truncate(message, MESSAGE_MAX))
91
+ await runner(
92
+ path,
93
+ ["-e", `display notification "${safeMessage}" with title "${safeTitle}"`],
94
+ SEND_TIMEOUT_MS,
95
+ )
96
+ }
79
97
  }
80
98
 
81
- /** 输入:标题 + 正文;输出:无。预期:没有 notify-send 时静默跳过。 */
82
- async function sendLinux(title: string, message: string): Promise<void> {
83
- const path = await findNotifySend()
84
- if (path === null) return
85
- await runQuiet(path, [truncate(title, TITLE_MAX), truncate(message, MESSAGE_MAX)])
99
+ /** 输入:执行器;输出:Linux 通知发送函数。 */
100
+ function linuxSender(runner: CommandRunner): NotificationSender {
101
+ const find = createCommandFinder(NOTIFY_SEND, ["--help"], runner)
102
+ return async (title, message) => {
103
+ const path = await find()
104
+ if (path === null) return
105
+ await runner(path, [truncate(title, TITLE_MAX), truncate(message, MESSAGE_MAX)], SEND_TIMEOUT_MS)
106
+ }
86
107
  }
87
108
 
88
109
  /**
89
- * 输入:Node 平台字符串。
110
+ * 输入:Node 平台字符串 + 可选执行器。
90
111
  * 输出:该平台的发送函数。
91
- * 预期:不支持的平台返回空实现,绝不抛错。
112
+ * 预期:不支持的平台返回空实现,绝不抛错;每个 sender 各自缓存命令探测结果。
92
113
  */
93
- export function createSender(platform: NodeJS.Platform): NotificationSender {
114
+ export function createSender(
115
+ platform: NodeJS.Platform,
116
+ runner: CommandRunner = defaultRunner,
117
+ ): NotificationSender {
94
118
  switch (platform) {
95
119
  case "win32":
96
- return sendWindows
120
+ return windowsSender(runner)
97
121
  case "darwin":
98
- return sendMacos
122
+ return macosSender(runner)
99
123
  case "linux":
100
- return sendLinux
124
+ return linuxSender(runner)
101
125
  default:
102
126
  return () => Promise.resolve()
103
127
  }
@@ -106,8 +106,9 @@ export async function registerSchedulingCommands(
106
106
  name: "loop",
107
107
  description: "Start or stop a recurring prompt loop in this session",
108
108
  execute: async (input) => {
109
- const blocked = await unavailable(input.sessionID)
110
109
  const parsed = parseLoopCommand(input.prompt.text)
110
+ // stop 即使功能关闭也要能执行,否则用户没法停掉正在跑的循环。
111
+ const blocked = parsed.kind === "stop" ? null : await unavailable(input.sessionID)
111
112
  const text = await (async (): Promise<string> => {
112
113
  if (blocked !== null) return blocked
113
114
  if (parsed.kind === "stop") {
@@ -128,8 +129,10 @@ export async function registerSchedulingCommands(
128
129
  name: "cron",
129
130
  description: "Create, list or delete scheduled prompts for this session",
130
131
  execute: async (input) => {
131
- const blocked = await unavailable(input.sessionID)
132
132
  const raw = input.prompt.text.trim()
133
+ // list / delete 属于清理操作,功能关闭时也允许;只有创建需要开关。
134
+ const readOnly = raw === "" || raw.toLowerCase() === "list" || /^delete\s+\S+$/i.test(raw)
135
+ const blocked = readOnly ? null : await unavailable(input.sessionID)
133
136
  const text = await (async (): Promise<string> => {
134
137
  if (blocked !== null) return blocked
135
138
 
@@ -2,6 +2,7 @@ import type { Plugin } from "@opencode/plugin"
2
2
 
3
3
  import { readEventData } from "../shared/events.ts"
4
4
  import { lastMessageIsUserInput } from "../shared/messages.ts"
5
+ import { capTimerDelay } from "../shared/timer.ts"
5
6
  import type { SessionRoles } from "../goal/session-role.ts"
6
7
  import type { SchedulingStore } from "./store.ts"
7
8
  import { isRecurringTaskAged } from "./store.ts"
@@ -10,6 +11,15 @@ import type { ScheduledJob } from "./types.ts"
10
11
  /** 注入消息的 metadata 标记。 */
11
12
  const CRON_MARKER = "cronContinuation"
12
13
 
14
+ /** 功能关闭时顺延任务的间隔。 */
15
+ const DISABLED_RETRY_MS = 60_000
16
+
17
+ /** 目标进行中让路后的重试间隔。 */
18
+ const DEFERRED_RETRY_MS = 5 * 60_000
19
+
20
+ /** 注入失败(会话忙或末条是用户输入)后的重试间隔。 */
21
+ const DELIVERY_RETRY_MS = 60_000
22
+
13
23
  /** 定时任务触发控制器。 */
14
24
  export type SchedulingContinuation = {
15
25
  /** 输入:会话事件;输出:无。预期:idle 兜底触发,删除会话清任务。 */
@@ -55,6 +65,10 @@ export function createSchedulingContinuation(args: {
55
65
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
56
66
  const inFlight = new Set<string>()
57
67
  const pending = new Set<string>()
68
+ const lifecycle = { disposed: false }
69
+
70
+ /** 输入:无;输出:插件是否已卸载。预期:await 之后用它再确认,避免继续注入或武装。 */
71
+ const isDisposed = (): boolean => lifecycle.disposed
58
72
 
59
73
  /** 输入:会话 + 文本;输出:是否注入成功。预期:并发、上一条未完成或末条是用户输入时跳过。 */
60
74
  const inject = async (sessionID: string, text: string): Promise<boolean> => {
@@ -75,35 +89,67 @@ export function createSchedulingContinuation(args: {
75
89
 
76
90
  /** 输入:无;输出:无。预期:每个未来任务一个 timer,已有 timer 的不重复武装。 */
77
91
  const armTimers = (): void => {
92
+ if (isDisposed()) return
78
93
  const at = now()
79
94
  for (const job of store.listJobs()) {
80
95
  if (job.nextRunAt === undefined || timers.has(job.id)) continue
81
- const delay = job.nextRunAt - at
82
- if (delay <= 0) continue
96
+ // 已过点的任务也要武装:0 延迟让它在当前 tick 后补跑,而不是干等下一次 idle。
97
+ // 超过 24 小时的延迟分段等待,避免 32 位溢出导致立即触发。
98
+ const delay = capTimerDelay(job.nextRunAt - at)
83
99
  const handle = setTimeout(() => {
84
100
  timers.delete(job.id)
85
101
  const fresh = store.getJob(job.id)
86
- if (fresh !== undefined) void handleCronFire(fresh)
102
+ if (fresh === undefined) return
103
+ // 分段等待提前醒来时还没到点,继续武装剩余时间。
104
+ if (fresh.nextRunAt !== undefined && fresh.nextRunAt > now()) {
105
+ armTimers()
106
+ return
107
+ }
108
+ // timer 回调里必须吞掉异常,否则未处理的 rejection 会打断整个进程。
109
+ void handleCronFire(fresh).catch(() => undefined)
87
110
  }, delay)
88
111
  handle.unref()
89
112
  timers.set(job.id, handle)
90
113
  }
91
114
  }
92
115
 
93
- /** 输入:任务;输出:无。预期:先重排/删除,再判断是否注入。 */
116
+ /** 输入:任务 + 延迟;输出:无。预期:只改运行时 nextRunAt 并重新武装,不消费任务。 */
117
+ const postpone = (job: ScheduledJob, delayMs: number): void => {
118
+ job.nextRunAt = now() + delayMs
119
+ armTimers()
120
+ }
121
+
122
+ /** 输入:任务;输出:无。预期:先确认允许投递,成功后才重排/删除。 */
94
123
  const handleCronFire = async (job: ScheduledJob): Promise<void> => {
124
+ if (isDisposed()) return
95
125
  const at = now()
96
126
  const aged = isRecurringTaskAged(job, at)
97
- if (aged || job.recurring !== true) {
98
- await store.removeJobs([job.id])
99
- } else {
100
- await store.markFired([job.id], at)
127
+ const recurring = job.recurring === true && !aged
128
+
129
+ // 关闭或让路时先不消费任务:否则 one-shot 会在用户重新开启前被静默丢掉。
130
+ const on = await enabled().catch(() => false)
131
+ if (!on) {
132
+ postpone(job, DISABLED_RETRY_MS)
133
+ return
101
134
  }
102
- armTimers()
135
+ const defer = await shouldDefer(job.sessionID).catch(() => false)
136
+ if (defer) {
137
+ postpone(job, DEFERRED_RETRY_MS)
138
+ return
139
+ }
140
+ if (isDisposed()) return
103
141
 
104
- if (!(await enabled())) return
105
- if (await shouldDefer(job.sessionID)) return
106
- await inject(job.sessionID, buildFirePrompt(job, new Date(at)))
142
+ const delivered = await inject(job.sessionID, buildFirePrompt(job, new Date(at)))
143
+ if (isDisposed()) return
144
+ if (!delivered) {
145
+ // 会话忙或末条是用户输入:保留任务稍后重试,one-shot 不会因此丢失。
146
+ postpone(job, DELIVERY_RETRY_MS)
147
+ return
148
+ }
149
+
150
+ if (recurring) await store.markFired([job.id], at)
151
+ else await store.removeJobs([job.id])
152
+ armTimers()
107
153
  }
108
154
 
109
155
  /** 输入:会话 ID;输出:无。预期:处理该会话已到点的任务,否则补武装 timer。 */
@@ -129,6 +175,8 @@ export function createSchedulingContinuation(args: {
129
175
  if (typeof sessionID !== "string" || sessionID.length === 0) return
130
176
 
131
177
  if (event.type === "session.idle") {
178
+ // idle 表示上一轮已经结束;清掉 pending 让本轮可以继续注入。
179
+ pending.delete(sessionID)
132
180
  await handleIdle(sessionID)
133
181
  return
134
182
  }
@@ -150,11 +198,16 @@ export function createSchedulingContinuation(args: {
150
198
  if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
151
199
  pending.delete(sessionID)
152
200
  }
201
+ // 注入后模型失败/被打断时清掉 pending,避免后续任务永远被挡住。
202
+ if (event.type === "session.execution.failed" || event.type === "session.execution.interrupted") {
203
+ pending.delete(sessionID)
204
+ }
153
205
  },
154
206
 
155
207
  armTimers,
156
208
 
157
209
  dispose() {
210
+ lifecycle.disposed = true
158
211
  for (const timer of timers.values()) clearTimeout(timer)
159
212
  timers.clear()
160
213
  inFlight.clear()
@@ -95,8 +95,9 @@ export function parseCronExpression(expr: string): CronFields | null {
95
95
 
96
96
  /**
97
97
  * 输入:展开后的字段 + 起点时间。
98
- * 输出:严格晚于起点、第一个匹配的本地时刻,一年内无匹配返回 `null`。
98
+ * 输出:严格晚于起点、第一个匹配的本地时刻,四年内无匹配返回 `null`。
99
99
  * 预期:dayOfMonth 与 dayOfWeek 都受限时任一匹配即可(OR 语义)。
100
+ * 窗口取四年而不是一年,保证 `0 0 29 2 *`(闰日)能算出下一次。
100
101
  */
101
102
  export function computeNextCronRun(fields: CronFields, from: Date): Date | null {
102
103
  const minuteSet = new Set(fields.minute)
@@ -112,7 +113,8 @@ export function computeNextCronRun(fields: CronFields, from: Date): Date | null
112
113
  t.setSeconds(0, 0)
113
114
  t.setMinutes(t.getMinutes() + 1)
114
115
 
115
- const maxIter = 366 * 24 * 60
116
+ // 四年覆盖一个完整闰年周期;月份/日期跳过让稀疏表达式远达不到这个上限。
117
+ const maxIter = 4 * 366 * 24 * 60
116
118
  for (let i = 0; i < maxIter; i += 1) {
117
119
  if (!monthSet.has(t.getMonth() + 1)) {
118
120
  t.setMonth(t.getMonth() + 1, 1)
@@ -35,7 +35,13 @@ export async function registerScheduling(
35
35
  const jobs = createSchedulingStore(store)
36
36
  await jobs.load()
37
37
 
38
- const scheduler = createLoopScheduler({ ctx, roles, shouldDefer })
38
+ /** 输入:无;输出:总开关与 scheduling 开关是否都打开。 */
39
+ const isEnabled = async (): Promise<boolean> => {
40
+ const settings = await loadSettings(store)
41
+ return settings.enabled && settings.scheduling.enabled
42
+ }
43
+
44
+ const scheduler = createLoopScheduler({ ctx, roles, shouldDefer, enabled: isEnabled })
39
45
  const tools = await registerSchedulingTools(ctx, jobs, scheduler, roles)
40
46
  const commands = await registerSchedulingCommands(ctx, jobs, scheduler, roles)
41
47
 
@@ -44,10 +50,7 @@ export async function registerScheduling(
44
50
  store: jobs,
45
51
  roles,
46
52
  shouldDefer,
47
- enabled: async () => {
48
- const settings = await loadSettings(store)
49
- return settings.enabled && settings.scheduling.enabled
50
- },
53
+ enabled: isEnabled,
51
54
  })
52
55
  continuation.armTimers()
53
56
 
@@ -4,6 +4,7 @@ import type { Plugin } from "@opencode/plugin"
4
4
 
5
5
  import { readEventData } from "../shared/events.ts"
6
6
  import { lastMessageIsUserInput } from "../shared/messages.ts"
7
+ import { capTimerDelay, MAX_TIMER_DELAY_MS } from "../shared/timer.ts"
7
8
  import { DEFAULT_MAX_AGE_DAYS, type Loop } from "./types.ts"
8
9
  import type { SessionRoles } from "../goal/session-role.ts"
9
10
 
@@ -15,6 +16,9 @@ const FALLBACK_WAKEUP_MS = 20 * 60 * 1000
15
16
  const WAKEUP_MIN_S = 60
16
17
  const WAKEUP_MAX_S = 3600
17
18
 
19
+ /** fixed 循环的最大间隔:与 7 天过期对齐,避免 setTimeout 超出 32 位范围。 */
20
+ const MAX_LOOP_INTERVAL_SECONDS = 7 * 24 * 60 * 60
21
+
18
22
  /** `schedule_wakeup` 的结果。 */
19
23
  export type ScheduleWakeupResult =
20
24
  | { readonly ok: true; readonly id: string; readonly fireAt: number }
@@ -51,12 +55,16 @@ export function createLoopScheduler(args: {
51
55
  readonly ctx: Plugin.Context
52
56
  readonly roles: SessionRoles
53
57
  readonly shouldDefer: (sessionID: string) => Promise<boolean>
58
+ /** 输入:无;输出:scheduling 是否启用。缺省视为启用(测试与旧调用兼容)。 */
59
+ readonly enabled?: () => Promise<boolean>
54
60
  }): LoopScheduler {
55
61
  const { ctx, roles, shouldDefer } = args
62
+ const isEnabled = args.enabled ?? ((): Promise<boolean> => Promise.resolve(true))
56
63
  const loops = new Map<string, Loop>()
57
64
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
58
65
  const inFlight = new Set<string>()
59
66
  const pending = new Set<string>()
67
+ const lifecycle = { disposed: false }
60
68
 
61
69
  /** 输入:会话 + prompt;输出:匹配的循环。预期:同一 prompt 只保留一个。 */
62
70
  const findLoop = (sessionID: string, prompt: string): Loop | undefined =>
@@ -85,26 +93,38 @@ export function createLoopScheduler(args: {
85
93
  stopLoop(loop.sessionID, loop.id)
86
94
  return
87
95
  }
88
- if (await shouldDefer(loop.sessionID)) return
96
+ // 功能关闭时循环暂停,但不销毁;重新开启后下一个 timer 继续。
97
+ if (!(await isEnabled().catch(() => false))) return
98
+ if (await shouldDefer(loop.sessionID).catch(() => false)) return
89
99
  loop.fires += 1
90
100
  await inject(loop.sessionID, loop.prompt)
91
101
  }
92
102
 
93
103
  /** 输入:循环 + 延迟毫秒;输出:无。预期:重设 timer,`unref` 不阻止进程退出。 */
94
104
  const armLoop = (loop: Loop, delayMs: number): void => {
105
+ if (lifecycle.disposed) return
95
106
  const existing = timers.get(loop.id)
96
107
  if (existing !== undefined) clearTimeout(existing)
97
- loop.nextFireAt = Date.now() + delayMs
108
+ const target = Number.isFinite(delayMs) ? Math.max(0, delayMs) : MAX_TIMER_DELAY_MS
109
+ loop.nextFireAt = Date.now() + target
98
110
  const handle = setTimeout(() => {
99
111
  timers.delete(loop.id)
100
- void handleFire(loop)
101
- }, Math.max(0, delayMs))
112
+ // 24 小时上限导致提前醒来时继续等剩余时间,而不是提前触发。
113
+ const remaining = (loop.nextFireAt ?? 0) - Date.now()
114
+ if (remaining > 0) {
115
+ armLoop(loop, remaining)
116
+ return
117
+ }
118
+ // timer 回调里必须吞掉异常,否则未处理的 rejection 会打断整个进程。
119
+ void handleFire(loop).catch(() => undefined)
120
+ }, capTimerDelay(target))
102
121
  handle.unref()
103
122
  timers.set(loop.id, handle)
104
123
  }
105
124
 
106
125
  /** 输入:循环;输出:无。预期:触发后按模式决定下一次。 */
107
126
  const handleFire = async (loop: Loop): Promise<void> => {
127
+ if (lifecycle.disposed) return
108
128
  const hadWakeup = loop.pendingWakeup
109
129
  await fireLoop(loop)
110
130
  if (!loops.has(loop.id)) return
@@ -129,20 +149,26 @@ export function createLoopScheduler(args: {
129
149
  const existing = findLoop(sessionID, prompt)
130
150
  if (existing !== undefined) stopLoop(sessionID, existing.id)
131
151
 
152
+ // 非法/非正间隔退化为 dynamic,避免 0 或负数把 setTimeout 变成忙循环。
153
+ const interval =
154
+ intervalSeconds !== undefined && Number.isFinite(intervalSeconds) && intervalSeconds > 0
155
+ ? Math.min(intervalSeconds, MAX_LOOP_INTERVAL_SECONDS)
156
+ : undefined
157
+
132
158
  const now = Date.now()
133
159
  const loop: Loop = {
134
160
  id: randomUUID().slice(0, 8),
135
161
  sessionID,
136
162
  prompt,
137
- mode: intervalSeconds === undefined ? "dynamic" : "fixed",
138
- ...(intervalSeconds === undefined ? {} : { intervalSeconds }),
163
+ mode: interval === undefined ? "dynamic" : "fixed",
164
+ ...(interval === undefined ? {} : { intervalSeconds: interval }),
139
165
  createdAt: now,
140
166
  expiresAt: now + DEFAULT_MAX_AGE_DAYS * 24 * 60 * 60 * 1000,
141
167
  fires: 0,
142
168
  }
143
169
  loops.set(loop.id, loop)
144
- void fireLoop(loop)
145
- armLoop(loop, intervalSeconds === undefined ? FALLBACK_WAKEUP_MS : intervalSeconds * 1000)
170
+ void fireLoop(loop).catch(() => undefined)
171
+ armLoop(loop, interval === undefined ? FALLBACK_WAKEUP_MS : interval * 1000)
146
172
  return loop
147
173
  }
148
174
 
@@ -186,7 +212,8 @@ export function createLoopScheduler(args: {
186
212
  error: "schedule_wakeup is only available inside an active dynamic /loop for this session and prompt.",
187
213
  }
188
214
  }
189
- const clamped = Math.min(Math.max(Math.round(delaySeconds), WAKEUP_MIN_S), WAKEUP_MAX_S)
215
+ const requested = Number.isFinite(delaySeconds) ? delaySeconds : WAKEUP_MIN_S
216
+ const clamped = Math.min(Math.max(Math.round(requested), WAKEUP_MIN_S), WAKEUP_MAX_S)
190
217
  const now = Date.now()
191
218
  const fireAt = Math.ceil((now + clamped * 1000) / 60_000) * 60_000
192
219
  loop.pendingWakeup = { fireAt, delaySeconds: clamped, prompt, reason }
@@ -221,10 +248,19 @@ export function createLoopScheduler(args: {
221
248
  if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
222
249
  pending.delete(sessionID)
223
250
  }
251
+ // 注入后模型失败/被打断/直接 idle 时清掉 pending,否则循环会被永久卡住。
252
+ if (
253
+ event.type === "session.execution.failed" ||
254
+ event.type === "session.execution.interrupted" ||
255
+ event.type === "session.idle"
256
+ ) {
257
+ pending.delete(sessionID)
258
+ }
224
259
  return Promise.resolve()
225
260
  },
226
261
 
227
262
  dispose() {
263
+ lifecycle.disposed = true
228
264
  for (const timer of timers.values()) clearTimeout(timer)
229
265
  timers.clear()
230
266
  loops.clear()
@@ -136,7 +136,9 @@ export function createSchedulingStore(store: ScannableStore): SchedulingStore {
136
136
  if (stored === null) continue
137
137
  const job: ScheduledJob = { ...stored, durable: true }
138
138
  const next = computeNext(job, job.lastFiredAt ?? job.createdAt)
139
- if (next !== null) job.nextRunAt = next
139
+ // 算不出下一次的任务不会再触发,跳过而不是留下一个永远过期的 nextRunAt。
140
+ if (next === null) continue
141
+ job.nextRunAt = next
140
142
  jobs.set(job.id, job)
141
143
  }
142
144
  return Promise.resolve()
@@ -149,7 +151,7 @@ export function createSchedulingStore(store: ScannableStore): SchedulingStore {
149
151
  return { ok: false, error: `Invalid cron expression '${cron}'. Expected 5 fields: M H DoM Mon DoW.` }
150
152
  }
151
153
  if (nextCronRunMs(cron, Date.now()) === null) {
152
- return { ok: false, error: `Cron expression '${cron}' does not match any calendar date in the next year.` }
154
+ return { ok: false, error: `Cron expression '${cron}' does not match any calendar date in the next 4 years.` }
153
155
  }
154
156
  const sessionJobs = [...jobs.values()].filter((job) => job.sessionID === sessionID).length
155
157
  if (sessionJobs >= MAX_JOBS) {
@@ -157,8 +159,11 @@ export function createSchedulingStore(store: ScannableStore): SchedulingStore {
157
159
  }
158
160
 
159
161
  const now = Date.now()
162
+ // 8 位 hex 理论上会碰撞;撞了就换一个,避免静默覆盖已有任务。
163
+ let id = randomUUID().slice(0, JOB_ID_LENGTH)
164
+ while (jobs.has(id)) id = randomUUID().slice(0, JOB_ID_LENGTH)
160
165
  const job: ScheduledJob = {
161
- id: randomUUID().slice(0, JOB_ID_LENGTH),
166
+ id,
162
167
  sessionID,
163
168
  cron,
164
169
  prompt,
@@ -202,7 +207,12 @@ export function createSchedulingStore(store: ScannableStore): SchedulingStore {
202
207
  touched.add(job.sessionID)
203
208
  job.lastFiredAt = firedAt
204
209
  const next = computeNext(job, firedAt)
205
- if (next !== null) job.nextRunAt = next
210
+ if (next === null) {
211
+ // 再也没有下一次:删掉任务,避免 nextRunAt 永远停在过去、每次 idle 都重复触发。
212
+ jobs.delete(id)
213
+ continue
214
+ }
215
+ job.nextRunAt = next
206
216
  }
207
217
  for (const sessionID of touched) await persist(sessionID)
208
218
  },
@@ -1,5 +1,22 @@
1
1
  import type { Plugin } from "@opencode/plugin"
2
2
 
3
+ /** 本插件注入的合成用户消息标记。 */
4
+ export const SYNTHETIC_MARKERS = ["goalContinuation", "cronContinuation", "loopContinuation"] as const
5
+
6
+ /**
7
+ * 输入:一条消息。
8
+ * 输出:是否是本插件注入的合成用户消息。
9
+ * 预期:只看 `type === "user"` 且 metadata 里任一标记为 true;其他消息一律 false。
10
+ * 通知预览据此跳过续作/定时注入,避免把系统提示当成用户原话。
11
+ */
12
+ export function isSyntheticUserMessage(message: {
13
+ readonly type: string
14
+ readonly metadata?: Record<string, unknown>
15
+ }): boolean {
16
+ if (message.type !== "user") return false
17
+ return SYNTHETIC_MARKERS.some((marker) => message.metadata?.[marker] === true)
18
+ }
19
+
3
20
  /**
4
21
  * 输入:插件上下文 + 会话 ID + 注入标记。
5
22
  * 输出:末条消息是否是真实用户输入(不是本插件注入的)。
@@ -15,7 +15,8 @@ export async function forEachScannedEntry(
15
15
  for (;;) {
16
16
  const page = await store.scan({ prefix, limit, ...(after === undefined ? {} : { after }) })
17
17
  for (const entry of page.entries) await visit(entry)
18
- if (page.next === undefined) return
18
+ // 游标不前进时立即停下,防止异常存储导致死循环。
19
+ if (page.next === undefined || page.next === after) return
19
20
  after = page.next
20
21
  }
21
22
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `setTimeout` 的安全上限。
3
+ *
4
+ * 预期:超过 24 小时的等待拆成多段,避免 Node/Bun 把大于 2^31-1 毫秒的延迟
5
+ * 当成 1 毫秒立即触发(年抛、闰日等长周期 cron 会踩到)。
6
+ */
7
+ export const MAX_TIMER_DELAY_MS = 24 * 60 * 60 * 1000
8
+
9
+ /**
10
+ * 输入:期望延迟毫秒。
11
+ * 输出:夹到 `[0, MAX_TIMER_DELAY_MS]` 的延迟;非有限值按上限处理,避免 0 延迟忙循环。
12
+ */
13
+ export function capTimerDelay(delayMs: number): number {
14
+ if (!Number.isFinite(delayMs)) return MAX_TIMER_DELAY_MS
15
+ return Math.min(Math.max(0, delayMs), MAX_TIMER_DELAY_MS)
16
+ }