my-opencode-toolkit 0.0.0-stage → 0.2.1

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
@@ -0,0 +1,183 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { Schema } from "effect"
3
+
4
+ import type { SessionRoles } from "../goal/session-role.ts"
5
+ import { loadSettings } from "../shared/settings-store.ts"
6
+ import { cronToHuman } from "./cron.ts"
7
+ import type { LoopScheduler } from "./loops.ts"
8
+ import type { SchedulingStore } from "./store.ts"
9
+ import { DEFAULT_MAX_AGE_DAYS } from "./types.ts"
10
+
11
+ const DESCRIPTION_CREATE =
12
+ 'Schedule a recurring or one-shot prompt in this session using a 5-field cron expression ' +
13
+ '(local time: "M H DoM Mon DoW"). The prompt is auto-injected when the session is idle at the ' +
14
+ `scheduled time (no catch-up for missed fires). Recurring tasks auto-expire after ${String(DEFAULT_MAX_AGE_DAYS)} ` +
15
+ "days. Use cron_delete to cancel sooner."
16
+
17
+ const DESCRIPTION_LIST = "List all scheduled jobs for this session with their ids, schedules, and prompts."
18
+
19
+ const DESCRIPTION_DELETE = "Cancel a scheduled job by its 8-character id (returned by cron_create)."
20
+
21
+ const DESCRIPTION_WAKEUP =
22
+ "Schedule when to resume work in /loop dynamic mode. Only works when there is an active dynamic " +
23
+ "loop in this session with the exact same prompt. Pass the loop prompt back verbatim to continue; " +
24
+ "omit this call to end the loop. delaySeconds is clamped to 60-3600. Set stop: true to end the " +
25
+ "loop immediately."
26
+
27
+ const CreateInput = Schema.Struct({
28
+ cron: Schema.String,
29
+ prompt: Schema.String,
30
+ recurring: Schema.optional(Schema.Boolean),
31
+ durable: Schema.optional(Schema.Boolean),
32
+ })
33
+
34
+ const EmptyInput = Schema.Struct({})
35
+
36
+ const DeleteInput = Schema.Struct({ id: Schema.String })
37
+
38
+ const WakeupInput = Schema.Struct({
39
+ delaySeconds: Schema.Number,
40
+ prompt: Schema.String,
41
+ reason: Schema.String,
42
+ stop: Schema.optional(Schema.Boolean),
43
+ })
44
+
45
+ const EmptyOutput = Schema.Struct({})
46
+
47
+ /** 输入:文本 + 上限;输出:超长时截断并加省略号。 */
48
+ function truncate(text: string, max: number): string {
49
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text
50
+ }
51
+
52
+ /**
53
+ * 输入:插件上下文、任务存储、循环调度器、会话角色。
54
+ * 输出:注册 cron_create / cron_list / cron_delete / schedule_wakeup。
55
+ * 预期:关闭或子代理会话返回错误文本;不抛异常。
56
+ */
57
+ export async function registerSchedulingTools(
58
+ ctx: Plugin.Context,
59
+ store: SchedulingStore,
60
+ scheduler: LoopScheduler,
61
+ roles: SessionRoles,
62
+ ): Promise<{ dispose(): Promise<void> }> {
63
+ /** 输入:会话 ID;输出:不可用原因,可用时为 null。 */
64
+ const unavailable = async (sessionID: string): Promise<string | null> => {
65
+ const settings = await loadSettings(ctx.storage)
66
+ if (!settings.enabled || !settings.scheduling.enabled) {
67
+ return "Error: the scheduling feature is disabled in settings."
68
+ }
69
+ if (await roles.isSubagent(sessionID)) return "Error: scheduling tools are not available to subagents."
70
+ return null
71
+ }
72
+
73
+ return ctx.tool.transform((tools) => {
74
+ tools.add({
75
+ name: "cron_create",
76
+ options: { codemode: false },
77
+ description: DESCRIPTION_CREATE,
78
+ input: CreateInput,
79
+ output: EmptyOutput,
80
+ execute: async (input, toolCtx) => {
81
+ const sessionID = toolCtx.sessionID
82
+ const blocked = await unavailable(sessionID)
83
+ if (blocked !== null) return { output: {}, content: blocked }
84
+
85
+ const prompt = input.prompt.trim()
86
+ if (prompt.length === 0) return { output: {}, content: "Error: prompt cannot be empty." }
87
+
88
+ const result = await store.addJob({
89
+ sessionID,
90
+ cron: input.cron,
91
+ prompt,
92
+ recurring: input.recurring ?? true,
93
+ durable: input.durable ?? false,
94
+ })
95
+ if (!result.ok) return { output: {}, content: `Error: ${result.error}` }
96
+
97
+ const job = result.job
98
+ const where =
99
+ job.durable === true
100
+ ? "Persisted in plugin storage and survives restarts"
101
+ : "Session-only (dies when the plugin restarts)"
102
+ const content =
103
+ job.recurring === true
104
+ ? `Scheduled recurring job ${job.id} (${cronToHuman(job.cron)}). ${where}. Auto-expires after ${String(DEFAULT_MAX_AGE_DAYS)} days. Use cron_delete to cancel sooner.`
105
+ : `Scheduled one-shot task ${job.id} (${cronToHuman(job.cron)}). ${where}. It will fire once then auto-delete.`
106
+ return { output: {}, content }
107
+ },
108
+ })
109
+
110
+ tools.add({
111
+ name: "cron_list",
112
+ options: { codemode: false },
113
+ description: DESCRIPTION_LIST,
114
+ input: EmptyInput,
115
+ output: EmptyOutput,
116
+ execute: async (_input, toolCtx) => {
117
+ const sessionID = toolCtx.sessionID
118
+ const blocked = await unavailable(sessionID)
119
+ if (blocked !== null) return { output: {}, content: blocked }
120
+
121
+ const jobs = store.listJobs(sessionID)
122
+ if (jobs.length === 0) return { output: {}, content: "No scheduled jobs." }
123
+ const lines = jobs.map((job) => {
124
+ const kind = job.recurring === true ? "recurring" : "one-shot"
125
+ const scope = job.durable === false ? " [session-only]" : ""
126
+ return `${job.id} — ${cronToHuman(job.cron)} (${kind})${scope}: ${truncate(job.prompt, 80)}`
127
+ })
128
+ return { output: {}, content: lines.join("\n") }
129
+ },
130
+ })
131
+
132
+ tools.add({
133
+ name: "cron_delete",
134
+ options: { codemode: false },
135
+ description: DESCRIPTION_DELETE,
136
+ input: DeleteInput,
137
+ output: EmptyOutput,
138
+ execute: async (input, toolCtx) => {
139
+ const sessionID = toolCtx.sessionID
140
+ const blocked = await unavailable(sessionID)
141
+ if (blocked !== null) return { output: {}, content: blocked }
142
+
143
+ const job = store.getJob(input.id)
144
+ if (job === undefined) return { output: {}, content: `Error: No scheduled job with id '${input.id}'` }
145
+ if (job.sessionID !== sessionID) {
146
+ return { output: {}, content: `Error: Cannot delete cron job '${input.id}': owned by another session` }
147
+ }
148
+ await store.removeJobs([input.id])
149
+ return { output: {}, content: `Cancelled job ${input.id}.` }
150
+ },
151
+ })
152
+
153
+ tools.add({
154
+ name: "schedule_wakeup",
155
+ options: { codemode: false },
156
+ description: DESCRIPTION_WAKEUP,
157
+ input: WakeupInput,
158
+ output: EmptyOutput,
159
+ execute: async (input, toolCtx) => {
160
+ const sessionID = toolCtx.sessionID
161
+ const blocked = await unavailable(sessionID)
162
+ if (blocked !== null) return { output: {}, content: blocked }
163
+
164
+ if (input.stop === true) {
165
+ const stopped = scheduler.stopLoopByPrompt(sessionID, input.prompt)
166
+ return {
167
+ output: {},
168
+ content: stopped
169
+ ? `Loop stopped: ${input.prompt}`
170
+ : `Error: no active dynamic loop for prompt '${input.prompt}'`,
171
+ }
172
+ }
173
+
174
+ const result = scheduler.scheduleWakeup(sessionID, input.prompt, input.delaySeconds, input.reason)
175
+ if (!result.ok) return { output: {}, content: `Error: ${result.error}` }
176
+ return {
177
+ output: {},
178
+ content: `Wake-up scheduled for ${new Date(result.fireAt).toLocaleString()} (loop ${result.id}).`,
179
+ }
180
+ },
181
+ })
182
+ })
183
+ }
@@ -0,0 +1,54 @@
1
+ /** 任务 id 长度(`randomUUID().slice(0, 8)`,8 个 hex 字符)。 */
2
+ export const JOB_ID_LENGTH = 8
3
+
4
+ /** 每会话定时任务上限。 */
5
+ export const MAX_JOBS = 50
6
+
7
+ /** recurring 任务自动过期天数。 */
8
+ export const DEFAULT_MAX_AGE_DAYS = 7
9
+
10
+ /** 定时任务(cron)。 */
11
+ export type ScheduledJob = {
12
+ readonly id: string
13
+ readonly sessionID: string
14
+ /** 5 字段 cron(本地时区)。 */
15
+ readonly cron: string
16
+ /** 触发时注入的 prompt。 */
17
+ readonly prompt: string
18
+ readonly createdAt: number
19
+ lastFiredAt?: number
20
+ /** true = 重复触发;缺省 = one-shot(火一次自删)。 */
21
+ readonly recurring?: boolean
22
+ /** 豁免过期(系统内部任务预留)。 */
23
+ readonly permanent?: boolean
24
+ /** false = 仅内存;缺省 = 写存储。 */
25
+ readonly durable?: boolean
26
+ /** 运行时字段:下一次 fire 时刻,不写存储。 */
27
+ nextRunAt?: number
28
+ }
29
+
30
+ /** 固定间隔循环。 */
31
+ export type Loop = {
32
+ readonly id: string
33
+ readonly sessionID: string
34
+ readonly prompt: string
35
+ readonly mode: "fixed" | "dynamic"
36
+ /** fixed 模式间隔秒数。 */
37
+ readonly intervalSeconds?: number
38
+ readonly createdAt: number
39
+ readonly expiresAt: number
40
+ /** 已触发次数(运行时)。 */
41
+ fires: number
42
+ /** 下一次 fire 时刻(运行时,供 TUI 倒计时)。 */
43
+ nextFireAt?: number
44
+ /** dynamic 模式:`schedule_wakeup` 设定的待唤醒信息。 */
45
+ pendingWakeup?: Wakeup
46
+ }
47
+
48
+ /** `schedule_wakeup` 的一次唤醒。 */
49
+ export type Wakeup = {
50
+ readonly fireAt: number
51
+ readonly delaySeconds: number
52
+ readonly prompt: string
53
+ readonly reason: string
54
+ }
@@ -0,0 +1,44 @@
1
+ import { asRecord } from "./json.ts"
2
+
3
+ /** 插件事件的最小形状。 */
4
+ export type PluginEvent = {
5
+ readonly type: string
6
+ readonly data: unknown
7
+ }
8
+
9
+ /**
10
+ * 输入:插件事件。
11
+ * 输出:`data.sessionID`,没有则 `undefined`。
12
+ * 预期:兼容 `sessionID` 字段缺失或类型错误;不抛异常。
13
+ */
14
+ export function readEventSessionID(event: PluginEvent): string | undefined {
15
+ const data = asRecord(event.data)
16
+ const value = data?.["sessionID"]
17
+ return typeof value === "string" && value.length > 0 ? value : undefined
18
+ }
19
+
20
+ /**
21
+ * 输入:插件事件。
22
+ * 输出:`data` 的对象视图,非对象时为 `undefined`。
23
+ */
24
+ export function readEventData(event: PluginEvent): Record<string, unknown> | undefined {
25
+ return asRecord(event.data)
26
+ }
27
+
28
+ /** 事件处理函数。 */
29
+ export type EventHandler = (event: PluginEvent) => Promise<void>
30
+
31
+ /**
32
+ * 输入:处理函数列表 + 事件。
33
+ * 输出:无。
34
+ * 预期:按顺序调用;单个 handler 抛错被吞掉,不影响其他 handler 与后续事件。
35
+ */
36
+ export async function dispatchEvent(handlers: readonly EventHandler[], event: PluginEvent): Promise<void> {
37
+ for (const handler of handlers) {
38
+ try {
39
+ await handler(event)
40
+ } catch {
41
+ // 单个 handler 失败不能影响其他 handler 与后续事件
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 输入:token 数。
3
+ * 输出:紧凑文本(`999` / `12.3k` / `1.2m`)。
4
+ * 预期:保留一位小数,末尾 `.0` 去掉。
5
+ */
6
+ export function formatTokens(value: number): string {
7
+ const abs = Math.abs(value)
8
+ if (abs < 1000) return String(Math.trunc(value))
9
+ if (abs < 1_000_000) return trim(`${(value / 1000).toFixed(1)}k`)
10
+ return trim(`${(value / 1_000_000).toFixed(1)}m`)
11
+ }
12
+
13
+ /** 输入:`12.0k`;输出:`12k`。预期:只去掉小数部分全零的尾巴。 */
14
+ function trim(text: string): string {
15
+ return text.replace(/\.0([km])$/, "$1")
16
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 插件存储能接受的 JSON 值。
3
+ *
4
+ * 预期:只包含 JSON 原生类型,保证 `ctx.storage.set` 能直接落盘。
5
+ */
6
+ export type JsonValue =
7
+ | string
8
+ | number
9
+ | boolean
10
+ | null
11
+ | JsonValue[]
12
+ | { [key: string]: JsonValue }
13
+
14
+ /**
15
+ * 插件存储的最小接口。
16
+ *
17
+ * 输入:key 与 JSON 值。
18
+ * 输出:读取返回 `unknown`(调用方负责校验),写入无返回。
19
+ * 预期:与 `ctx.storage` 结构兼容,测试里可用内存实现替换。
20
+ */
21
+ export type JsonStore = {
22
+ get(key: string): Promise<unknown>
23
+ set(key: string, value: JsonValue): Promise<void>
24
+ remove(key: string): Promise<void>
25
+ }
26
+
27
+ /** 带分页扫描能力的存储(`ctx.storage` 满足)。 */
28
+ export type ScannableStore = JsonStore & {
29
+ scan(options: {
30
+ prefix: string
31
+ limit?: number
32
+ after?: string
33
+ }): Promise<{ entries: readonly { key: string; value: unknown }[]; next?: string }>
34
+ }
35
+
36
+ /**
37
+ * 输入:任意值。
38
+ * 输出:普通对象则返回自身,否则 `undefined`。
39
+ * 预期:用于把存储里读出的 `unknown` 收窄成可按 key 访问的记录;数组不算记录。
40
+ */
41
+ export function asRecord(value: unknown): Record<string, unknown> | undefined {
42
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
43
+ return value as Record<string, unknown>
44
+ }
@@ -0,0 +1,26 @@
1
+ /** 全局设置的存储 key。 */
2
+ export const SETTINGS_KEY = "settings"
3
+
4
+ /** Goal 状态的 key 前缀,后面拼会话 ID。 */
5
+ export const GOAL_PREFIX = "goal:"
6
+
7
+ /** 定时任务的 key 前缀,后面拼会话 ID。 */
8
+ export const SCHEDULE_PREFIX = "schedule:"
9
+
10
+ /**
11
+ * 输入:会话 ID。
12
+ * 输出:该会话的 goal 存储 key。
13
+ * 预期:不同会话互不覆盖。
14
+ */
15
+ export function goalKey(sessionID: string): string {
16
+ return `${GOAL_PREFIX}${sessionID}`
17
+ }
18
+
19
+ /**
20
+ * 输入:会话 ID。
21
+ * 输出:该会话的定时任务存储 key。
22
+ * 预期:不同会话互不覆盖。
23
+ */
24
+ export function scheduleKey(sessionID: string): string {
25
+ return `${SCHEDULE_PREFIX}${sessionID}`
26
+ }
@@ -0,0 +1,17 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ /**
4
+ * 输入:插件上下文 + 会话 ID + 注入标记。
5
+ * 输出:末条消息是否是真实用户输入(不是本插件注入的)。
6
+ * 预期:读不到消息时返回 false——宁可尝试注入,也不因为读取失败而永久死锁。
7
+ */
8
+ export async function lastMessageIsUserInput(
9
+ ctx: Plugin.Context,
10
+ sessionID: string,
11
+ marker: string,
12
+ ): Promise<boolean> {
13
+ const messages = await ctx.session.context({ sessionID }).catch(() => undefined)
14
+ const last = messages?.at(-1)
15
+ if (last?.type !== "user") return false
16
+ return last.metadata?.[marker] !== true
17
+ }
@@ -0,0 +1,214 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ import type { Settings } from "./settings.ts"
4
+
5
+ /** 设置读取的输入:当前不需要参数,保留空对象以便后续扩展。 */
6
+ const EmptyInputJson = {
7
+ type: "object",
8
+ properties: {},
9
+ additionalProperties: false,
10
+ } as const
11
+
12
+ /** 面板查询输入。 */
13
+ const PanelInputJson = {
14
+ type: "object",
15
+ properties: { sessionID: { type: "string" } },
16
+ required: ["sessionID"],
17
+ additionalProperties: false,
18
+ } as const
19
+
20
+ /** 设置对象的 JSON Schema,服务端与 TUI 共用。 */
21
+ const SettingsJson = {
22
+ type: "object",
23
+ properties: {
24
+ lang: { type: "string", enum: ["auto", "zh", "en"] },
25
+ enabled: { type: "boolean" },
26
+ notification: {
27
+ type: "object",
28
+ properties: {
29
+ enabled: { type: "boolean" },
30
+ onComplete: { type: "boolean" },
31
+ onQuestion: { type: "boolean" },
32
+ onPermission: { type: "boolean" },
33
+ onlyMainSession: { type: "boolean" },
34
+ },
35
+ required: ["enabled", "onComplete", "onQuestion", "onPermission", "onlyMainSession"],
36
+ additionalProperties: false,
37
+ },
38
+ goal: {
39
+ type: "object",
40
+ properties: { enabled: { type: "boolean" } },
41
+ required: ["enabled"],
42
+ additionalProperties: false,
43
+ },
44
+ scheduling: {
45
+ type: "object",
46
+ properties: { enabled: { type: "boolean" } },
47
+ required: ["enabled"],
48
+ additionalProperties: false,
49
+ },
50
+ sidebar: {
51
+ type: "object",
52
+ properties: {
53
+ enabled: { type: "boolean" },
54
+ showCache: { type: "boolean" },
55
+ showTools: { type: "boolean" },
56
+ showSubagents: { type: "boolean" },
57
+ collapsed: { type: "boolean" },
58
+ },
59
+ required: ["enabled", "showCache", "showTools", "showSubagents", "collapsed"],
60
+ additionalProperties: false,
61
+ },
62
+ },
63
+ required: ["lang", "enabled", "notification", "goal", "scheduling", "sidebar"],
64
+ additionalProperties: false,
65
+ } as const
66
+
67
+ /** 面板里的目标摘要。 */
68
+ const PanelGoalJson = {
69
+ type: "object",
70
+ properties: {
71
+ objective: { type: "string" },
72
+ status: { type: "string", enum: ["active", "paused", "blocked", "budget_limited", "complete"] },
73
+ tokensUsed: { type: "number" },
74
+ tokenBudget: { type: "number" },
75
+ timeUsedSeconds: { type: "number" },
76
+ },
77
+ required: ["objective", "status", "tokensUsed", "timeUsedSeconds"],
78
+ additionalProperties: false,
79
+ } as const
80
+
81
+ /** 面板里的循环摘要。 */
82
+ const PanelLoopJson = {
83
+ type: "object",
84
+ properties: {
85
+ id: { type: "string" },
86
+ mode: { type: "string", enum: ["fixed", "dynamic"] },
87
+ prompt: { type: "string" },
88
+ nextFireAt: { type: "number" },
89
+ },
90
+ required: ["id", "mode", "prompt"],
91
+ additionalProperties: false,
92
+ } as const
93
+
94
+ /** 面板里的定时任务摘要。 */
95
+ const PanelJobJson = {
96
+ type: "object",
97
+ properties: {
98
+ id: { type: "string" },
99
+ cron: { type: "string" },
100
+ prompt: { type: "string" },
101
+ nextRunAt: { type: "number" },
102
+ },
103
+ required: ["id", "cron", "prompt"],
104
+ additionalProperties: false,
105
+ } as const
106
+
107
+ /** 面板里的统计。 */
108
+ const PanelStatsJson = {
109
+ type: "object",
110
+ properties: {
111
+ steps: { type: "number" },
112
+ input: { type: "number" },
113
+ output: { type: "number" },
114
+ cached: { type: "number" },
115
+ hitRate: { type: "number" },
116
+ subagents: { type: "number" },
117
+ tools: {
118
+ type: "array",
119
+ items: {
120
+ type: "object",
121
+ properties: { name: { type: "string" }, count: { type: "number" } },
122
+ required: ["name", "count"],
123
+ additionalProperties: false,
124
+ },
125
+ },
126
+ },
127
+ required: ["steps", "input", "output", "cached", "hitRate", "subagents", "tools"],
128
+ additionalProperties: false,
129
+ } as const
130
+
131
+ const PanelOutputJson = {
132
+ type: "object",
133
+ properties: {
134
+ goal: { anyOf: [PanelGoalJson, { type: "null" }] },
135
+ loops: { type: "array", items: PanelLoopJson },
136
+ jobs: { type: "array", items: PanelJobJson },
137
+ stats: PanelStatsJson,
138
+ },
139
+ required: ["goal", "loops", "jobs", "stats"],
140
+ additionalProperties: false,
141
+ } as const
142
+
143
+ /** 服务端与 TUI 共用的 RPC 定义。 */
144
+ export const ToolkitRpc: Rpc.PortableDefinition = Rpc.define({
145
+ id: "my-opencode-toolkit",
146
+ methods: {
147
+ settingsGet: { input: EmptyInputJson, output: SettingsJson },
148
+ settingsSet: { input: SettingsJson, output: SettingsJson },
149
+ panelGet: { input: PanelInputJson, output: PanelOutputJson },
150
+ },
151
+ events: {},
152
+ })
153
+
154
+ /** 面板目标摘要。 */
155
+ export type PanelGoal = {
156
+ readonly objective: string
157
+ readonly status: string
158
+ readonly tokensUsed: number
159
+ readonly tokenBudget?: number
160
+ readonly timeUsedSeconds: number
161
+ }
162
+
163
+ /** 面板循环摘要。 */
164
+ export type PanelLoop = {
165
+ readonly id: string
166
+ readonly mode: "fixed" | "dynamic"
167
+ readonly prompt: string
168
+ readonly nextFireAt?: number
169
+ }
170
+
171
+ /** 面板任务摘要。 */
172
+ export type PanelJob = {
173
+ readonly id: string
174
+ readonly cron: string
175
+ readonly prompt: string
176
+ readonly nextRunAt?: number
177
+ }
178
+
179
+ /** 面板统计。 */
180
+ export type PanelStats = {
181
+ readonly steps: number
182
+ readonly input: number
183
+ readonly output: number
184
+ readonly cached: number
185
+ readonly hitRate: number
186
+ readonly subagents: number
187
+ readonly tools: readonly { readonly name: string; readonly count: number }[]
188
+ }
189
+
190
+ /** 面板数据。 */
191
+ export type PanelData = {
192
+ readonly goal: PanelGoal | null
193
+ readonly loops: readonly PanelLoop[]
194
+ readonly jobs: readonly PanelJob[]
195
+ readonly stats: PanelStats
196
+ }
197
+
198
+ /** TUI 侧对 ToolkitRpc 的类型化视图。 */
199
+ export type ToolkitRpcClient = {
200
+ settingsGet(input: Record<string, never>): Promise<Settings>
201
+ settingsSet(input: Settings): Promise<Settings>
202
+ panelGet(input: { readonly sessionID: string }): Promise<PanelData>
203
+ }
204
+
205
+ /**
206
+ * 输入:带 `rpc` 方法的 TUI client。
207
+ * 输出:类型化 RPC 客户端。
208
+ * 预期:只做类型断言,不改变运行时行为。
209
+ */
210
+ export function toolkitRpc(client: {
211
+ rpc(definition: Rpc.PortableDefinition): unknown
212
+ }): ToolkitRpcClient {
213
+ return client.rpc(ToolkitRpc) as ToolkitRpcClient
214
+ }
@@ -0,0 +1,21 @@
1
+ import type { ScannableStore } from "./json.ts"
2
+
3
+ /**
4
+ * 输入:具备 scan 能力的存储 + 前缀 + 每条目处理函数 + 每页条数。
5
+ * 输出:无。
6
+ * 预期:按 next 游标分页遍历全部匹配条目,逐条串行处理,避免一次读进内存。
7
+ */
8
+ export async function forEachScannedEntry(
9
+ store: ScannableStore,
10
+ prefix: string,
11
+ visit: (entry: { key: string; value: unknown }) => Promise<void>,
12
+ limit = 100,
13
+ ): Promise<void> {
14
+ let after: string | undefined
15
+ for (;;) {
16
+ const page = await store.scan({ prefix, limit, ...(after === undefined ? {} : { after }) })
17
+ for (const entry of page.entries) await visit(entry)
18
+ if (page.next === undefined) return
19
+ after = page.next
20
+ }
21
+ }
@@ -0,0 +1,28 @@
1
+ import type { JsonStore } from "./json.ts"
2
+ import { SETTINGS_KEY } from "./keys.ts"
3
+ import { normalizeSettings, type Settings } from "./settings.ts"
4
+
5
+ /**
6
+ * 输入:存储 + 插件 options(可缺省)。
7
+ * 输出:当前设置。
8
+ * 预期:首次运行时把 options 归一化成默认值写盘;之后以存储里的值为准,
9
+ * 存储损坏或字段缺失时按默认值补齐。
10
+ */
11
+ export async function loadSettings(store: JsonStore, options?: unknown): Promise<Settings> {
12
+ const stored = await store.get(SETTINGS_KEY)
13
+ if (stored === undefined) {
14
+ const initial = normalizeSettings(options)
15
+ await store.set(SETTINGS_KEY, initial)
16
+ return initial
17
+ }
18
+ return normalizeSettings(stored)
19
+ }
20
+
21
+ /**
22
+ * 输入:存储 + 完整设置。
23
+ * 输出:无。
24
+ * 预期:先归一化再写盘,保证存储里不会出现未知字段或错误类型。
25
+ */
26
+ export async function saveSettings(store: JsonStore, settings: Settings): Promise<void> {
27
+ await store.set(SETTINGS_KEY, normalizeSettings(settings))
28
+ }