@freely01/opencode-notify 0.1.0 → 0.2.0
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 +119 -31
- package/cli.ts +27 -15
- package/config.ts +298 -37
- package/delayed-dispatcher.ts +135 -0
- package/dispatcher.ts +11 -6
- package/events.ts +35 -43
- package/index.ts +115 -62
- package/log.ts +90 -0
- package/message.ts +39 -2
- package/package.json +1 -1
- package/senders/screen-flash/index.ts +27 -0
- package/senders/screen-flash/linux.ts +29 -0
- package/senders/system/darwin.ts +17 -0
- package/senders/system/index.ts +61 -0
- package/senders/system/linux.ts +16 -0
- package/senders/system/win32.ts +28 -0
- package/session-tracker.ts +133 -0
- package/store.ts +5 -4
- package/terminator-detect.ts +164 -0
- package/senders/screen-flash.ts +0 -42
- package/senders/system.ts +0 -88
package/events.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 但通过 event hook 收到时是 { type: string; properties: any }
|
|
3
3
|
// 这里直接使用 any 避免对 SDK 内部类型的依赖
|
|
4
4
|
import type { Message } from "./message.js"
|
|
5
|
-
import { formatTitle, defaultBody } from "./message.js"
|
|
5
|
+
import { formatTitle, formatBody, defaultBody } from "./message.js"
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 将 opencode event hook 收到的 Event 映射为内部通知 Message
|
|
@@ -15,6 +15,8 @@ import { formatTitle, defaultBody } from "./message.js"
|
|
|
15
15
|
* - "session.error" → 会话错误
|
|
16
16
|
* - "session.created" → 新会话
|
|
17
17
|
*
|
|
18
|
+
* @param event opencode 事件对象
|
|
19
|
+
* @param enabledEvents 启用的事件列表,用于过滤
|
|
18
20
|
* @returns Message | null — 不关心的事件返回 null
|
|
19
21
|
*/
|
|
20
22
|
export function route(
|
|
@@ -23,18 +25,33 @@ export function route(
|
|
|
23
25
|
): Message | null {
|
|
24
26
|
const enabled = new Set(enabledEvents ?? [])
|
|
25
27
|
const { type, properties } = event
|
|
28
|
+
const sessionID = properties.sessionID ?? "unknown"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 构造 Message 的辅助函数
|
|
32
|
+
* 统一处理 sessionID、title(带会话前缀)、body(结构化格式)
|
|
33
|
+
*/
|
|
34
|
+
function makeMsg(evt: string, detail: string): Message {
|
|
35
|
+
const msg: Message = {
|
|
36
|
+
agent: "opencode",
|
|
37
|
+
event: evt,
|
|
38
|
+
sessionID,
|
|
39
|
+
title: formatTitle(evt, sessionID),
|
|
40
|
+
body: detail,
|
|
41
|
+
}
|
|
42
|
+
// 将 body 格式化为结构化通知正文
|
|
43
|
+
msg.body = formatBody(msg)
|
|
44
|
+
return msg
|
|
45
|
+
}
|
|
26
46
|
|
|
27
47
|
// question.asked — 通用问题询问(权限/确认等均走此事件)
|
|
28
48
|
// properties 结构待实测确认
|
|
29
49
|
if (type === "question.asked" && enabled.has("permission_required")) {
|
|
30
50
|
const text = properties.text ?? properties.message ?? ""
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
title: formatTitle("permission_required"),
|
|
36
|
-
body: text ? `需要确认: ${truncate(text, 200)}` : defaultBody("permission_required"),
|
|
37
|
-
}
|
|
51
|
+
return makeMsg(
|
|
52
|
+
"permission_required",
|
|
53
|
+
text ? `需要确认: ${truncate(text, 200)}` : defaultBody("permission_required"),
|
|
54
|
+
)
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
// permission.asked — 工具权限请求
|
|
@@ -46,62 +63,37 @@ export function route(
|
|
|
46
63
|
: (properties.tool ?? "")
|
|
47
64
|
const permission = properties.permission ?? ""
|
|
48
65
|
const desc = [toolName, permission].filter(Boolean).join(" - ")
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
sessionID: properties.sessionID ?? "unknown",
|
|
53
|
-
title: formatTitle("permission_required"),
|
|
54
|
-
body: desc
|
|
66
|
+
return makeMsg(
|
|
67
|
+
"permission_required",
|
|
68
|
+
desc
|
|
55
69
|
? `操作「${desc}」需要您的授权许可`
|
|
56
70
|
: defaultBody("permission_required"),
|
|
57
|
-
|
|
71
|
+
)
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
// 会话错误 → run_failed
|
|
61
75
|
if (type === "session.error" && enabled.has("run_failed")) {
|
|
62
76
|
const err = properties.error
|
|
63
77
|
const errMsg = err?.message ?? err?.name ?? defaultBody("run_failed")
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
title: formatTitle("run_failed"),
|
|
69
|
-
body: `错误: ${truncate(String(errMsg), 200)}`,
|
|
70
|
-
}
|
|
78
|
+
return makeMsg(
|
|
79
|
+
"run_failed",
|
|
80
|
+
`错误: ${truncate(String(errMsg), 200)}`,
|
|
81
|
+
)
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
// 会话空闲 → input_required
|
|
74
85
|
if (type === "session.idle" && enabled.has("input_required")) {
|
|
75
|
-
return
|
|
76
|
-
agent: "opencode",
|
|
77
|
-
event: "input_required",
|
|
78
|
-
sessionID: properties.sessionID ?? "unknown",
|
|
79
|
-
title: formatTitle("input_required"),
|
|
80
|
-
body: defaultBody("input_required"),
|
|
81
|
-
}
|
|
86
|
+
return makeMsg("input_required", defaultBody("input_required"))
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
// session.status 也可能包含 idle 状态
|
|
85
90
|
if (type === "session.status" && enabled.has("input_required")) {
|
|
86
91
|
const status = properties.status
|
|
87
92
|
if (status?.type === "idle") {
|
|
88
|
-
return
|
|
89
|
-
agent: "opencode",
|
|
90
|
-
event: "input_required",
|
|
91
|
-
sessionID: properties.sessionID ?? "unknown",
|
|
92
|
-
title: formatTitle("input_required"),
|
|
93
|
-
body: defaultBody("input_required"),
|
|
94
|
-
}
|
|
93
|
+
return makeMsg("input_required", defaultBody("input_required"))
|
|
95
94
|
}
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
// 新会话创建 → 可跟踪任务开始
|
|
99
|
-
// 当前暂不直接通知,留作 run_completed 检测的基础
|
|
100
|
-
if (type === "session.created") {
|
|
101
|
-
// 预留: 可在这里记录会话开始时间,用于后续推断任务完成
|
|
102
|
-
return null
|
|
103
|
-
}
|
|
104
|
-
|
|
105
97
|
return null
|
|
106
98
|
}
|
|
107
99
|
|
package/index.ts
CHANGED
|
@@ -1,43 +1,21 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import type { PluginConfig } from "./config.js"
|
|
3
|
-
import { resolveConfig, loadYamlConfig, mergeConfig } from "./config.js"
|
|
3
|
+
import { resolveConfig, loadYamlConfig, mergeConfig, ensureConfigFile } from "./config.js"
|
|
4
4
|
import { route } from "./events.js"
|
|
5
5
|
import { Dispatcher } from "./dispatcher.js"
|
|
6
6
|
import { FileStore } from "./store.js"
|
|
7
|
-
import { SystemSender } from "./senders/system.js"
|
|
8
|
-
import { ScreenFlashSender } from "./senders/screen-flash.js"
|
|
7
|
+
import { SystemSender } from "./senders/system/index.js"
|
|
8
|
+
import { ScreenFlashSender } from "./senders/screen-flash/index.js"
|
|
9
9
|
import { CustomWebhookSender } from "./senders/custom-webhook.js"
|
|
10
10
|
import { WechatWorkSender } from "./senders/wechat-work.js"
|
|
11
11
|
import { FeishuSender } from "./senders/feishu.js"
|
|
12
12
|
import { FilteredSender } from "./senders/types.js"
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
13
|
+
import { SessionTracker } from "./session-tracker.js"
|
|
14
|
+
import { configureLog, error, warn, info, debug } from "./log.js"
|
|
15
|
+
import { DelayedDispatcher } from "./delayed-dispatcher.js"
|
|
16
|
+
import { isTerminalOccluded } from "./terminator-detect.js"
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
const MARQUEE_SCRIPT = join(__dirname, "scripts", "marquee.py")
|
|
20
|
-
|
|
21
|
-
const LOG_DIR = join(homedir(), ".opencode-notify")
|
|
22
|
-
const LOG_FILE = join(LOG_DIR, "plugin.log")
|
|
23
|
-
|
|
24
|
-
let debugEnabled = false
|
|
25
|
-
|
|
26
|
-
function enableDebug() {
|
|
27
|
-
debugEnabled = true
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function log(msg: string) {
|
|
31
|
-
if (!debugEnabled) return
|
|
32
|
-
try {
|
|
33
|
-
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
|
|
34
|
-
writeFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`, { flag: "a" })
|
|
35
|
-
} catch {
|
|
36
|
-
// 日志写入失败不影响主流程
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 用户活跃事件类型
|
|
18
|
+
// 用户活跃事件类型(这些事件表明用户正在操作 opencode 的某个会话)
|
|
41
19
|
const USER_ACTIVITY_EVENTS = new Set([
|
|
42
20
|
"message.updated",
|
|
43
21
|
"permission.replied",
|
|
@@ -46,21 +24,44 @@ const USER_ACTIVITY_EVENTS = new Set([
|
|
|
46
24
|
"tui.command.execute",
|
|
47
25
|
])
|
|
48
26
|
|
|
27
|
+
// 应追踪会话生命周期的事件(不产生通知,仅更新会话状态)
|
|
28
|
+
// 直接在内联 if 中判断,无需常量
|
|
29
|
+
|
|
30
|
+
|
|
49
31
|
const plugin: Plugin = async (_input, options) => {
|
|
32
|
+
// 确保配置文件存在(不存在则生成默认模板)
|
|
33
|
+
ensureConfigFile()
|
|
34
|
+
|
|
50
35
|
// 加载 YAML 配置 + 合并 plugin options
|
|
51
36
|
const yamlCfg = loadYamlConfig() ?? {}
|
|
52
37
|
const merged = mergeConfig(yamlCfg, options as PluginConfig ?? {})
|
|
53
38
|
const cfg = resolveConfig(merged)
|
|
54
|
-
|
|
39
|
+
configureLog(cfg.log?.level as any ?? "info", cfg.log?.file)
|
|
55
40
|
const store = new FileStore()
|
|
56
|
-
|
|
41
|
+
|
|
42
|
+
// 构建发送器
|
|
43
|
+
const { senders, senderMap } = buildSenders(cfg)
|
|
57
44
|
const dispatcher = new Dispatcher(store, cfg.dedupe_seconds ?? 60, senders)
|
|
58
|
-
// 用户活跃追踪
|
|
59
|
-
let lastActivity = Date.now()
|
|
60
|
-
const suppressActive = cfg.suppress_when_active ?? true
|
|
61
|
-
const activityTimeout = cfg.activity_timeout_ms ?? 30_000
|
|
62
45
|
|
|
63
|
-
|
|
46
|
+
// 远程延迟推送
|
|
47
|
+
const delayedChannels = cfg.remote_delay_channels ?? []
|
|
48
|
+
const delayedDispatcher = delayedChannels.length > 0
|
|
49
|
+
? new DelayedDispatcher(
|
|
50
|
+
(cfg.remote_delay_seconds ?? 60) * 1000,
|
|
51
|
+
cfg.remote_delay_max_count ?? 3,
|
|
52
|
+
delayedChannels,
|
|
53
|
+
senderMap,
|
|
54
|
+
)
|
|
55
|
+
: undefined
|
|
56
|
+
|
|
57
|
+
// 会话感知抑制
|
|
58
|
+
const tracker = new SessionTracker(cfg.session_stale_timeout_ms)
|
|
59
|
+
|
|
60
|
+
info(`插件已加载, log_level=${cfg.log?.level}, events=${JSON.stringify(cfg.events)}, `
|
|
61
|
+
+ `suppressActive=${cfg.suppress_when_active}, timeout=${cfg.activity_timeout_ms}ms, `
|
|
62
|
+
+ `suppressEvents=${JSON.stringify(cfg.suppress_events_when_active)}, `
|
|
63
|
+
+ `remote_channels=${JSON.stringify(delayedChannels)}, `
|
|
64
|
+
+ `terminator_detect=${!!process.env.TERMINATOR_UUID}`)
|
|
64
65
|
|
|
65
66
|
return {
|
|
66
67
|
// event 总线 — 所有事件通过此钩子
|
|
@@ -68,70 +69,122 @@ const plugin: Plugin = async (_input, options) => {
|
|
|
68
69
|
const { type, properties } = event as any
|
|
69
70
|
const propKeys = properties ? Object.keys(properties).join(",") : ""
|
|
70
71
|
|
|
71
|
-
//
|
|
72
|
-
|
|
72
|
+
// 调试日志:记录所有事件
|
|
73
|
+
debug(`[event] type=${type} keys=${propKeys}`)
|
|
74
|
+
|
|
75
|
+
const sessionID = properties?.sessionID ?? "unknown"
|
|
73
76
|
|
|
74
|
-
//
|
|
77
|
+
// === 更新会话追踪状态 ===
|
|
78
|
+
|
|
79
|
+
// 用户操作事件 → 标记该会话活跃 + 取消延迟通知
|
|
75
80
|
if (USER_ACTIVITY_EVENTS.has(type)) {
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
tracker.markActivity(sessionID)
|
|
82
|
+
delayedDispatcher?.cancelForSession(sessionID)
|
|
83
|
+
debug(`→ 用户活跃事件, 会话=${sessionID}`)
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
//
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
// 会话生命周期事件
|
|
87
|
+
if (type === "session.created") {
|
|
88
|
+
tracker.register(sessionID)
|
|
89
|
+
debug(`→ 会话已创建, 会话=${sessionID}`)
|
|
90
|
+
}
|
|
91
|
+
if (type === "session.deleted") {
|
|
92
|
+
tracker.remove(sessionID)
|
|
93
|
+
delayedDispatcher?.cancelForSession(sessionID)
|
|
94
|
+
debug(`→ 会话已删除, 会话=${sessionID}`)
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
// === 通知判定 ===
|
|
98
|
+
|
|
99
|
+
const suppressEvents = cfg.suppress_events_when_active ?? []
|
|
100
|
+
|
|
101
|
+
// 先路由事件,看是否匹配通知
|
|
89
102
|
const msg = route(event, cfg.events)
|
|
90
|
-
if (msg)
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if (!msg) return // 不关心的事件
|
|
104
|
+
|
|
105
|
+
debug(`→ 匹配通知: ${msg.event}`)
|
|
106
|
+
|
|
107
|
+
// 会话感知抑制判定
|
|
108
|
+
let shouldSuppress = cfg.suppress_when_active && suppressEvents.includes(msg.event)
|
|
109
|
+
&& tracker.isSessionActive(sessionID, cfg.activity_timeout_ms ?? 15000)
|
|
110
|
+
|
|
111
|
+
// Terminator 子屏遮挡覆盖:会话活跃但如果本屏被遮挡 → 强制通知
|
|
112
|
+
if (shouldSuppress) {
|
|
113
|
+
const occluded = isTerminalOccluded()
|
|
114
|
+
if (occluded === true) {
|
|
115
|
+
info(`→ 会话 ${sessionID} 活跃但 Terminator 子屏被遮挡,强制通知 (${msg.event})`)
|
|
116
|
+
shouldSuppress = false
|
|
117
|
+
} else if (occluded === false) {
|
|
118
|
+
debug(`→ Terminator 子屏未遮挡,正常抑制`)
|
|
119
|
+
}
|
|
120
|
+
// null = 不在 Terminator 或检测失败,不处理
|
|
93
121
|
}
|
|
122
|
+
|
|
123
|
+
if (shouldSuppress) {
|
|
124
|
+
info(`→ 会话 ${sessionID} 活跃中,跳过通知 (${msg.event})`)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 调度发送(正常立即通知)
|
|
129
|
+
await dispatcher.dispatch(msg)
|
|
130
|
+
|
|
131
|
+
// 正常通知已发出 → 调度远程延迟推送(如果启用)
|
|
132
|
+
delayedDispatcher?.schedule(msg)
|
|
94
133
|
},
|
|
95
134
|
}
|
|
96
135
|
}
|
|
97
136
|
|
|
137
|
+
/** buildSenders 返回值 */
|
|
138
|
+
interface BuildSendersResult {
|
|
139
|
+
senders: import("./senders/types.js").Sender[]
|
|
140
|
+
senderMap: Map<string, import("./senders/types.js").Sender>
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
function addSender(
|
|
99
144
|
senders: import("./senders/types.js").Sender[],
|
|
100
145
|
sender: import("./senders/types.js").Sender,
|
|
101
146
|
events: string[],
|
|
102
147
|
label: string,
|
|
103
148
|
extra?: string,
|
|
104
|
-
) {
|
|
105
|
-
|
|
149
|
+
): import("./senders/types.js").Sender {
|
|
150
|
+
const filtered = new FilteredSender(sender, events)
|
|
151
|
+
senders.push(filtered)
|
|
106
152
|
const evt = events.length < 6 ? `events=${JSON.stringify(events)}` : `events=${events.length}个`
|
|
107
|
-
|
|
153
|
+
info(`${label}已启用 (${evt})${extra ? `, ${extra}` : ""}`)
|
|
154
|
+
return filtered
|
|
108
155
|
}
|
|
109
156
|
|
|
110
|
-
function buildSenders(cfg: PluginConfig) {
|
|
157
|
+
function buildSenders(cfg: PluginConfig): BuildSendersResult {
|
|
111
158
|
const senders: import("./senders/types.js").Sender[] = []
|
|
159
|
+
const senderMap = new Map<string, import("./senders/types.js").Sender>()
|
|
112
160
|
const globalEvents = cfg.events ?? []
|
|
113
161
|
|
|
114
162
|
if (cfg.channels?.system_message?.enabled) {
|
|
115
163
|
const events = cfg.channels.system_message.events ?? globalEvents
|
|
116
|
-
addSender(senders, new SystemSender(), events, "系统通知")
|
|
164
|
+
const s = addSender(senders, new SystemSender(), events, "系统通知")
|
|
165
|
+
senderMap.set("system_message", s)
|
|
117
166
|
}
|
|
118
167
|
if (cfg.channels?.screen_flash?.enabled) {
|
|
119
168
|
const events = cfg.channels.screen_flash.events ?? globalEvents
|
|
120
|
-
addSender(senders, new ScreenFlashSender(cfg.channels.screen_flash), events, "屏幕跑马灯")
|
|
169
|
+
const s = addSender(senders, new ScreenFlashSender(cfg.channels.screen_flash), events, "屏幕跑马灯")
|
|
170
|
+
senderMap.set("screen_flash", s)
|
|
121
171
|
}
|
|
122
172
|
if (cfg.channels?.custom_webhook?.enabled && cfg.channels.custom_webhook.url) {
|
|
123
173
|
const events = cfg.channels.custom_webhook.events ?? globalEvents
|
|
124
|
-
addSender(senders, new CustomWebhookSender(cfg.channels.custom_webhook), events, "自定义 Webhook")
|
|
174
|
+
const s = addSender(senders, new CustomWebhookSender(cfg.channels.custom_webhook), events, "自定义 Webhook")
|
|
175
|
+
senderMap.set("custom_webhook", s)
|
|
125
176
|
}
|
|
126
177
|
if (cfg.channels?.wechat_work?.enabled && cfg.channels.wechat_work.webhook_url) {
|
|
127
178
|
const events = cfg.channels.wechat_work.events ?? globalEvents
|
|
128
|
-
addSender(senders, new WechatWorkSender(cfg.channels.wechat_work), events, "企业微信")
|
|
179
|
+
const s = addSender(senders, new WechatWorkSender(cfg.channels.wechat_work), events, "企业微信")
|
|
180
|
+
senderMap.set("wechat_work", s)
|
|
129
181
|
}
|
|
130
182
|
if (cfg.channels?.feishu?.enabled && cfg.channels.feishu.webhook_url) {
|
|
131
183
|
const events = cfg.channels.feishu.events ?? globalEvents
|
|
132
|
-
addSender(senders, new FeishuSender(cfg.channels.feishu), events, "飞书")
|
|
184
|
+
const s = addSender(senders, new FeishuSender(cfg.channels.feishu), events, "飞书")
|
|
185
|
+
senderMap.set("feishu", s)
|
|
133
186
|
}
|
|
134
|
-
return senders
|
|
187
|
+
return { senders, senderMap }
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
export default plugin
|
package/log.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join, dirname } from "node:path"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 日志等级(按优先级从高到低)
|
|
7
|
+
*/
|
|
8
|
+
export type LogLevel = "error" | "warn" | "info" | "debug"
|
|
9
|
+
|
|
10
|
+
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
11
|
+
error: 0,
|
|
12
|
+
warn: 1,
|
|
13
|
+
info: 2,
|
|
14
|
+
debug: 3,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LogState {
|
|
18
|
+
level: LogLevel
|
|
19
|
+
file: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const state: LogState = {
|
|
23
|
+
level: "info",
|
|
24
|
+
file: join(homedir(), ".opencode-notify", "plugin.log"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 配置日志系统
|
|
29
|
+
*
|
|
30
|
+
* @param level 日志等级(仅输出 >= 此等级的消息)
|
|
31
|
+
* @param file 日志文件路径(可选,不传保持当前值)
|
|
32
|
+
*/
|
|
33
|
+
export function configureLog(level: LogLevel, file?: string): void {
|
|
34
|
+
state.level = level
|
|
35
|
+
if (file) state.file = file
|
|
36
|
+
ensureDir()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 获取当前日志文件路径 */
|
|
40
|
+
export function getLogFile(): string {
|
|
41
|
+
return state.file
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 获取当前日志等级 */
|
|
45
|
+
export function getLogLevel(): LogLevel {
|
|
46
|
+
return state.level
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureDir(): void {
|
|
50
|
+
try {
|
|
51
|
+
const dir = dirname(state.file)
|
|
52
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
53
|
+
} catch {
|
|
54
|
+
// 创建目录失败不影响主流程
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeLog(level: LogLevel, msg: string): void {
|
|
59
|
+
if (LEVEL_PRIORITY[level] > LEVEL_PRIORITY[state.level]) return
|
|
60
|
+
try {
|
|
61
|
+
ensureDir()
|
|
62
|
+
writeFileSync(
|
|
63
|
+
state.file,
|
|
64
|
+
`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}\n`,
|
|
65
|
+
{ flag: "a" },
|
|
66
|
+
)
|
|
67
|
+
} catch {
|
|
68
|
+
// 日志写入失败不影响主流程
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 错误日志 — 系统无法正常运行或功能不可用 */
|
|
73
|
+
export function error(msg: string): void {
|
|
74
|
+
writeLog("error", msg)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 警告日志 — 潜在问题,但不影响核心功能 */
|
|
78
|
+
export function warn(msg: string): void {
|
|
79
|
+
writeLog("warn", msg)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 信息日志 — 正常运行状态变化 */
|
|
83
|
+
export function info(msg: string): void {
|
|
84
|
+
writeLog("info", msg)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 调试日志 — 详细事件流,仅排查问题时开启 */
|
|
88
|
+
export function debug(msg: string): void {
|
|
89
|
+
writeLog("debug", msg)
|
|
90
|
+
}
|
package/message.ts
CHANGED
|
@@ -23,12 +23,27 @@ const EVENT_LABELS: Record<string, string> = {
|
|
|
23
23
|
session_idle: "会话空闲",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 截短会话 ID 便于阅读
|
|
28
|
+
*/
|
|
29
|
+
function shortSession(sessionID: string): string {
|
|
30
|
+
if (!sessionID || sessionID === "unknown") return "未知"
|
|
31
|
+
return sessionID.length > 11
|
|
32
|
+
? sessionID.slice(0, 11) + "…"
|
|
33
|
+
: sessionID
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
/**
|
|
27
37
|
* 格式化通知标题
|
|
38
|
+
* @param event 事件类型
|
|
39
|
+
* @param sessionID 会话 ID(可选,传入后在标题前加会话标签)
|
|
28
40
|
*/
|
|
29
|
-
export function formatTitle(event: string): string {
|
|
41
|
+
export function formatTitle(event: string, sessionID?: string): string {
|
|
30
42
|
const label = EVENT_LABELS[event] ?? event
|
|
31
|
-
|
|
43
|
+
const prefix = sessionID && sessionID !== "unknown"
|
|
44
|
+
? `[${shortSession(sessionID)}] `
|
|
45
|
+
: ""
|
|
46
|
+
return `${prefix}opencode - ${label}`
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
/**
|
|
@@ -48,3 +63,25 @@ export function defaultBody(event: string): string {
|
|
|
48
63
|
return `事件: ${event}`
|
|
49
64
|
}
|
|
50
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 格式化结构化通知正文
|
|
69
|
+
*
|
|
70
|
+
* 输出格式:
|
|
71
|
+
* 事件:权限请求
|
|
72
|
+
* 会话:ses_abc1234
|
|
73
|
+
* 详情:Agent 需要授权
|
|
74
|
+
* 时间:2026-05-31 15:30:00
|
|
75
|
+
*/
|
|
76
|
+
export function formatBody(msg: Message): string {
|
|
77
|
+
const now = new Date()
|
|
78
|
+
const time = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
|
|
79
|
+
const eventLabel = EVENT_LABELS[msg.event] ?? msg.event
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
`事件:${eventLabel}`,
|
|
83
|
+
`会话:${shortSession(msg.sessionID)}`,
|
|
84
|
+
`详情:${msg.body}`,
|
|
85
|
+
`时间:${time}`,
|
|
86
|
+
].join("\n")
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 屏幕跑马灯发送器
|
|
3
|
+
*
|
|
4
|
+
* 通知时在屏幕四边产生彩色跑马灯效果。
|
|
5
|
+
* 当前仅 Linux X11 支持(Python + GTK 透明覆盖窗口)。
|
|
6
|
+
* 其他平台静默忽略。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Sender } from "../types.js"
|
|
10
|
+
import type { Message } from "../../message.js"
|
|
11
|
+
import type { ScreenFlashChannelConfig } from "../../config.js"
|
|
12
|
+
import { flash } from "./linux.js"
|
|
13
|
+
|
|
14
|
+
export class ScreenFlashSender implements Sender {
|
|
15
|
+
readonly name = "screen_flash"
|
|
16
|
+
private config: ScreenFlashChannelConfig
|
|
17
|
+
|
|
18
|
+
constructor(config: ScreenFlashChannelConfig) {
|
|
19
|
+
this.config = config
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async send(_msg: Message): Promise<void> {
|
|
23
|
+
// 仅 Linux 支持跑马灯效果
|
|
24
|
+
if (process.platform !== "linux") return
|
|
25
|
+
await flash(this.config)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux X11 屏幕跑马灯
|
|
3
|
+
*
|
|
4
|
+
* 在屏幕四边生成彩色高亮闪烁效果(跑马灯)。
|
|
5
|
+
* 使用 Python + PyGObject(GTK 3) 创建透明覆盖窗口。
|
|
6
|
+
* Ubuntu GNOME 桌面内置依赖,无需额外安装。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process"
|
|
10
|
+
import { join, dirname } from "node:path"
|
|
11
|
+
import { fileURLToPath } from "node:url"
|
|
12
|
+
import type { ScreenFlashChannelConfig } from "../../config.js"
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const MARQUEE_SCRIPT = join(__dirname, "..", "..", "scripts", "marquee.py")
|
|
16
|
+
|
|
17
|
+
export async function flash(config: ScreenFlashChannelConfig): Promise<void> {
|
|
18
|
+
const args = [
|
|
19
|
+
MARQUEE_SCRIPT,
|
|
20
|
+
String(config.duration ?? 3.0),
|
|
21
|
+
String(config.speed ?? 4.0),
|
|
22
|
+
String(config.intensity ?? 0.9),
|
|
23
|
+
]
|
|
24
|
+
const child = spawn("python3", args, {
|
|
25
|
+
stdio: "ignore",
|
|
26
|
+
detached: true,
|
|
27
|
+
})
|
|
28
|
+
child.unref()
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS 系统通知
|
|
3
|
+
*
|
|
4
|
+
* 使用 osascript 调用原生通知中心。
|
|
5
|
+
* - display notification: 弹窗 + 声音
|
|
6
|
+
* - 系统内置,无需额外安装
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process"
|
|
10
|
+
|
|
11
|
+
export async function notify(title: string, body: string): Promise<void> {
|
|
12
|
+
const script = `display notification "${body}" with title "${title}" sound name "default"`
|
|
13
|
+
execSync(`osascript -e ${JSON.stringify(script)}`, {
|
|
14
|
+
timeout: 5000,
|
|
15
|
+
stdio: "ignore",
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统通知发送器
|
|
3
|
+
*
|
|
4
|
+
* 根据运行平台自动选择实现:
|
|
5
|
+
* - macOS → darwin.ts (osascript)
|
|
6
|
+
* - Linux → linux.ts (notify-send)
|
|
7
|
+
* - Windows → win32.ts (PowerShell BurntToast / MessageBox)
|
|
8
|
+
* - 其他平台 → 静默忽略
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Sender } from "../types.js"
|
|
12
|
+
import type { Message } from "../../message.js"
|
|
13
|
+
import { notify as darwinNotify } from "./darwin.js"
|
|
14
|
+
import { notify as linuxNotify } from "./linux.js"
|
|
15
|
+
import { notify as win32Notify } from "./win32.js"
|
|
16
|
+
|
|
17
|
+
/** 平台通知函数注册表 */
|
|
18
|
+
const notifiers: Record<string, (title: string, body: string) => Promise<void>> = {
|
|
19
|
+
darwin: darwinNotify,
|
|
20
|
+
linux: linuxNotify,
|
|
21
|
+
win32: win32Notify,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class SystemSender implements Sender {
|
|
25
|
+
readonly name = "system_message"
|
|
26
|
+
|
|
27
|
+
async send(msg: Message): Promise<void> {
|
|
28
|
+
const fn = notifiers[process.platform]
|
|
29
|
+
if (!fn) return // 其他平台静默忽略
|
|
30
|
+
|
|
31
|
+
const { title, body } = sanitize(msg.title, msg.body)
|
|
32
|
+
try {
|
|
33
|
+
await fn(title, body)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(`系统通知失败: ${err}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 转义标题和正文中的特殊字符,防止 shell 注入
|
|
42
|
+
*/
|
|
43
|
+
function sanitize(
|
|
44
|
+
title: string,
|
|
45
|
+
body: string,
|
|
46
|
+
): { title: string; body: string } {
|
|
47
|
+
return {
|
|
48
|
+
title: escape(title),
|
|
49
|
+
body: escape(body),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escape(s: string): string {
|
|
54
|
+
return s
|
|
55
|
+
.replace(/"/g, '\\"')
|
|
56
|
+
.replace(/'/g, "\\'")
|
|
57
|
+
.replace(/`/g, "\\`")
|
|
58
|
+
.replace(/\$/g, "\\$")
|
|
59
|
+
.replace(/\n/g, " ")
|
|
60
|
+
.replace(/\r/g, "")
|
|
61
|
+
}
|