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 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 + 161 单测 + TUI 测试
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",
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)
@@ -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
 
@@ -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,
@@ -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
 
@@ -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
 
@@ -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
  }