my-opencode-toolkit 0.2.2 → 0.2.3

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,124 @@ 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 只留内存
31
66
  - 循环:固定间隔或动态(模型用 `schedule_wakeup` 决定下次,60–3600 秒,20 分钟兜底)
32
- - 裸 `/loop` 使用内置维护提示词;项目根目录有非空 `loop.md` 时优先用它
67
+ - 固定间隔范围 60 秒 – 7 天;裸 `/loop` 使用内置维护提示词,项目根目录有非空 `loop.md` 时优先用它
68
+
69
+ #### 触发语义
70
+
71
+ - 任务只在会话空闲时注入;会话忙或末条是用户输入时**顺延重试**,不会静默丢失
72
+ - 功能关闭或目标进行中时任务让路,同样只顺延、不消费;重新开启后一分钟内恢复
73
+ - 重启后已过点的任务会补触发一次,不会把错过的每个时间点都补一遍
74
+ - 重复任务错过的时间点只补一次;一次性任务会一直重试到成功注入或会话被删除
33
75
 
34
76
  ### TUI 面板
35
77
 
36
78
  - 侧栏显示目标、缓存命中率、工具使用 Top3、子代理数量、循环与定时任务数量
37
- - 统计不单独存一份:每个会话第一次打开面板时,从 OpenCode 已存消息(`session.context`)重建一次,
38
- 之后用事件增量维护;插件重载或进程重启后再重建
39
- - 设置项可控制显示内容与折叠
40
- - `/my-opencode-toolkit` 打开设置菜单:启用、语言、通知、Goal、定时任务、侧栏
79
+ - 统计不单独存一份:每个会话第一次打开面板时,从 OpenCode 已存消息(`session.context`)重建
80
+ - 设置菜单:`/my-opencode-toolkit`,可热改语言、通知、Goal、定时任务与侧栏显示项
81
+ - 设置写入失败会回滚本地显示
82
+
83
+ ## 命令与工具
84
+
85
+ | 命令 | 作用 |
86
+ |---|---|
87
+ | `/my-opencode-toolkit` | 打开设置菜单(TUI) |
88
+ | `/goal <objective>` | 设置/覆盖当前会话的持久目标 |
89
+ | `/goal` | 查看当前目标 |
90
+ | `/goal pause` / `resume` / `clear` / `edit` | 暂停 / 恢复 / 清除 / 编辑目标 |
91
+ | `/cron <expr> <prompt>` | 创建重复任务 |
92
+ | `/cron once <expr> <prompt>` | 创建一次性任务 |
93
+ | `/cron list` | 列出任务 |
94
+ | `/cron delete <id>` | 删除任务 |
95
+ | `/loop [interval] [prompt]` | 启动循环;不带间隔为 dynamic 模式 |
96
+ | `/loop stop` | 停止当前会话全部循环 |
97
+
98
+ | 工具 | 作用 |
99
+ |---|---|
100
+ | `toolkit_ping` | 返回插件是否启用与当前语言,用于验证加载 |
101
+ | `create_goal` / `get_goal` / `update_goal` | 目标读写与完成/阻塞标记 |
102
+ | `cron_create` / `cron_list` / `cron_delete` | 定时任务管理 |
103
+ | `schedule_wakeup` | dynamic 循环的下次唤醒时间 |
41
104
 
42
- ### 子代理隔离
105
+ ## 设置
43
106
 
44
- 子代理会话里不会出现 goal / scheduling 工具,也不会收到任何续作或定时注入。
45
- 实现方式是在 `context` 钩子里从工具表删除,并在注入前检查 `session.parentID`。
107
+ 设置存在 OpenCode 插件存储里,通过 TUI 菜单修改;插件 `options` 只在首次运行时作为初始值。
46
108
 
47
- ## 安装
109
+ | 字段 | 默认 | 说明 |
110
+ |---|---|---|
111
+ | `lang` | `auto` | `auto` / `zh` / `en` |
112
+ | `enabled` | `true` | 插件总开关;关闭后隐藏工具、停止注入与通知 |
113
+ | `notification.enabled` | `true` | 通知总开关 |
114
+ | `notification.onComplete` | `true` | 会话完成通知 |
115
+ | `notification.onQuestion` | `true` | 代理提问通知 |
116
+ | `notification.onPermission` | `true` | 权限请求通知 |
117
+ | `notification.onlyMainSession` | `true` | 只通知主会话,忽略子代理 |
118
+ | `goal.enabled` | `true` | 持久目标功能 |
119
+ | `scheduling.enabled` | `true` | 定时任务与循环功能 |
120
+ | `sidebar.enabled` | `true` | 侧栏面板 |
121
+ | `sidebar.showCache` | `true` | 显示缓存命中率 |
122
+ | `sidebar.showTools` | `true` | 显示工具使用 Top3 |
123
+ | `sidebar.showSubagents` | `true` | 显示子代理数量 |
124
+ | `sidebar.collapsed` | `false` | 折叠侧栏内容 |
48
125
 
49
- 在 `~/.config/opencode/opencode.json` 的 `plugins` 数组里加包名:
126
+ ## 数据与隐私
50
127
 
51
- ```jsonc
52
- {
53
- "plugins": ["my-opencode-toolkit"]
54
- }
55
- ```
128
+ - 目标、任务、设置都写在 OpenCode 的插件存储里,不额外建数据库
129
+ - 通知预览会读取当前会话最后一条用户/助手文本,只用于本地系统通知
130
+ - 不发送任何遥测;不访问网络(通知命令与 OpenCode 自身除外)
131
+ - 删除会话时会清掉该会话的 goal、定时任务、循环、统计与角色缓存
56
132
 
57
- 本地开发可以直接指向仓库目录:
133
+ ## 已知限制
58
134
 
59
- ```jsonc
60
- {
61
- "plugins": ["D:\\Git_Repository\\my-opencode-toolkit"]
62
- }
63
- ```
135
+ - 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)
136
+ - 通知依赖系统命令:Windows 用 PowerShell toast,macOS 用 `osascript`,Linux 用 `notify-send`
137
+ - 定时任务按 OpenCode 进程的本地时区解析;不支持秒级字段与 `L` / `W` / `?` 等扩展语法
138
+ - 目标进行中时 cron/loop 让路,避免和续作抢同一轮对话
139
+ - 子代理会话不提供 goal/scheduling 工具,也不会被注入续作
64
140
 
65
- 重启 OpenCode 后生效。
141
+ ## 排障
66
142
 
67
- 要求 Node 20+;Windows 通知走 PowerShell toast,macOS 走 `osascript`,Linux 走 `notify-send`。
143
+ **插件没加载**
68
144
 
69
- ## 设置
145
+ 1. 确认 `opencode.json` 用的是 `plugins` 数组,且包名拼写正确
146
+ 2. 跑 `opencode plugin list` 看是否列出 `my-opencode-toolkit`
147
+ 3. 重启 OpenCode;已存在的会话不会重建工具表,建议新开会话验证
70
148
 
71
- 设置存在插件存储里,首次启动用默认值;通过 `/my-opencode-toolkit` 菜单修改,立即生效。
149
+ **TUI 没有侧栏/设置菜单**
72
150
 
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` | 折叠侧栏 |
151
+ - TUI 插件只在交互式 TUI 里加载;`opencode run` 不渲染侧栏
152
+ - 确认包内 `tui.ts`(或 npm 包的 `./tui` 导出)存在
153
+ - 重启 TUI;插件热重载不会重放已注册的 slot
89
154
 
90
- ## 优先级
155
+ **没有系统通知**
91
156
 
92
- 会话空闲时:Goal active > 定时任务 / 循环。Goal 进行中,定时任务到点只重排不注入。
157
+ - 检查系统通知权限与专注助手
158
+ - Windows 确认 `powershell.exe` 在 PATH;macOS 确认 `osascript`;Linux 确认 `notify-send`
159
+ - 用 `toolkit_ping` 确认插件本身已加载
93
160
 
94
- ## 已知限制
161
+ **定时任务没触发**
95
162
 
96
- - 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)。
97
- - 通知是尽力而为:系统关闭通知、专注助手拦截或缺少对应命令时静默跳过,不影响会话。
98
- - 续作与定时注入只在主会话生效;子代理会话既看不到相关工具,也不会被注入。
99
- - 定时任务按本地时区解析 5 字段 cron,不支持秒级字段。
163
+ - 检查会话是否长期处于目标 active 状态(会主动让路)
164
+ - 检查任务是否已超过 7 天过期
165
+ - 检查时区:cron 按本地时区解析
166
+ - 用 `/cron list` 确认任务还在
100
167
 
101
168
  ## 开发
102
169
 
103
170
  ```bash
104
171
  pnpm install
105
- pnpm check # typecheck + lint + 单测 + TUI 渲染测试
106
- pnpm test:e2e # 隔离环境启动真实 opencode,假 provider 驱动工具调用
172
+ pnpm check # typecheck + lint + 161 单测 + TUI 测试
173
+ pnpm test:e2e # 需要本机 opencode 2.x 与 bun,使用隔离的 HOME/DB/config
107
174
  ```
108
175
 
109
- 要求 Node 20+(OpenCode v2 要求),测试用 vitest + bun。
176
+ e2e 用假 provider 驱动工具调用,不会请求真实模型;它会在临时目录里启动一个隔离的 OpenCode server,不读写日常数据。
110
177
 
111
- ## License
178
+ ## 许可证
112
179
 
113
- MIT,见 [LICENSE](LICENSE)。
180
+ 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.3",
4
4
  "description": "OpenCode 插件:系统通知、持久目标、定时任务与 TUI 面板",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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")
@@ -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。 */
@@ -155,6 +201,7 @@ export function createSchedulingContinuation(args: {
155
201
  armTimers,
156
202
 
157
203
  dispose() {
204
+ lifecycle.disposed = true
158
205
  for (const timer of timers.values()) clearTimeout(timer)
159
206
  timers.clear()
160
207
  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)
@@ -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 }
@@ -57,6 +61,7 @@ export function createLoopScheduler(args: {
57
61
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
58
62
  const inFlight = new Set<string>()
59
63
  const pending = new Set<string>()
64
+ const lifecycle = { disposed: false }
60
65
 
61
66
  /** 输入:会话 + prompt;输出:匹配的循环。预期:同一 prompt 只保留一个。 */
62
67
  const findLoop = (sessionID: string, prompt: string): Loop | undefined =>
@@ -85,26 +90,36 @@ export function createLoopScheduler(args: {
85
90
  stopLoop(loop.sessionID, loop.id)
86
91
  return
87
92
  }
88
- if (await shouldDefer(loop.sessionID)) return
93
+ if (await shouldDefer(loop.sessionID).catch(() => false)) return
89
94
  loop.fires += 1
90
95
  await inject(loop.sessionID, loop.prompt)
91
96
  }
92
97
 
93
98
  /** 输入:循环 + 延迟毫秒;输出:无。预期:重设 timer,`unref` 不阻止进程退出。 */
94
99
  const armLoop = (loop: Loop, delayMs: number): void => {
100
+ if (lifecycle.disposed) return
95
101
  const existing = timers.get(loop.id)
96
102
  if (existing !== undefined) clearTimeout(existing)
97
- loop.nextFireAt = Date.now() + delayMs
103
+ const target = Number.isFinite(delayMs) ? Math.max(0, delayMs) : MAX_TIMER_DELAY_MS
104
+ loop.nextFireAt = Date.now() + target
98
105
  const handle = setTimeout(() => {
99
106
  timers.delete(loop.id)
100
- void handleFire(loop)
101
- }, Math.max(0, delayMs))
107
+ // 24 小时上限导致提前醒来时继续等剩余时间,而不是提前触发。
108
+ const remaining = (loop.nextFireAt ?? 0) - Date.now()
109
+ if (remaining > 0) {
110
+ armLoop(loop, remaining)
111
+ return
112
+ }
113
+ // timer 回调里必须吞掉异常,否则未处理的 rejection 会打断整个进程。
114
+ void handleFire(loop).catch(() => undefined)
115
+ }, capTimerDelay(target))
102
116
  handle.unref()
103
117
  timers.set(loop.id, handle)
104
118
  }
105
119
 
106
120
  /** 输入:循环;输出:无。预期:触发后按模式决定下一次。 */
107
121
  const handleFire = async (loop: Loop): Promise<void> => {
122
+ if (lifecycle.disposed) return
108
123
  const hadWakeup = loop.pendingWakeup
109
124
  await fireLoop(loop)
110
125
  if (!loops.has(loop.id)) return
@@ -129,20 +144,26 @@ export function createLoopScheduler(args: {
129
144
  const existing = findLoop(sessionID, prompt)
130
145
  if (existing !== undefined) stopLoop(sessionID, existing.id)
131
146
 
147
+ // 非法/非正间隔退化为 dynamic,避免 0 或负数把 setTimeout 变成忙循环。
148
+ const interval =
149
+ intervalSeconds !== undefined && Number.isFinite(intervalSeconds) && intervalSeconds > 0
150
+ ? Math.min(intervalSeconds, MAX_LOOP_INTERVAL_SECONDS)
151
+ : undefined
152
+
132
153
  const now = Date.now()
133
154
  const loop: Loop = {
134
155
  id: randomUUID().slice(0, 8),
135
156
  sessionID,
136
157
  prompt,
137
- mode: intervalSeconds === undefined ? "dynamic" : "fixed",
138
- ...(intervalSeconds === undefined ? {} : { intervalSeconds }),
158
+ mode: interval === undefined ? "dynamic" : "fixed",
159
+ ...(interval === undefined ? {} : { intervalSeconds: interval }),
139
160
  createdAt: now,
140
161
  expiresAt: now + DEFAULT_MAX_AGE_DAYS * 24 * 60 * 60 * 1000,
141
162
  fires: 0,
142
163
  }
143
164
  loops.set(loop.id, loop)
144
- void fireLoop(loop)
145
- armLoop(loop, intervalSeconds === undefined ? FALLBACK_WAKEUP_MS : intervalSeconds * 1000)
165
+ void fireLoop(loop).catch(() => undefined)
166
+ armLoop(loop, interval === undefined ? FALLBACK_WAKEUP_MS : interval * 1000)
146
167
  return loop
147
168
  }
148
169
 
@@ -186,7 +207,8 @@ export function createLoopScheduler(args: {
186
207
  error: "schedule_wakeup is only available inside an active dynamic /loop for this session and prompt.",
187
208
  }
188
209
  }
189
- const clamped = Math.min(Math.max(Math.round(delaySeconds), WAKEUP_MIN_S), WAKEUP_MAX_S)
210
+ const requested = Number.isFinite(delaySeconds) ? delaySeconds : WAKEUP_MIN_S
211
+ const clamped = Math.min(Math.max(Math.round(requested), WAKEUP_MIN_S), WAKEUP_MAX_S)
190
212
  const now = Date.now()
191
213
  const fireAt = Math.ceil((now + clamped * 1000) / 60_000) * 60_000
192
214
  loop.pendingWakeup = { fireAt, delaySeconds: clamped, prompt, reason }
@@ -225,6 +247,7 @@ export function createLoopScheduler(args: {
225
247
  },
226
248
 
227
249
  dispose() {
250
+ lifecycle.disposed = true
228
251
  for (const timer of timers.values()) clearTimeout(timer)
229
252
  timers.clear()
230
253
  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
  * 输出:末条消息是否是真实用户输入(不是本插件注入的)。
@@ -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
+ }