my-opencode-toolkit 0.2.3 → 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 +4 -1
- package/package.json +3 -2
- package/src/goal/command.ts +4 -1
- package/src/goal/continuation.ts +4 -0
- package/src/notification/send.ts +63 -39
- package/src/scheduling/command.ts +5 -2
- package/src/scheduling/continuation.ts +6 -0
- package/src/scheduling/index.ts +8 -5
- package/src/scheduling/loops.ts +13 -0
- package/src/shared/scan.ts +2 -1
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ OpenCode v2 插件:系统通知、持久目标、定时任务与循环、TUI
|
|
|
63
63
|
- 下一次触发在四年窗口内搜索,支持 `2 月 29 日`;永远不存在的日期会被拒绝
|
|
64
64
|
- 每会话最多 50 个任务;重复任务 7 天后自动过期
|
|
65
65
|
- durable 任务存插件存储,重启恢复并重新武装 timer;非 durable 只留内存
|
|
66
|
+
- 循环只存在内存,重启后消失;需要跨重启请用 cron
|
|
66
67
|
- 循环:固定间隔或动态(模型用 `schedule_wakeup` 决定下次,60–3600 秒,20 分钟兜底)
|
|
67
68
|
- 固定间隔范围 60 秒 – 7 天;裸 `/loop` 使用内置维护提示词,项目根目录有非空 `loop.md` 时优先用它
|
|
68
69
|
|
|
@@ -135,6 +136,8 @@ OpenCode v2 插件:系统通知、持久目标、定时任务与循环、TUI
|
|
|
135
136
|
- 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)
|
|
136
137
|
- 通知依赖系统命令:Windows 用 PowerShell toast,macOS 用 `osascript`,Linux 用 `notify-send`
|
|
137
138
|
- 定时任务按 OpenCode 进程的本地时区解析;不支持秒级字段与 `L` / `W` / `?` 等扩展语法
|
|
139
|
+
- 夏令时切换当天,本地时间的 cron 可能跳过或重复一次(标准 cron 语义的固有问题)
|
|
140
|
+
- 同一会话不要同时开多个 OpenCode 实例执行目标/定时任务;插件存储没有跨进程锁
|
|
138
141
|
- 目标进行中时 cron/loop 让路,避免和续作抢同一轮对话
|
|
139
142
|
- 子代理会话不提供 goal/scheduling 工具,也不会被注入续作
|
|
140
143
|
|
|
@@ -169,7 +172,7 @@ OpenCode v2 插件:系统通知、持久目标、定时任务与循环、TUI
|
|
|
169
172
|
|
|
170
173
|
```bash
|
|
171
174
|
pnpm install
|
|
172
|
-
pnpm check # typecheck + lint +
|
|
175
|
+
pnpm check # typecheck + lint + 175 单测 + TUI 测试 + 发布包内容校验
|
|
173
176
|
pnpm test:e2e # 需要本机 opencode 2.x 与 bun,使用隔离的 HOME/DB/config
|
|
174
177
|
```
|
|
175
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-opencode-toolkit",
|
|
3
|
-
"version": "0.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",
|
package/src/goal/command.ts
CHANGED
|
@@ -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 只关心文本参数;附件不回传,避免把命令附件重复塞进会话。
|
package/src/goal/continuation.ts
CHANGED
|
@@ -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)
|
package/src/notification/send.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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(
|
|
114
|
+
export function createSender(
|
|
115
|
+
platform: NodeJS.Platform,
|
|
116
|
+
runner: CommandRunner = defaultRunner,
|
|
117
|
+
): NotificationSender {
|
|
94
118
|
switch (platform) {
|
|
95
119
|
case "win32":
|
|
96
|
-
return
|
|
120
|
+
return windowsSender(runner)
|
|
97
121
|
case "darwin":
|
|
98
|
-
return
|
|
122
|
+
return macosSender(runner)
|
|
99
123
|
case "linux":
|
|
100
|
-
return
|
|
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
|
|
|
@@ -175,6 +175,8 @@ export function createSchedulingContinuation(args: {
|
|
|
175
175
|
if (typeof sessionID !== "string" || sessionID.length === 0) return
|
|
176
176
|
|
|
177
177
|
if (event.type === "session.idle") {
|
|
178
|
+
// idle 表示上一轮已经结束;清掉 pending 让本轮可以继续注入。
|
|
179
|
+
pending.delete(sessionID)
|
|
178
180
|
await handleIdle(sessionID)
|
|
179
181
|
return
|
|
180
182
|
}
|
|
@@ -196,6 +198,10 @@ export function createSchedulingContinuation(args: {
|
|
|
196
198
|
if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
|
|
197
199
|
pending.delete(sessionID)
|
|
198
200
|
}
|
|
201
|
+
// 注入后模型失败/被打断时清掉 pending,避免后续任务永远被挡住。
|
|
202
|
+
if (event.type === "session.execution.failed" || event.type === "session.execution.interrupted") {
|
|
203
|
+
pending.delete(sessionID)
|
|
204
|
+
}
|
|
199
205
|
},
|
|
200
206
|
|
|
201
207
|
armTimers,
|
package/src/scheduling/index.ts
CHANGED
|
@@ -35,7 +35,13 @@ export async function registerScheduling(
|
|
|
35
35
|
const jobs = createSchedulingStore(store)
|
|
36
36
|
await jobs.load()
|
|
37
37
|
|
|
38
|
-
|
|
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:
|
|
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
|
|
package/src/scheduling/loops.ts
CHANGED
|
@@ -55,8 +55,11 @@ export function createLoopScheduler(args: {
|
|
|
55
55
|
readonly ctx: Plugin.Context
|
|
56
56
|
readonly roles: SessionRoles
|
|
57
57
|
readonly shouldDefer: (sessionID: string) => Promise<boolean>
|
|
58
|
+
/** 输入:无;输出:scheduling 是否启用。缺省视为启用(测试与旧调用兼容)。 */
|
|
59
|
+
readonly enabled?: () => Promise<boolean>
|
|
58
60
|
}): LoopScheduler {
|
|
59
61
|
const { ctx, roles, shouldDefer } = args
|
|
62
|
+
const isEnabled = args.enabled ?? ((): Promise<boolean> => Promise.resolve(true))
|
|
60
63
|
const loops = new Map<string, Loop>()
|
|
61
64
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
62
65
|
const inFlight = new Set<string>()
|
|
@@ -90,6 +93,8 @@ export function createLoopScheduler(args: {
|
|
|
90
93
|
stopLoop(loop.sessionID, loop.id)
|
|
91
94
|
return
|
|
92
95
|
}
|
|
96
|
+
// 功能关闭时循环暂停,但不销毁;重新开启后下一个 timer 继续。
|
|
97
|
+
if (!(await isEnabled().catch(() => false))) return
|
|
93
98
|
if (await shouldDefer(loop.sessionID).catch(() => false)) return
|
|
94
99
|
loop.fires += 1
|
|
95
100
|
await inject(loop.sessionID, loop.prompt)
|
|
@@ -243,6 +248,14 @@ export function createLoopScheduler(args: {
|
|
|
243
248
|
if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
|
|
244
249
|
pending.delete(sessionID)
|
|
245
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
|
+
}
|
|
246
259
|
return Promise.resolve()
|
|
247
260
|
},
|
|
248
261
|
|
package/src/shared/scan.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
+
// 游标不前进时立即停下,防止异常存储导致死循环。
|
|
19
|
+
if (page.next === undefined || page.next === after) return
|
|
19
20
|
after = page.next
|
|
20
21
|
}
|
|
21
22
|
}
|