my-opencode-toolkit 0.0.0-stage → 0.2.2

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/index.ts +3 -0
  4. package/package.json +65 -4
  5. package/src/goal/command.ts +84 -0
  6. package/src/goal/continuation.ts +211 -0
  7. package/src/goal/controller.ts +107 -0
  8. package/src/goal/error.ts +94 -0
  9. package/src/goal/filter.ts +21 -0
  10. package/src/goal/index.ts +43 -0
  11. package/src/goal/parse-command.ts +31 -0
  12. package/src/goal/prompts.ts +84 -0
  13. package/src/goal/session-role.ts +37 -0
  14. package/src/goal/store.ts +91 -0
  15. package/src/goal/tools.ts +131 -0
  16. package/src/goal/types.ts +75 -0
  17. package/src/i18n/en.ts +61 -0
  18. package/src/i18n/index.ts +28 -0
  19. package/src/i18n/types.ts +57 -0
  20. package/src/i18n/zh.ts +61 -0
  21. package/src/index.ts +150 -0
  22. package/src/notification/format.ts +42 -0
  23. package/src/notification/index.ts +165 -0
  24. package/src/notification/send.ts +104 -0
  25. package/src/scheduling/command.ts +181 -0
  26. package/src/scheduling/continuation.ts +164 -0
  27. package/src/scheduling/cron.ts +309 -0
  28. package/src/scheduling/filter.ts +25 -0
  29. package/src/scheduling/index.ts +68 -0
  30. package/src/scheduling/loops.ts +235 -0
  31. package/src/scheduling/store.ts +228 -0
  32. package/src/scheduling/tools.ts +183 -0
  33. package/src/scheduling/types.ts +54 -0
  34. package/src/shared/events.ts +44 -0
  35. package/src/shared/format.ts +16 -0
  36. package/src/shared/json.ts +44 -0
  37. package/src/shared/keys.ts +26 -0
  38. package/src/shared/messages.ts +17 -0
  39. package/src/shared/rpc.ts +214 -0
  40. package/src/shared/scan.ts +21 -0
  41. package/src/shared/settings-store.ts +28 -0
  42. package/src/shared/settings.ts +133 -0
  43. package/src/shared/types.ts +4 -0
  44. package/src/stats.ts +215 -0
  45. package/src/tools/ping.ts +40 -0
  46. package/src/tui/index.tsx +274 -0
  47. package/tui.ts +3 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1cyberlangke1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,113 @@
1
- # Temporary Holding Version
1
+ # my-opencode-toolkit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenCode 插件:系统通知、持久目标、定时任务与循环、TUI 状态面板。
4
+
5
+ ## 功能
6
+
7
+ ### 系统通知
8
+
9
+ - 会话空闲(任务完成)、权限请求(`permission.asked`)、代理提问(`question` 工具)三种通知
10
+ - 平台:Windows toast(PowerShell)、macOS(osascript)、Linux(notify-send)
11
+ - 标题带会话名,正文按设置语言本地化,长度截断避免 toast 溢出
12
+
13
+ ### 持久目标(Goal)
14
+
15
+ - 工具:`create_goal`、`get_goal`、`update_goal`
16
+ - 命令:`/goal <objective>`、`/goal pause|resume|clear|edit`、裸 `/goal` 查看
17
+ - 目标 active 时会话空闲自动注入续作提示;同一原因连续 3 次阻塞才标记 `blocked`
18
+ - 记账:每步的输入、输出、缓存读写都计入;达到 token 预算自动转 `budget_limited`
19
+ - 用户打断自动暂停;余额不足暂停并锁死,需显式 `/goal resume`
20
+
21
+ ### 定时任务与循环
22
+
23
+ - 工具:`cron_create`、`cron_list`、`cron_delete`、`schedule_wakeup`
24
+ - 命令:
25
+ - `/cron <expr> <prompt>` 创建重复任务
26
+ - `/cron once <expr> <prompt>` 一次性任务
27
+ - `/cron list`、`/cron delete <id>`
28
+ - `/loop [interval] [prompt]` 启动循环,`/loop stop` 停止
29
+ - 5 字段 cron(本地时区),支持 `*`、步进、范围、列表;每会话上限 50;重复任务 7 天过期
30
+ - durable 任务存插件存储,重启恢复并重新武装 timer;非 durable 只留内存
31
+ - 循环:固定间隔或动态(模型用 `schedule_wakeup` 决定下次,60–3600 秒,20 分钟兜底)
32
+ - 裸 `/loop` 使用内置维护提示词;项目根目录有非空 `loop.md` 时优先用它
33
+
34
+ ### TUI 面板
35
+
36
+ - 侧栏显示目标、缓存命中率、工具使用 Top3、子代理数量、循环与定时任务数量
37
+ - 统计不单独存一份:每个会话第一次打开面板时,从 OpenCode 已存消息(`session.context`)重建一次,
38
+ 之后用事件增量维护;插件重载或进程重启后再重建
39
+ - 设置项可控制显示内容与折叠
40
+ - `/my-opencode-toolkit` 打开设置菜单:启用、语言、通知、Goal、定时任务、侧栏
41
+
42
+ ### 子代理隔离
43
+
44
+ 子代理会话里不会出现 goal / scheduling 工具,也不会收到任何续作或定时注入。
45
+ 实现方式是在 `context` 钩子里从工具表删除,并在注入前检查 `session.parentID`。
46
+
47
+ ## 安装
48
+
49
+ 在 `~/.config/opencode/opencode.json` 的 `plugins` 数组里加包名:
50
+
51
+ ```jsonc
52
+ {
53
+ "plugins": ["my-opencode-toolkit"]
54
+ }
55
+ ```
56
+
57
+ 本地开发可以直接指向仓库目录:
58
+
59
+ ```jsonc
60
+ {
61
+ "plugins": ["D:\\Git_Repository\\my-opencode-toolkit"]
62
+ }
63
+ ```
64
+
65
+ 重启 OpenCode 后生效。
66
+
67
+ 要求 Node 20+;Windows 通知走 PowerShell toast,macOS 走 `osascript`,Linux 走 `notify-send`。
68
+
69
+ ## 设置
70
+
71
+ 设置存在插件存储里,首次启动用默认值;通过 `/my-opencode-toolkit` 菜单修改,立即生效。
72
+
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` | 折叠侧栏 |
89
+
90
+ ## 优先级
91
+
92
+ 会话空闲时:Goal active > 定时任务 / 循环。Goal 进行中,定时任务到点只重排不注入。
93
+
94
+ ## 已知限制
95
+
96
+ - 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)。
97
+ - 通知是尽力而为:系统关闭通知、专注助手拦截或缺少对应命令时静默跳过,不影响会话。
98
+ - 续作与定时注入只在主会话生效;子代理会话既看不到相关工具,也不会被注入。
99
+ - 定时任务按本地时区解析 5 字段 cron,不支持秒级字段。
100
+
101
+ ## 开发
102
+
103
+ ```bash
104
+ pnpm install
105
+ pnpm check # typecheck + lint + 单测 + TUI 渲染测试
106
+ pnpm test:e2e # 隔离环境启动真实 opencode,假 provider 驱动工具调用
107
+ ```
108
+
109
+ 要求 Node 20+(OpenCode v2 要求),测试用 vitest + bun。
110
+
111
+ ## License
112
+
113
+ MIT,见 [LICENSE](LICENSE)。
package/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ // 本地插件目录入口:OpenCode 的本地插件解析会找 <dir>/index,
2
+ // 这里只做转发,真正实现放在 src/index.ts。
3
+ export { default } from "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "my-opencode-toolkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.2",
4
+ "description": "OpenCode 插件:系统通知、持久目标、定时任务与 TUI 面板",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "1cyberlangke1",
8
+ "homepage": "https://github.com/1cyberlangke1/my-opencode-toolkit#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/1cyberlangke1/my-opencode-toolkit.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/1cyberlangke1/my-opencode-toolkit/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "plugin",
19
+ "notification",
20
+ "goal",
21
+ "cron",
22
+ "tui"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "exports": {
28
+ ".": "./index.ts",
29
+ "./tui": "./tui.ts"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "index.ts",
34
+ "tui.ts"
35
+ ],
36
+ "dependencies": {
37
+ "effect": "4.0.1"
38
+ },
39
+ "peerDependencies": {
40
+ "@opencode/plugin": "^2.0.32",
41
+ "@opentui/core": ">=0.5.17",
42
+ "@opentui/solid": ">=0.5.17",
43
+ "solid-js": ">=1.9.0"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsc --noEmit",
47
+ "lint": "eslint .",
48
+ "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm test:tui",
49
+ "test": "vitest run",
50
+ "test:tui": "cd tui-test && bun test",
51
+ "test:e2e": "bun scripts/e2e-load.mjs",
52
+ "test:watch": "vitest"
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/js": "^10.0.1",
56
+ "@opencode/plugin": "^2.1.2",
57
+ "@opencode/theme": "2.1.2",
58
+ "@opentui/core": "0.6.6",
59
+ "@opentui/solid": "0.6.6",
60
+ "@types/node": "^26.6.5",
61
+ "eslint": "^10.13.0",
62
+ "solid-js": "1.9.15",
63
+ "typescript": "^6.0.3",
64
+ "typescript-eslint": "^8.71.2",
65
+ "vitest": "^5.0.5"
66
+ }
67
+ }
@@ -0,0 +1,84 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { loadSettings } from "../shared/settings-store.ts"
4
+ import type { GoalContinuation } from "./continuation.ts"
5
+ import type { GoalController } from "./controller.ts"
6
+ import { parseGoalCommand } from "./parse-command.ts"
7
+
8
+ const DESCRIPTION = "Set, inspect, pause, resume or clear the persistent goal for this session"
9
+
10
+ const USAGE = "Usage: /goal [<objective>|clear|edit|pause|resume]"
11
+
12
+ /**
13
+ * 输入:控制器 + 续作控制器 + 会话 ID + 原始参数。
14
+ * 输出:给模型看的英文反馈。
15
+ * 预期:set 会清掉旧目标与锁死标志;pause/resume 只对已有目标生效。
16
+ */
17
+ async function applyGoalCommand(
18
+ controller: GoalController,
19
+ continuation: GoalContinuation,
20
+ sessionID: string,
21
+ rawArgs: string,
22
+ ): Promise<string> {
23
+ const parsed = parseGoalCommand(rawArgs)
24
+ switch (parsed.kind) {
25
+ case "set": {
26
+ await controller.setGoal(sessionID, parsed.objective)
27
+ // 新目标 = 全新开始:清掉旧会话残留的打断/超长/余额锁死标志。
28
+ continuation.resetRunState(sessionID)
29
+ return `Goal set: ${parsed.objective}`
30
+ }
31
+ case "pause": {
32
+ const goal = await controller.pauseGoal(sessionID)
33
+ return goal !== null ? `Goal paused. Current goal: ${goal.objective}` : "No active goal"
34
+ }
35
+ case "resume": {
36
+ const goal = await controller.resumeGoal(sessionID)
37
+ continuation.resetRunState(sessionID)
38
+ return goal !== null ? `Goal resumed. Current goal: ${goal.objective}` : "No active goal"
39
+ }
40
+ case "clear": {
41
+ const existed = await controller.clearGoal(sessionID)
42
+ return existed ? "Goal cleared" : "No active goal"
43
+ }
44
+ case "edit": {
45
+ const goal = await controller.getGoal(sessionID)
46
+ if (goal === null) return USAGE
47
+ return `Current goal: ${goal.objective}. Edit with /goal <new objective>.\n${USAGE}`
48
+ }
49
+ case "show": {
50
+ const goal = await controller.getGoal(sessionID)
51
+ if (goal === null) return "No active goal"
52
+ return `Goal (${goal.status}): ${goal.objective}`
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 输入:插件上下文 + 控制器 + 续作控制器。
59
+ * 输出:注册 `/goal` 命令,返回注销句柄。
60
+ * 预期:命令本身不进对话流;执行后把反馈作为普通 prompt 发给当前会话。
61
+ */
62
+ export async function registerGoalCommand(
63
+ ctx: Plugin.Context,
64
+ controller: GoalController,
65
+ continuation: GoalContinuation,
66
+ ): Promise<{ dispose(): Promise<void> }> {
67
+ const registration = await ctx.command.transform((editor) => {
68
+ editor.add({
69
+ name: "goal",
70
+ description: DESCRIPTION,
71
+ execute: async (input) => {
72
+ const settings = await loadSettings(ctx.storage)
73
+ const text =
74
+ settings.enabled && settings.goal.enabled
75
+ ? await applyGoalCommand(controller, continuation, input.sessionID, input.prompt.text)
76
+ : "Error: the goal feature is disabled in settings."
77
+ // /goal 只关心文本参数;附件不回传,避免把命令附件重复塞进会话。
78
+ await ctx.session.prompt({ sessionID: input.sessionID, text, delivery: input.delivery })
79
+ },
80
+ })
81
+ })
82
+ await ctx.command.reload()
83
+ return registration
84
+ }
@@ -0,0 +1,211 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { readEventData } from "../shared/events.ts"
4
+ import type { PluginEvent } from "../shared/events.ts"
5
+ import { asRecord, type JsonStore } from "../shared/json.ts"
6
+ import { lastMessageIsUserInput } from "../shared/messages.ts"
7
+ import { loadSettings } from "../shared/settings-store.ts"
8
+ import type { GoalController } from "./controller.ts"
9
+ import { classifyGoalFailure } from "./error.ts"
10
+ import { buildContinuationPrompt } from "./prompts.ts"
11
+ import type { SessionRoles } from "./session-role.ts"
12
+
13
+ /** 注入消息的 metadata 标记,用于识别“这条 user 消息是我们发的”。 */
14
+ const CONTINUATION_MARKER = "goalContinuation"
15
+
16
+ /** 会话运行期锁死标志。 */
17
+ type RunState = {
18
+ /** 用户打断过,暂停后需显式 resume。 */
19
+ wasCancelled: boolean
20
+ /** 上下文超长,等下一次模型活动自动解除。 */
21
+ tokenLimitDetected: boolean
22
+ /** 余额不足,只有显式 resume 才解除。 */
23
+ insufficientFundsDetected: boolean
24
+ }
25
+
26
+ /** 续作控制器。 */
27
+ export type GoalContinuation = {
28
+ /** 输入:会话事件;输出:无。预期:按事件类型记账、续作或暂停。 */
29
+ handle(event: PluginEvent): Promise<void>
30
+ /** 输入:会话 ID;输出:无。预期:清除打断/超长/余额锁死与待确认注入。 */
31
+ resetRunState(sessionID: string): void
32
+ }
33
+
34
+ /**
35
+ * 输入:插件上下文、存储、目标控制器、可选时钟。
36
+ * 输出:续作控制器。
37
+ * 预期:只在 goal 处于 active 且未被锁死时注入续作;一次 idle 最多注入一条。
38
+ */
39
+ export function createGoalContinuation(args: {
40
+ readonly ctx: Plugin.Context
41
+ readonly store: JsonStore
42
+ readonly controller: GoalController
43
+ readonly roles: SessionRoles
44
+ readonly now?: () => number
45
+ }): GoalContinuation {
46
+ const { ctx, store, controller, roles } = args
47
+ const now = args.now ?? Date.now
48
+ const runStates = new Map<string, RunState>()
49
+ const pending = new Set<string>()
50
+ const inFlight = new Set<string>()
51
+ const lastAccountAt = new Map<string, number>()
52
+
53
+ /** 输入:会话 ID;输出:可变的运行状态。预期:不存在时创建默认值。 */
54
+ const stateOf = (sessionID: string): RunState => {
55
+ const existing = runStates.get(sessionID)
56
+ if (existing !== undefined) return existing
57
+ const created: RunState = { wasCancelled: false, tokenLimitDetected: false, insufficientFundsDetected: false }
58
+ runStates.set(sessionID, created)
59
+ return created
60
+ }
61
+
62
+ /** 输入:会话 ID;输出:设置是否允许续作。预期:总开关或 goal 开关关闭时不续作。 */
63
+ const enabled = async (): Promise<boolean> => {
64
+ const settings = await loadSettings(store)
65
+ return settings.enabled && settings.goal.enabled
66
+ }
67
+
68
+ /** 输入:会话 ID + token 增量;输出:无。预期:只对 active 目标记账。 */
69
+ const account = async (sessionID: string, tokens: number): Promise<void> => {
70
+ const at = now()
71
+ const last = lastAccountAt.get(sessionID)
72
+ const elapsedSeconds = last === undefined ? 0 : Math.max(0, Math.floor((at - last) / 1000))
73
+ lastAccountAt.set(sessionID, at)
74
+ if (tokens === 0 && elapsedSeconds === 0) return
75
+ await controller.accountUsage(sessionID, { tokens, elapsedSeconds })
76
+ }
77
+
78
+ /** 输入:会话 ID + 文本;输出:无。预期:注入一条带标记的续作提示;并发或失败时静默跳过。 */
79
+ const inject = async (sessionID: string, text: string): Promise<void> => {
80
+ if (inFlight.has(sessionID)) return
81
+ inFlight.add(sessionID)
82
+ try {
83
+ await ctx.session.prompt({ sessionID, text, delivery: "steer", metadata: { [CONTINUATION_MARKER]: true } })
84
+ pending.add(sessionID)
85
+ } catch {
86
+ // 注入失败:下一轮 idle 再试,不阻塞其他会话
87
+ } finally {
88
+ inFlight.delete(sessionID)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 输入:会话 ID。
94
+ * 输出:末条消息是否是真实用户输入。
95
+ * 预期:委托共享 helper,读不到消息时返回 false。
96
+ */
97
+ const userInputPending = (sessionID: string): Promise<boolean> =>
98
+ lastMessageIsUserInput(ctx, sessionID, CONTINUATION_MARKER)
99
+
100
+ /** 输入:会话 ID;输出:无。预期:active 且未锁死、且不是子代理时注入续作。 */
101
+ const handleIdle = async (sessionID: string): Promise<void> => {
102
+ if (!(await enabled())) return
103
+ // 子代理不续作:子代理的生命周期由主会话的工具调用驱动。
104
+ if (await roles.isSubagent(sessionID)) return
105
+
106
+ const state = stateOf(sessionID)
107
+ if (state.wasCancelled || state.tokenLimitDetected || state.insufficientFundsDetected) return
108
+
109
+ // 上一条续作注入后没有产生任何 step:说明模型没有接手,先跳过一次避免死循环。
110
+ if (pending.has(sessionID)) {
111
+ pending.delete(sessionID)
112
+ return
113
+ }
114
+
115
+ const goal = await controller.getGoal(sessionID)
116
+ if (goal?.status !== "active") return
117
+
118
+ // 末条是真实用户输入(上一条续作还没被回复)时跳过,避免叠加。
119
+ if (await userInputPending(sessionID)) return
120
+
121
+ await inject(sessionID, buildContinuationPrompt(goal))
122
+ }
123
+
124
+ /** 输入:会话 ID + 错误载荷;输出:无。预期:余额不足/超长锁死,其余只记录。 */
125
+ const handleFailure = async (sessionID: string, error: unknown): Promise<void> => {
126
+ if (!(await enabled())) return
127
+
128
+ const state = stateOf(sessionID)
129
+ const kind = classifyGoalFailure(error)
130
+ if (kind === "insufficient_funds") {
131
+ state.insufficientFundsDetected = true
132
+ await controller.pauseGoal(sessionID)
133
+ return
134
+ }
135
+ if (kind === "token_limit") {
136
+ state.tokenLimitDetected = true
137
+ }
138
+ }
139
+
140
+ /** 输入:会话 ID + 打断原因;输出:无。预期:用户打断时暂停目标。 */
141
+ const handleInterrupted = async (sessionID: string, reason: string): Promise<void> => {
142
+ if (reason !== "user") return
143
+ if (!(await enabled())) return
144
+ const state = stateOf(sessionID)
145
+ state.wasCancelled = true
146
+ await controller.pauseGoal(sessionID)
147
+ }
148
+
149
+ /** 输入:任意值;输出:本步消耗的 token 总数。预期:输入、输出、缓存读写都计入。 */
150
+ const readTokens = (raw: unknown): number => {
151
+ const record = asRecord(raw)
152
+ if (record === undefined) return 0
153
+ const cache = asRecord(record["cache"]) ?? {}
154
+ const parts = [record["input"], record["output"], cache["read"], cache["write"]]
155
+ let total = 0
156
+ for (const part of parts) {
157
+ if (typeof part === "number" && Number.isFinite(part) && part > 0) total += part
158
+ }
159
+ return total
160
+ }
161
+
162
+ return {
163
+ async handle(event) {
164
+ const data = readEventData(event)
165
+ if (data === undefined) return
166
+ const sessionID = data["sessionID"]
167
+ if (typeof sessionID !== "string" || sessionID.length === 0) return
168
+
169
+ switch (event.type) {
170
+ case "session.step.ended": {
171
+ // 模型活动:解除打断/超长锁死,并记账。
172
+ const state = stateOf(sessionID)
173
+ state.wasCancelled = false
174
+ state.tokenLimitDetected = false
175
+ pending.delete(sessionID)
176
+ if (!(await enabled())) return
177
+ await account(sessionID, readTokens(data["tokens"]))
178
+ return
179
+ }
180
+ case "session.idle":
181
+ await handleIdle(sessionID)
182
+ return
183
+ case "session.execution.failed":
184
+ await handleFailure(sessionID, data["error"])
185
+ return
186
+ case "session.execution.interrupted":
187
+ await handleInterrupted(sessionID, typeof data["reason"] === "string" ? data["reason"] : "")
188
+ return
189
+ case "session.deleted":
190
+ runStates.delete(sessionID)
191
+ pending.delete(sessionID)
192
+ inFlight.delete(sessionID)
193
+ lastAccountAt.delete(sessionID)
194
+ roles.forget(sessionID)
195
+ // 会话删了就把目标一起清掉,避免插件存储里留下永远读不到的 key。
196
+ await controller.clearGoal(sessionID)
197
+ return
198
+ default:
199
+ return
200
+ }
201
+ },
202
+
203
+ resetRunState(sessionID) {
204
+ const state = stateOf(sessionID)
205
+ state.wasCancelled = false
206
+ state.tokenLimitDetected = false
207
+ state.insufficientFundsDetected = false
208
+ pending.delete(sessionID)
209
+ },
210
+ }
211
+ }
@@ -0,0 +1,107 @@
1
+ import type { JsonStore } from "../shared/json.ts"
2
+ import { clearGoal, createGoal, nowSeconds, readGoal, setGoalStatus, writeGoal } from "./store.ts"
3
+ import type { Goal } from "./types.ts"
4
+
5
+ /** 同一阻塞原因连续出现多少次才判定为 blocked。 */
6
+ export const BLOCKED_STREAK_REQUIRED = 3
7
+
8
+ /** 一次记账的增量。 */
9
+ export type GoalUsage = {
10
+ readonly tokens: number
11
+ readonly elapsedSeconds: number
12
+ }
13
+
14
+ /** 目标控制器:所有方法异步,读写都经过插件存储。 */
15
+ export type GoalController = {
16
+ setGoal(sessionID: string, objective: string, tokenBudget?: number): Promise<Goal>
17
+ getGoal(sessionID: string): Promise<Goal | null>
18
+ pauseGoal(sessionID: string): Promise<Goal | null>
19
+ resumeGoal(sessionID: string): Promise<Goal | null>
20
+ clearGoal(sessionID: string): Promise<boolean>
21
+ markComplete(sessionID: string): Promise<Goal | null>
22
+ markBlocked(sessionID: string, reason: string): Promise<Goal | null>
23
+ accountUsage(sessionID: string, usage: GoalUsage): Promise<Goal | null>
24
+ }
25
+
26
+ /**
27
+ * 输入:插件存储。
28
+ * 输出:目标控制器。
29
+ * 预期:所有状态转换都落盘;不合法转换返回 `null` 或原目标,不抛异常。
30
+ */
31
+ export function createGoalController(store: JsonStore): GoalController {
32
+ return {
33
+ async setGoal(sessionID, objective, tokenBudget) {
34
+ await clearGoal(store, sessionID)
35
+ const goal = await createGoal(store, sessionID, objective)
36
+ if (tokenBudget === undefined) return goal
37
+ const withBudget: Goal = { ...goal, tokenBudget }
38
+ await writeGoal(store, withBudget)
39
+ return withBudget
40
+ },
41
+
42
+ getGoal(sessionID) {
43
+ return readGoal(store, sessionID)
44
+ },
45
+
46
+ async pauseGoal(sessionID) {
47
+ // 只允许 active → paused:已 complete/blocked/budget_limited 的目标不覆写。
48
+ const existing = await readGoal(store, sessionID)
49
+ if (existing?.status !== "active") return null
50
+ return setGoalStatus(store, sessionID, "paused")
51
+ },
52
+
53
+ async resumeGoal(sessionID) {
54
+ const existing = await readGoal(store, sessionID)
55
+ if (existing === null) return null
56
+ if (existing.status === "active") return existing
57
+ return setGoalStatus(store, sessionID, "active")
58
+ },
59
+
60
+ clearGoal(sessionID) {
61
+ return clearGoal(store, sessionID)
62
+ },
63
+
64
+ markComplete(sessionID) {
65
+ return setGoalStatus(store, sessionID, "complete")
66
+ },
67
+
68
+ async markBlocked(sessionID, reason) {
69
+ const existing = await readGoal(store, sessionID)
70
+ if (existing === null) return null
71
+
72
+ const sameReason = existing.lastBlockedReason === reason
73
+ const streak = sameReason ? (existing.blockedStreak ?? 1) + 1 : 1
74
+ const status = streak >= BLOCKED_STREAK_REQUIRED ? "blocked" : existing.status
75
+ const updated: Goal = {
76
+ ...existing,
77
+ status,
78
+ lastBlockedReason: reason,
79
+ blockedStreak: streak,
80
+ updatedAt: nowSeconds(),
81
+ }
82
+ await writeGoal(store, updated)
83
+ return updated
84
+ },
85
+
86
+ async accountUsage(sessionID, usage) {
87
+ const goal = await readGoal(store, sessionID)
88
+ if (goal?.status !== "active") return goal
89
+
90
+ const tokenDelta = Math.max(0, usage.tokens)
91
+ const timeDelta = Math.max(0, usage.elapsedSeconds)
92
+ if (tokenDelta === 0 && timeDelta === 0) return goal
93
+
94
+ const nextTokens = goal.tokensUsed + tokenDelta
95
+ const budgetReached = goal.tokenBudget !== undefined && nextTokens >= goal.tokenBudget
96
+ const updated: Goal = {
97
+ ...goal,
98
+ tokensUsed: nextTokens,
99
+ timeUsedSeconds: goal.timeUsedSeconds + timeDelta,
100
+ updatedAt: nowSeconds(),
101
+ ...(budgetReached ? { status: "budget_limited" as const } : {}),
102
+ }
103
+ await writeGoal(store, updated)
104
+ return updated
105
+ },
106
+ }
107
+ }