my-opencode-toolkit 0.2.1 → 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 +124 -57
- package/package.json +1 -1
- package/src/goal/controller.ts +7 -4
- package/src/goal/tools.ts +12 -1
- package/src/notification/index.ts +4 -1
- package/src/scheduling/continuation.ts +59 -12
- package/src/scheduling/cron.ts +4 -2
- package/src/scheduling/loops.ts +32 -9
- package/src/scheduling/store.ts +14 -4
- package/src/shared/messages.ts +17 -0
- package/src/shared/timer.ts +16 -0
package/README.md
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
# my-opencode-toolkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/1cyberlangke1/my-opencode-toolkit/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
## 数据与隐私
|
|
50
127
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```
|
|
128
|
+
- 目标、任务、设置都写在 OpenCode 的插件存储里,不额外建数据库
|
|
129
|
+
- 通知预览会读取当前会话最后一条用户/助手文本,只用于本地系统通知
|
|
130
|
+
- 不发送任何遥测;不访问网络(通知命令与 OpenCode 自身除外)
|
|
131
|
+
- 删除会话时会清掉该会话的 goal、定时任务、循环、统计与角色缓存
|
|
56
132
|
|
|
57
|
-
|
|
133
|
+
## 已知限制
|
|
58
134
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
141
|
+
## 排障
|
|
66
142
|
|
|
67
|
-
|
|
143
|
+
**插件没加载**
|
|
68
144
|
|
|
69
|
-
|
|
145
|
+
1. 确认 `opencode.json` 用的是 `plugins` 数组,且包名拼写正确
|
|
146
|
+
2. 跑 `opencode plugin list` 看是否列出 `my-opencode-toolkit`
|
|
147
|
+
3. 重启 OpenCode;已存在的会话不会重建工具表,建议新开会话验证
|
|
70
148
|
|
|
71
|
-
|
|
149
|
+
**TUI 没有侧栏/设置菜单**
|
|
72
150
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
157
|
+
- 检查系统通知权限与专注助手
|
|
158
|
+
- Windows 确认 `powershell.exe` 在 PATH;macOS 确认 `osascript`;Linux 确认 `notify-send`
|
|
159
|
+
- 用 `toolkit_ping` 确认插件本身已加载
|
|
93
160
|
|
|
94
|
-
|
|
161
|
+
**定时任务没触发**
|
|
95
162
|
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
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 #
|
|
172
|
+
pnpm check # typecheck + lint + 161 单测 + TUI 测试
|
|
173
|
+
pnpm test:e2e # 需要本机 opencode 2.x 与 bun,使用隔离的 HOME/DB/config
|
|
107
174
|
```
|
|
108
175
|
|
|
109
|
-
|
|
176
|
+
e2e 用假 provider 驱动工具调用,不会请求真实模型;它会在临时目录里启动一个隔离的 OpenCode server,不读写日常数据。
|
|
110
177
|
|
|
111
|
-
##
|
|
178
|
+
## 许可证
|
|
112
179
|
|
|
113
|
-
MIT
|
|
180
|
+
MIT
|
package/package.json
CHANGED
package/src/goal/controller.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
const
|
|
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,
|
|
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"
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
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()
|
package/src/scheduling/cron.ts
CHANGED
|
@@ -95,8 +95,9 @@ export function parseCronExpression(expr: string): CronFields | null {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* 输入:展开后的字段 + 起点时间。
|
|
98
|
-
*
|
|
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
|
-
|
|
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)
|
package/src/scheduling/loops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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:
|
|
138
|
-
...(
|
|
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,
|
|
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
|
|
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()
|
package/src/scheduling/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|
package/src/shared/messages.ts
CHANGED
|
@@ -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
|
+
}
|