@freely01/opencode-notify 0.1.1 → 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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Linux 系统通知
3
+ *
4
+ * 使用 notify-send(来自 libnotify)。
5
+ * 桌面发行版通常预装,如缺失可:
6
+ * apt install libnotify-bin / yum install libnotify
7
+ */
8
+
9
+ import { execSync } from "node:child_process"
10
+
11
+ export async function notify(title: string, body: string): Promise<void> {
12
+ execSync(`notify-send "${title}" "${body}"`, {
13
+ timeout: 5000,
14
+ stdio: "ignore",
15
+ })
16
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Windows 系统通知
3
+ *
4
+ * 优先使用 BurntToast(需额外安装),失败时回退为 MessageBox。
5
+ * 安装 BurntToast:
6
+ * Install-Module -Name BurntToast -Force
7
+ *
8
+ * PowerShell script 使用单引号包裹字符串以避免扩展问题。
9
+ */
10
+
11
+ import { execSync } from "node:child_process"
12
+
13
+ export async function notify(title: string, body: string): Promise<void> {
14
+ // PowerShell 脚本:优先 BurntToast,失败回退 MessageBox
15
+ const psScript = [
16
+ `try {`,
17
+ ` New-BurntToastNotification -Text '${title}', '${body}' -ErrorAction Stop`,
18
+ `} catch {`,
19
+ ` Add-Type -AssemblyName System.Windows.Forms`,
20
+ ` [System.Windows.Forms.MessageBox]::Show('${body}', '${title}')`,
21
+ `}`,
22
+ ].join("\n")
23
+
24
+ execSync(`powershell -NoProfile -Command ${JSON.stringify(psScript)}`, {
25
+ timeout: 10000,
26
+ stdio: "ignore",
27
+ })
28
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 会话状态追踪器
3
+ *
4
+ * 跟踪 opencode 每个会话的用户活跃状态,
5
+ * 用于会话感知的通知抑制(用户在操作某会话时跳过部分通知)。
6
+ *
7
+ * 状态更新事件:
8
+ * message.updated / permission.replied / question.replied
9
+ * command.executed / tui.command.execute
10
+ *
11
+ * 生命周期事件:
12
+ * session.created / session.deleted
13
+ */
14
+
15
+ import { warn } from "./log.js"
16
+
17
+ export interface SessionInfo {
18
+ sessionID: string
19
+ /** 用户最后操作时间戳 */
20
+ lastActivity: number
21
+ /** 会话创建时间 */
22
+ createdAt: number
23
+ }
24
+
25
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 分钟
26
+
27
+ export class SessionTracker {
28
+ private sessions = new Map<string, SessionInfo>()
29
+ private staleTimeoutMs: number
30
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null
31
+
32
+ /**
33
+ * @param staleTimeoutMs 会话无操作自动淘汰阈值(默认 10 分钟)
34
+ */
35
+ constructor(staleTimeoutMs = 600_000) {
36
+ this.staleTimeoutMs = staleTimeoutMs
37
+ this.startCleanupTimer()
38
+ }
39
+
40
+ // ============ 对外接口 ============
41
+
42
+ /**
43
+ * 检查某会话在指定超时窗口内是否有用户活动
44
+ * @returns true = 用户正在操作,应抑制可抑制事件
45
+ */
46
+ isSessionActive(sessionID: string, activityTimeoutMs: number): boolean {
47
+ if (sessionID === "unknown") return false
48
+ const info = this.sessions.get(sessionID)
49
+ if (!info) return false
50
+ return Date.now() - info.lastActivity < activityTimeoutMs
51
+ }
52
+
53
+ // ============ 事件更新 ============
54
+
55
+ /** 标记用户在该会话中有操作 */
56
+ markActivity(sessionID: string): void {
57
+ if (sessionID === "unknown") return
58
+ this.lazyCleanup()
59
+ const existing = this.sessions.get(sessionID)
60
+ if (existing) {
61
+ existing.lastActivity = Date.now()
62
+ } else {
63
+ this.sessions.set(sessionID, {
64
+ sessionID,
65
+ lastActivity: Date.now(),
66
+ createdAt: Date.now(),
67
+ })
68
+ }
69
+ }
70
+
71
+ /** 注册新会话 */
72
+ register(sessionID: string): void {
73
+ if (sessionID === "unknown") return
74
+ if (!this.sessions.has(sessionID)) {
75
+ this.sessions.set(sessionID, {
76
+ sessionID,
77
+ lastActivity: Date.now(),
78
+ createdAt: Date.now(),
79
+ })
80
+ }
81
+ }
82
+
83
+ /** 移除会话 */
84
+ remove(sessionID: string): void {
85
+ this.sessions.delete(sessionID)
86
+ }
87
+
88
+ // ============ 内部 ============
89
+
90
+ /** 惰性清除 — 每次操作时顺带清理少数过期条目 */
91
+ private lazyCleanup(): void {
92
+ const cutoff = Date.now() - this.staleTimeoutMs
93
+ // 每次随机检查最多 8 条(避免大 Map 遍历性能开销)
94
+ let checked = 0
95
+ for (const [id, info] of this.sessions) {
96
+ if (checked >= 8) break
97
+ if (info.lastActivity < cutoff) this.sessions.delete(id)
98
+ checked++
99
+ }
100
+ }
101
+
102
+ /** 全量清理 */
103
+ private fullCleanup(): void {
104
+ const cutoff = Date.now() - this.staleTimeoutMs
105
+ for (const [id, info] of this.sessions) {
106
+ if (info.lastActivity < cutoff) this.sessions.delete(id)
107
+ }
108
+ }
109
+
110
+ /** 启动定时清理 */
111
+ private startCleanupTimer(): void {
112
+ this.cleanupTimer = setInterval(() => this.fullCleanup(), CLEANUP_INTERVAL_MS)
113
+ this.cleanupTimer.unref()
114
+ }
115
+
116
+ /** 销毁(进程退出时调用) */
117
+ destroy(): void {
118
+ if (this.cleanupTimer) {
119
+ clearInterval(this.cleanupTimer)
120
+ this.cleanupTimer = null
121
+ }
122
+ const count = this.sessions.size
123
+ this.sessions.clear()
124
+ if (count > 0) {
125
+ warn(`会话追踪器销毁,清理 ${count} 个未过期会话`)
126
+ }
127
+ }
128
+
129
+ /** 当前追踪的会话数(用于调试) */
130
+ get size(): number {
131
+ return this.sessions.size
132
+ }
133
+ }
package/store.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
10
10
  import { homedir } from "node:os"
11
11
  import { join } from "node:path"
12
+ import { warn, error } from "./log.js"
12
13
 
13
14
  interface StoreData {
14
15
  lastSent: Record<string, number> // key → unix timestamp (seconds)
@@ -90,8 +91,8 @@ export class FileStore {
90
91
  const raw = readFileSync(this.path, "utf-8")
91
92
  const data = JSON.parse(raw) as StoreData
92
93
  this.lastSent = data.lastSent ?? {}
93
- } catch {
94
- // 文件损坏时使用空状态
94
+ } catch (e) {
95
+ warn(`去重状态文件读取失败,使用空状态: ${e instanceof Error ? e.message : String(e)}`)
95
96
  this.lastSent = {}
96
97
  }
97
98
  }
@@ -104,8 +105,8 @@ export class FileStore {
104
105
  }
105
106
  const data: StoreData = { lastSent: this.lastSent }
106
107
  writeFileSync(this.path, JSON.stringify(data, null, 2), "utf-8")
107
- } catch {
108
- // 持久化失败不应影响主流程
108
+ } catch (e) {
109
+ error(`去重状态持久化失败: ${e instanceof Error ? e.message : String(e)}`)
109
110
  }
110
111
  }
111
112
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Terminator 子屏幕最大化检测
3
+ *
4
+ * 当用户在 Terminator 中最大化某个子屏幕时(Ctrl+Shift+X),
5
+ * 其他子屏幕被遮挡。通过 DBus 查询焦点终端 UUID,
6
+ * 与本进程的 TERMINATOR_UUID 比较,判定本屏是否被遮挡。
7
+ *
8
+ * 检测策略(依次尝试):
9
+ * 1. busctl(systemd 自带,Ubuntu 预装)
10
+ * 2. python3-dbus(备选)
11
+ * 3. gdbus(GLib 备选)
12
+ * 4. xdotool 窗口类检测(回退,仅判断是否在 Terminator 窗口中)
13
+ */
14
+
15
+ import { execSync } from "node:child_process"
16
+ import { warn, debug } from "./log.js"
17
+
18
+ /** 当前进程的 TERMINATOR_UUID */
19
+ const MY_UUID = process.env.TERMINATOR_UUID ?? null
20
+
21
+ /** Terminator DBus 信息(每实例唯一) */
22
+ const DBUS_NAME = process.env.TERMINATOR_DBUS_NAME ?? null
23
+ const DBUS_PATH = process.env.TERMINATOR_DBUS_PATH ?? null
24
+
25
+ /** 是否已确认在 Terminator 中 */
26
+ let insideTerminator: boolean | null = null
27
+
28
+ /**
29
+ * 检测当前子屏幕是否被遮挡(用户在其他子屏幕上操作)
30
+ *
31
+ * @returns true → 本子屏幕被遮挡,不应抑制通知
32
+ * false → 本子屏幕可见,正常抑制逻辑
33
+ * null → 无法确定(不在 Terminator 中或检测失败)
34
+ */
35
+ export function isTerminalOccluded(): boolean | null {
36
+ if (!MY_UUID) {
37
+ if (insideTerminator === null) insideTerminator = false
38
+ return null
39
+ }
40
+
41
+ insideTerminator = true
42
+
43
+ // 如果没有 DBus 名称/路径信息,直接回退到 xdotool
44
+ if (!DBUS_NAME || !DBUS_PATH) {
45
+ debug(`Terminator DBus 环境变量不全: name=${DBUS_NAME} path=${DBUS_PATH},回退到 xdotool`)
46
+ return callXdotool()
47
+ }
48
+
49
+ // 策略 1: busctl
50
+ try {
51
+ const focused = callBusctl()
52
+ if (focused !== null) {
53
+ const occluded = !uuidEqual(focused, MY_UUID)
54
+ debug(`Terminator 焦点检测(busctl): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
55
+ return occluded
56
+ }
57
+ } catch (e) {
58
+ debug(`Terminator busctl 失败: ${e instanceof Error ? e.message : String(e)}`)
59
+ }
60
+
61
+ // 策略 2: python3-dbus
62
+ try {
63
+ const focused = callPythonDBus()
64
+ if (focused !== null) {
65
+ const occluded = !uuidEqual(focused, MY_UUID)
66
+ debug(`Terminator 焦点检测(python): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
67
+ return occluded
68
+ }
69
+ } catch (e) {
70
+ debug(`Terminator python-dbus 失败: ${e instanceof Error ? e.message : String(e)}`)
71
+ }
72
+
73
+ // 策略 3: gdbus
74
+ try {
75
+ const focused = callGDBus()
76
+ if (focused !== null) {
77
+ const occluded = !uuidEqual(focused, MY_UUID)
78
+ debug(`Terminator 焦点检测(gdbus): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
79
+ return occluded
80
+ }
81
+ } catch (e) {
82
+ debug(`Terminator gdbus 失败: ${e instanceof Error ? e.message : String(e)}`)
83
+ }
84
+
85
+ // 策略 4: xdotool 窗口类检测(回退)
86
+ try {
87
+ const result = callXdotool()
88
+ debug(`Terminator 窗口检测(xdotool): ${result}`)
89
+ return result
90
+ } catch (e) {
91
+ warn(`Terminator 所有检测策略均失败,最后错误: ${e instanceof Error ? e.message : String(e)}`)
92
+ return null
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 检查是否在 Terminator 环境中(基于环境变量)
98
+ */
99
+ export function isInsideTerminator(): boolean {
100
+ if (insideTerminator !== null) return insideTerminator
101
+ insideTerminator = !!process.env.TERMINATOR_UUID
102
+ return insideTerminator
103
+ }
104
+
105
+ // ─── 工具函数 ───────────────────────────────────────────────────────────────
106
+
107
+ /** 比较两个 UUID(忽略 urn:uuid: 前缀) */
108
+ function uuidEqual(a: string, b: string): boolean {
109
+ const strip = (s: string) => s.replace(/^urn:uuid:/i, "")
110
+ return strip(a) === strip(b)
111
+ }
112
+
113
+ /** 取 UUID 前 8 位用于日志 */
114
+ function shortId(uuid: string): string {
115
+ return uuid.replace(/^urn:uuid:/i, "").slice(0, 8)
116
+ }
117
+
118
+ // ─── 检测策略实现 ───────────────────────────────────────────────────────────
119
+
120
+ /** 策略 1: busctl(systemd) */
121
+ function callBusctl(): string | null {
122
+ const out = execSync(
123
+ `busctl --user call ${DBUS_NAME} ${DBUS_PATH} ${DBUS_NAME} get_focused_terminal`,
124
+ { encoding: "utf-8", timeout: 3000 },
125
+ ).trim()
126
+ // 输出格式: s "uuid-string"
127
+ const m = out.match(/s\s+"([^"]+)"/)
128
+ return m ? m[1] : null
129
+ }
130
+
131
+ /** 策略 2: python3-dbus */
132
+ function callPythonDBus(): string | null {
133
+ const out = execSync(
134
+ `python3 -c "
135
+ import dbus
136
+ bus = dbus.SessionBus()
137
+ proxy = bus.get_object('${DBUS_NAME}', '${DBUS_PATH}')
138
+ focused = proxy.get_focused_terminal(dbus_interface='${DBUS_NAME}')
139
+ print(focused)
140
+ "`,
141
+ { encoding: "utf-8", timeout: 5000 },
142
+ ).trim()
143
+ return out || null
144
+ }
145
+
146
+ /** 策略 3: gdbus(GLib) */
147
+ function callGDBus(): string | null {
148
+ const out = execSync(
149
+ `gdbus call --session --dest ${DBUS_NAME} --object-path ${DBUS_PATH} --method ${DBUS_NAME}.get_focused_terminal`,
150
+ { encoding: "utf-8", timeout: 3000 },
151
+ ).trim()
152
+ // 输出格式: ('uuid-string',)
153
+ const m = out.match(/\('([^']+)'/)
154
+ return m ? m[1] : null
155
+ }
156
+
157
+ /** 策略 4: xdotool 窗口类检测 */
158
+ function callXdotool(): boolean | null {
159
+ const cls = execSync(
160
+ `xdotool getactivewindow getwindowclassname`,
161
+ { encoding: "utf-8", timeout: 3000 },
162
+ ).trim()
163
+ return cls === "Terminator" ? true : null
164
+ }
@@ -1,42 +0,0 @@
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 { Message } from "../message.js"
13
- import type { Sender } from "./types.js"
14
- import type { ScreenFlashChannelConfig } from "../config.js"
15
-
16
- const __dirname = dirname(fileURLToPath(import.meta.url))
17
- const MARQUEE_SCRIPT = join(__dirname, "..", "scripts", "marquee.py")
18
-
19
- export class ScreenFlashSender implements Sender {
20
- readonly name = "screen_flash"
21
- private config: ScreenFlashChannelConfig
22
-
23
- constructor(config: ScreenFlashChannelConfig) {
24
- this.config = config
25
- }
26
-
27
- async send(_msg: Message): Promise<void> {
28
- if (process.platform !== "linux") return
29
-
30
- const args = [
31
- MARQUEE_SCRIPT,
32
- String(this.config.duration ?? 3.0),
33
- String(this.config.speed ?? 4.0),
34
- String(this.config.intensity ?? 0.9),
35
- ]
36
- const child = spawn("python3", args, {
37
- stdio: "ignore",
38
- detached: true,
39
- })
40
- child.unref()
41
- }
42
- }
package/senders/system.ts DELETED
@@ -1,88 +0,0 @@
1
- /**
2
- * 系统通知发送器
3
- *
4
- * 平台适配:
5
- * - macOS: osascript(内置,无需额外安装)
6
- * - Linux: notify-send(需 libnotify)
7
- * - Windows: PowerShell Toast(需 BurntToast 模块)
8
- */
9
-
10
- import { execSync } from "node:child_process"
11
- import type { Message } from "../message.js"
12
- import type { Sender } from "./types.js"
13
-
14
- export class SystemSender implements Sender {
15
- readonly name = "system_message"
16
-
17
- async send(msg: Message): Promise<void> {
18
- const { title, body } = escapeForShell(msg.title, msg.body)
19
- const platform = process.platform
20
-
21
- try {
22
- if (platform === "darwin") {
23
- this.sendMacOS(title, body)
24
- } else if (platform === "linux") {
25
- this.sendLinux(title, body)
26
- } else if (platform === "win32") {
27
- this.sendWindows(title, body)
28
- }
29
- // 其他平台静默忽略
30
- } catch (err) {
31
- throw new Error(`系统通知失败: ${err}`)
32
- }
33
- }
34
-
35
- private sendMacOS(title: string, body: string): void {
36
- // 使用 osascript 显示原生通知
37
- const script = `display notification "${body}" with title "${title}" sound name "default"`
38
- execSync(`osascript -e ${JSON.stringify(script)}`, {
39
- timeout: 5000,
40
- stdio: "ignore",
41
- })
42
- }
43
-
44
- private sendLinux(title: string, body: string): void {
45
- execSync(`notify-send "${title}" "${body}"`, {
46
- timeout: 5000,
47
- stdio: "ignore",
48
- })
49
- }
50
-
51
- private sendWindows(title: string, body: string): void {
52
- // 尝试使用 BurntToast,失败则用简单 MessageBox
53
- const psScript = `
54
- try {
55
- New-BurntToastNotification -Text '${title}', '${body}' -ErrorAction Stop
56
- } catch {
57
- Add-Type -AssemblyName System.Windows.Forms
58
- [System.Windows.Forms.MessageBox]::Show('${body}', '${title}')
59
- }`
60
- execSync(`powershell -NoProfile -Command ${JSON.stringify(psScript)}`, {
61
- timeout: 10000,
62
- stdio: "ignore",
63
- })
64
- }
65
- }
66
-
67
- /**
68
- * 转义标题和正文中的特殊字符,防止 shell 注入
69
- */
70
- function escapeForShell(
71
- title: string,
72
- body: string,
73
- ): { title: string; body: string } {
74
- return {
75
- title: sanitize(title),
76
- body: sanitize(body),
77
- }
78
- }
79
-
80
- function sanitize(s: string): string {
81
- return s
82
- .replace(/"/g, '\\"')
83
- .replace(/'/g, "\\'")
84
- .replace(/`/g, "\\`")
85
- .replace(/\$/g, "\\$")
86
- .replace(/\n/g, " ")
87
- .replace(/\r/g, "")
88
- }