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,28 @@
1
+ import { en } from "./en.ts"
2
+ import type { Messages } from "./types.ts"
3
+ import { zh } from "./zh.ts"
4
+
5
+ export type { Messages }
6
+
7
+ /**
8
+ * 输入:locale 字符串(可为空)。
9
+ * 输出:对应语言的消息表。
10
+ * 预期:`zh*` 用中文,其余一律英文;空值回退到系统 locale。
11
+ */
12
+ export function resolveMessages(locale: string | undefined): Messages {
13
+ const value = (locale ?? detectLocale()).toLowerCase()
14
+ return value.startsWith("zh") ? zh : en
15
+ }
16
+
17
+ /**
18
+ * 输入:无。
19
+ * 输出:系统 locale。
20
+ * 预期:运行时拿不到时回退 `en`,保证始终有文案。
21
+ */
22
+ export function detectLocale(): string {
23
+ try {
24
+ return Intl.DateTimeFormat().resolvedOptions().locale
25
+ } catch {
26
+ return "en"
27
+ }
28
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 界面文案。
3
+ *
4
+ * 源码里只引用键名,所有用户可见文字都来自语言文件。
5
+ */
6
+ export type Messages = {
7
+ readonly command: {
8
+ readonly title: string
9
+ readonly description: string
10
+ readonly group: string
11
+ }
12
+ readonly menu: {
13
+ readonly title: string
14
+ readonly enabled: string
15
+ readonly language: string
16
+ readonly notifications: string
17
+ readonly goal: string
18
+ readonly scheduling: string
19
+ readonly sidebar: string
20
+ readonly on: string
21
+ readonly off: string
22
+ readonly auto: string
23
+ readonly chinese: string
24
+ readonly english: string
25
+ readonly showCache: string
26
+ readonly showTools: string
27
+ readonly showSubagents: string
28
+ readonly collapsed: string
29
+ }
30
+ readonly panel: {
31
+ readonly title: string
32
+ readonly goal: string
33
+ readonly noGoal: string
34
+ readonly loops: string
35
+ readonly jobs: string
36
+ readonly cache: string
37
+ readonly tools: string
38
+ readonly subagents: string
39
+ readonly status: Readonly<Record<string, string>>
40
+ }
41
+ readonly sidebar: {
42
+ readonly title: string
43
+ readonly on: string
44
+ readonly off: string
45
+ }
46
+ readonly notification: {
47
+ readonly title: string
48
+ readonly completed: string
49
+ readonly question: string
50
+ readonly permission: string
51
+ readonly user: string
52
+ readonly assistant: string
53
+ }
54
+ readonly tool: {
55
+ readonly pingResult: (enabled: boolean, lang: string) => string
56
+ }
57
+ }
package/src/i18n/zh.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Messages } from "./types.ts"
2
+
3
+ /** 中文文案。 */
4
+ export const zh: Messages = {
5
+ command: {
6
+ title: "工具包设置",
7
+ description: "打开 my-opencode-toolkit 设置",
8
+ group: "插件",
9
+ },
10
+ menu: {
11
+ title: "my-opencode-toolkit",
12
+ enabled: "启用",
13
+ language: "语言",
14
+ notifications: "通知",
15
+ goal: "目标",
16
+ scheduling: "定时任务",
17
+ sidebar: "侧栏",
18
+ on: "开",
19
+ off: "关",
20
+ auto: "自动",
21
+ chinese: "中文",
22
+ english: "English",
23
+ showCache: "缓存",
24
+ showTools: "工具",
25
+ showSubagents: "子代理",
26
+ collapsed: "折叠",
27
+ },
28
+ panel: {
29
+ title: "工具包",
30
+ goal: "目标",
31
+ noGoal: "无",
32
+ loops: "循环",
33
+ jobs: "定时",
34
+ cache: "缓存",
35
+ tools: "工具",
36
+ subagents: "子代理",
37
+ status: {
38
+ active: "进行中",
39
+ paused: "已暂停",
40
+ blocked: "已阻塞",
41
+ budget_limited: "预算用尽",
42
+ complete: "已完成",
43
+ },
44
+ },
45
+ sidebar: {
46
+ title: "工具包",
47
+ on: "已启用",
48
+ off: "已禁用",
49
+ },
50
+ notification: {
51
+ title: "OpenCode",
52
+ completed: "任务完成",
53
+ question: "代理正在提问",
54
+ permission: "需要权限确认",
55
+ user: "用户",
56
+ assistant: "助手",
57
+ },
58
+ tool: {
59
+ pingResult: (enabled, lang) => `my-opencode-toolkit:启用=${enabled ? "是" : "否"},语言=${lang}`,
60
+ },
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ import { hideGoalTools } from "./goal/filter.ts"
4
+ import { registerGoal } from "./goal/index.ts"
5
+ import { createGoalController } from "./goal/controller.ts"
6
+ import { createSessionRoles } from "./goal/session-role.ts"
7
+ import { createNotifier } from "./notification/index.ts"
8
+ import { hideSchedulingTools } from "./scheduling/filter.ts"
9
+ import { registerScheduling } from "./scheduling/index.ts"
10
+ import { dispatchEvent, type EventHandler } from "./shared/events.ts"
11
+ import { asRecord } from "./shared/json.ts"
12
+ import { ToolkitRpc } from "./shared/rpc.ts"
13
+ import { loadSettings, saveSettings } from "./shared/settings-store.ts"
14
+ import { normalizeSettings } from "./shared/settings.ts"
15
+ import { buildStats, createStatsTracker, hitRatePercent, trackEvent } from "./stats.ts"
16
+ import { registerPingTool } from "./tools/ping.ts"
17
+
18
+ export default Plugin.define({
19
+ id: "my-opencode-toolkit",
20
+
21
+ /**
22
+ * 输入:OpenCode 注入的插件上下文。
23
+ * 输出:设置/面板 RPC、`toolkit_ping`、Goal、定时任务、通知,以及卸载回调。
24
+ * 预期:首次启动把 options 归一化后写入存储;之后以存储里的设置为准。
25
+ */
26
+ async setup(ctx) {
27
+ await loadSettings(ctx.storage, ctx.options)
28
+
29
+ const roles = createSessionRoles(ctx)
30
+ const goalController = createGoalController(ctx.storage)
31
+ const tracker = createStatsTracker()
32
+
33
+ const tool = await registerPingTool(ctx)
34
+ const goal = await registerGoal(ctx, ctx.storage, roles, goalController)
35
+ const scheduling = await registerScheduling(ctx, ctx.storage, roles, async (sessionID) => {
36
+ const current = await goalController.getGoal(sessionID)
37
+ return current?.status === "active"
38
+ })
39
+
40
+ const rpc = await ctx.rpc.register(ToolkitRpc, {
41
+ settingsGet: async () => loadSettings(ctx.storage, ctx.options),
42
+ settingsSet: async (input) => {
43
+ // RPC 定义是 PortableDefinition,运行时才知道 input 形状;这里再归一化一次兜底。
44
+ await saveSettings(ctx.storage, normalizeSettings(input))
45
+ return loadSettings(ctx.storage, ctx.options)
46
+ },
47
+ panelGet: async (input) => {
48
+ const record = asRecord(input)
49
+ const requested = record?.["sessionID"]
50
+ const sessionID = typeof requested === "string" ? requested : ""
51
+
52
+ // 统计不落盘:每个会话第一次打开面板时,从 OpenCode 已存消息重建一次。
53
+ if (sessionID.length > 0 && !tracker.isSeeded(sessionID)) {
54
+ tracker.seed(sessionID, await buildStats(ctx, sessionID))
55
+ }
56
+
57
+ const current = await goalController.getGoal(sessionID)
58
+ const stats = tracker.get(sessionID)
59
+ const tools = Object.entries(stats.tools)
60
+ .map(([name, count]) => ({ name, count }))
61
+ .sort((left, right) => right.count - left.count)
62
+
63
+ return {
64
+ goal:
65
+ current === null
66
+ ? null
67
+ : {
68
+ objective: current.objective,
69
+ status: current.status,
70
+ tokensUsed: current.tokensUsed,
71
+ timeUsedSeconds: current.timeUsedSeconds,
72
+ ...(current.tokenBudget === undefined ? {} : { tokenBudget: current.tokenBudget }),
73
+ },
74
+ loops: scheduling.scheduler.listLoops(sessionID).map((loop) => ({
75
+ id: loop.id,
76
+ mode: loop.mode,
77
+ prompt: loop.prompt,
78
+ ...(loop.nextFireAt === undefined ? {} : { nextFireAt: loop.nextFireAt }),
79
+ })),
80
+ jobs: scheduling.jobs.listJobs(sessionID).map((job) => ({
81
+ id: job.id,
82
+ cron: job.cron,
83
+ prompt: job.prompt,
84
+ ...(job.nextRunAt === undefined ? {} : { nextRunAt: job.nextRunAt }),
85
+ })),
86
+ stats: { ...stats, hitRate: hitRatePercent(stats.input, stats.cached), tools },
87
+ }
88
+ },
89
+ })
90
+
91
+ // 功能关闭或子代理会话时,请求发给模型前就把对应工具从工具表移除。
92
+ const hook = await ctx.session.hook("context", async (event) => {
93
+ const settings = await loadSettings(ctx.storage)
94
+ if (!settings.enabled) {
95
+ hideGoalTools(event.tools)
96
+ hideSchedulingTools(event.tools)
97
+ return
98
+ }
99
+ const subagent = await roles.isSubagent(event.sessionID)
100
+ if (subagent || !settings.goal.enabled) hideGoalTools(event.tools)
101
+ if (subagent || !settings.scheduling.enabled) hideSchedulingTools(event.tools)
102
+ })
103
+
104
+ const notifier = createNotifier({ ctx, store: ctx.storage, roles })
105
+
106
+ const toolBefore = await ctx.tool.hook("execute.before", async (input) => {
107
+ await notifier.handleToolBefore({ tool: input.tool, sessionID: input.sessionID })
108
+ })
109
+ const toolAfter = await ctx.tool.hook("execute.after", (input) => {
110
+ tracker.recordTool(input.sessionID, input.tool)
111
+ })
112
+
113
+ const handlers: EventHandler[] = [
114
+ (event) => {
115
+ trackEvent(tracker, event)
116
+ return Promise.resolve()
117
+ },
118
+ (event) => notifier.handle(event),
119
+ (event) => goal.handle(event),
120
+ (event) => scheduling.handle(event),
121
+ ]
122
+
123
+ const events = new AbortController()
124
+ void (async () => {
125
+ try {
126
+ for await (const event of ctx.event.subscribe({ signal: events.signal })) {
127
+ await dispatchEvent(handlers, event)
128
+ }
129
+ } catch {
130
+ // aborted 或连接断开:卸载时正常路径
131
+ }
132
+ })()
133
+
134
+ // 工具注册后刷新一次工具表,让当前会话的下一次请求就能看到新工具。
135
+ await ctx.tool.reload()
136
+
137
+ return async () => {
138
+ events.abort()
139
+ notifier.dispose()
140
+ tracker.clear()
141
+ await toolAfter.dispose()
142
+ await toolBefore.dispose()
143
+ await hook.dispose()
144
+ await scheduling.dispose()
145
+ await goal.dispose()
146
+ await tool.dispose()
147
+ await rpc.dispose()
148
+ }
149
+ },
150
+ })
@@ -0,0 +1,42 @@
1
+ /** 输入:文本;输出:PowerShell 单引号字符串里可安全使用的文本。 */
2
+ export function escapePowerShellSingleQuotedText(input: string): string {
3
+ return input.replaceAll("'", "''")
4
+ }
5
+
6
+ /** 输入:文本;输出:AppleScript 双引号字符串里可安全使用的文本。 */
7
+ export function escapeAppleScriptText(input: string): string {
8
+ return input.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
9
+ }
10
+
11
+ /** 输入:文本;输出:XML 转义后的文本。 */
12
+ export function escapeXmlText(input: string): string {
13
+ return input
14
+ .replaceAll("&", "&amp;")
15
+ .replaceAll("<", "&lt;")
16
+ .replaceAll(">", "&gt;")
17
+ .replaceAll('"', "&quot;")
18
+ .replaceAll("'", "&apos;")
19
+ }
20
+
21
+ /**
22
+ * 输入:标题 + 正文。
23
+ * 输出:一行 PowerShell 脚本,弹出 Windows toast。
24
+ * 预期:先 XML 转义(给 toast 模板),再单引号转义(给 -Command 字符串)。
25
+ */
26
+ export function buildWindowsToastScript(title: string, message: string): string {
27
+ const psTitle = escapePowerShellSingleQuotedText(escapeXmlText(title))
28
+ const psMessage = escapePowerShellSingleQuotedText(escapeXmlText(message))
29
+
30
+ return `
31
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
32
+ $Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
33
+ $RawXml = [xml] $Template.GetXml()
34
+ ($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null
35
+ ($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null
36
+ $SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument
37
+ $SerializedXml.LoadXml($RawXml.OuterXml)
38
+ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
39
+ $Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
40
+ $Notifier.Show($Toast)
41
+ `.trim().replaceAll("\n", "; ")
42
+ }
@@ -0,0 +1,165 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import type { SessionRoles } from "../goal/session-role.ts"
4
+ import { resolveMessages, type Messages } from "../i18n/index.ts"
5
+ import { readEventSessionID } from "../shared/events.ts"
6
+ import type { JsonStore } from "../shared/json.ts"
7
+ import { loadSettings } from "../shared/settings-store.ts"
8
+ import type { Settings } from "../shared/settings.ts"
9
+ import { createSender, type NotificationSender } from "./send.ts"
10
+
11
+ /** 通知类型。 */
12
+ type NotificationKind = "completed" | "question" | "permission"
13
+
14
+ /** 同一会话完成通知的去重窗口(idle 与 execution.succeeded 会同时到达)。 */
15
+ const COMPLETE_DEDUPE_MS = 1000
16
+
17
+ /** 预览文本上限。 */
18
+ const PREVIEW_MAX = 200
19
+
20
+ /** 通知控制器。 */
21
+ export type Notifier = {
22
+ /** 输入:会话事件;输出:无。预期:完成/权限事件按设置发系统通知。 */
23
+ handle(event: { readonly type: string; readonly data: unknown }): Promise<void>
24
+ /** 输入:工具名 + 会话;输出:无。预期:`question` 工具触发提问通知。 */
25
+ handleToolBefore(input: { readonly tool: string; readonly sessionID: string }): Promise<void>
26
+ /** 输入:无;输出:无。预期:清理去重缓存。 */
27
+ dispose(): void
28
+ }
29
+
30
+ /** 输入:文本;输出:空白折叠成单行。 */
31
+ function collapse(text: string): string {
32
+ return text
33
+ .split(/\r?\n/)
34
+ .map((line) => line.trim())
35
+ .filter(Boolean)
36
+ .join(" ")
37
+ }
38
+
39
+ /** 输入:文本 + 上限;输出:截断后的文本。 */
40
+ function truncate(text: string, max: number): string {
41
+ return text.length > max ? `${text.slice(0, max)}...` : text
42
+ }
43
+
44
+ /**
45
+ * 输入:插件上下文、存储、会话角色、可选发送函数与平台。
46
+ * 输出:通知控制器。
47
+ * 预期:发送失败静默;子代理在 onlyMainSession 下不通知;完成通知带消息预览。
48
+ */
49
+ export function createNotifier(args: {
50
+ readonly ctx: Plugin.Context
51
+ readonly store: JsonStore
52
+ readonly roles: SessionRoles
53
+ readonly send?: NotificationSender
54
+ readonly platform?: NodeJS.Platform
55
+ readonly now?: () => number
56
+ }): Notifier {
57
+ const { ctx, store, roles } = args
58
+ const send = args.send ?? createSender(args.platform ?? process.platform)
59
+ const clock = args.now ?? Date.now
60
+ const lastCompleteAt = new Map<string, number>()
61
+
62
+ /** 输入:设置里的语言;输出:`resolveMessages` 需要的 locale。 */
63
+ const localeOf = (settings: Settings): string | undefined =>
64
+ settings.lang === "auto" ? undefined : settings.lang
65
+
66
+ /**
67
+ * 输入:会话 ID + 文案。
68
+ * 输出:`User: ...` / `Assistant: ...` 两行(可能为空)。
69
+ * 预期:取最后一条用户文本与最后一条助手文本的最后一行;读取失败返回空。
70
+ */
71
+ const buildPreview = async (sessionID: string, messages: Messages): Promise<string[]> => {
72
+ const history = await ctx.session.context({ sessionID }).catch(() => undefined)
73
+ if (history === undefined) return []
74
+
75
+ let userText = ""
76
+ let assistantText = ""
77
+ for (let index = history.length - 1; index >= 0; index -= 1) {
78
+ const message = history[index]
79
+ if (message === undefined) continue
80
+ if (userText === "" && message.type === "user") userText = collapse(message.text)
81
+ if (assistantText === "" && message.type === "assistant") {
82
+ const text = message.content
83
+ .filter((part) => part.type === "text")
84
+ .map((part) => part.text)
85
+ .join("\n")
86
+ const lines = collapse(text)
87
+ assistantText = lines
88
+ }
89
+ if (userText !== "" && assistantText !== "") break
90
+ }
91
+
92
+ return [
93
+ userText === "" ? "" : `${messages.notification.user}: ${truncate(userText, PREVIEW_MAX)}`,
94
+ assistantText === "" ? "" : `${messages.notification.assistant}: ${truncate(assistantText, PREVIEW_MAX)}`,
95
+ ].filter(Boolean)
96
+ }
97
+
98
+ /** 输入:会话 + 设置 + 通知类型;输出:无。预期:标题带会话名,发送失败静默。 */
99
+ const notify = async (sessionID: string, settings: Settings, kind: NotificationKind): Promise<void> => {
100
+ const messages = resolveMessages(localeOf(settings))
101
+ const session = await ctx.session.get({ sessionID }).catch(() => undefined)
102
+ const name = session?.title?.trim() ?? ""
103
+ const title = name.length > 0 ? `${messages.notification.title} · ${name}` : messages.notification.title
104
+ const detail = kind === "completed" ? await buildPreview(sessionID, messages) : []
105
+ const body = [messages.notification[kind], ...detail].join("\n")
106
+
107
+ try {
108
+ await send(title, body)
109
+ } catch {
110
+ // 通知是尽力而为,失败不影响会话
111
+ }
112
+ }
113
+
114
+ /** 输入:会话 ID + 设置;输出:是否应通知。预期:onlyMainSession 时跳过子代理。 */
115
+ const allowed = async (sessionID: string, settings: Settings): Promise<boolean> => {
116
+ if (!settings.notification.onlyMainSession) return true
117
+ return !(await roles.isSubagent(sessionID))
118
+ }
119
+
120
+ return {
121
+ async handle(event) {
122
+ const sessionID = readEventSessionID(event)
123
+ if (sessionID === undefined) return
124
+
125
+ const settings = await loadSettings(store)
126
+ if (!settings.enabled || !settings.notification.enabled) return
127
+
128
+ if (event.type === "session.deleted") {
129
+ lastCompleteAt.delete(sessionID)
130
+ return
131
+ }
132
+
133
+ if (!(await allowed(sessionID, settings))) return
134
+
135
+ // idle 与 execution.succeeded 是同一轮结束的两个信号,合并成一次通知。
136
+ if (event.type === "session.idle" || event.type === "session.execution.succeeded") {
137
+ if (!settings.notification.onComplete) return
138
+ const at = clock()
139
+ const last = lastCompleteAt.get(sessionID)
140
+ if (last !== undefined && at - last < COMPLETE_DEDUPE_MS) return
141
+ lastCompleteAt.set(sessionID, at)
142
+ await notify(sessionID, settings, "completed")
143
+ return
144
+ }
145
+
146
+ if (event.type === "permission.asked") {
147
+ if (!settings.notification.onPermission) return
148
+ await notify(sessionID, settings, "permission")
149
+ }
150
+ },
151
+
152
+ async handleToolBefore(input) {
153
+ if (input.tool.toLowerCase() !== "question") return
154
+
155
+ const settings = await loadSettings(store)
156
+ if (!settings.enabled || !settings.notification.enabled || !settings.notification.onQuestion) return
157
+ if (!(await allowed(input.sessionID, settings))) return
158
+ await notify(input.sessionID, settings, "question")
159
+ },
160
+
161
+ dispose() {
162
+ lastCompleteAt.clear()
163
+ },
164
+ }
165
+ }
@@ -0,0 +1,104 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+
4
+ import { buildWindowsToastScript, escapeAppleScriptText } from "./format.ts"
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ const POWERSHELL = "powershell.exe"
9
+ const OSASCRIPT = "osascript"
10
+ const NOTIFY_SEND = "notify-send"
11
+
12
+ /** Windows toast 对长度敏感,统一截断。 */
13
+ const TITLE_MAX = 100
14
+ const MESSAGE_MAX = 800
15
+
16
+ /** 发送函数。 */
17
+ export type NotificationSender = (title: string, message: string) => Promise<void>
18
+
19
+ /** 输入:文本 + 上限;输出:截断后的文本。 */
20
+ function truncate(text: string, max: number): string {
21
+ return text.length > max ? `${text.slice(0, max)}...` : text
22
+ }
23
+
24
+ /**
25
+ * 输入:命令名 + 该命令能快速成功退出的探测参数。
26
+ * 输出:可用时返回命令名,否则 `null`。
27
+ * 预期:结果缓存;探测超时 3 秒,不阻塞通知链路。
28
+ * 探测参数必须按命令区分:把 PowerShell 的 `-Command` 传给 osascript/notify-send 会必然失败,
29
+ * 导致这两个平台的命令被误判为不存在。
30
+ */
31
+ function createCommandFinder(commandName: string, probeArgs: readonly string[]): () => Promise<string | null> {
32
+ let cached: string | null | undefined
33
+ let pending: Promise<string | null> | undefined
34
+
35
+ return async () => {
36
+ if (cached !== undefined) return cached
37
+ if (pending !== undefined) return pending
38
+
39
+ pending = (async () => {
40
+ try {
41
+ await execFileAsync(commandName, [...probeArgs], {
42
+ timeout: 3_000,
43
+ windowsHide: true,
44
+ })
45
+ cached = commandName
46
+ } catch {
47
+ cached = null
48
+ }
49
+ return cached
50
+ })()
51
+ return pending
52
+ }
53
+ }
54
+
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])
70
+ }
71
+
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}"`])
79
+ }
80
+
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)])
86
+ }
87
+
88
+ /**
89
+ * 输入:Node 平台字符串。
90
+ * 输出:该平台的发送函数。
91
+ * 预期:不支持的平台返回空实现,绝不抛错。
92
+ */
93
+ export function createSender(platform: NodeJS.Platform): NotificationSender {
94
+ switch (platform) {
95
+ case "win32":
96
+ return sendWindows
97
+ case "darwin":
98
+ return sendMacos
99
+ case "linux":
100
+ return sendLinux
101
+ default:
102
+ return () => Promise.resolve()
103
+ }
104
+ }