@freely01/opencode-notify 0.4.1 → 0.4.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.
- package/config.ts +13 -3
- package/delayed-dispatcher.ts +42 -9
- package/events.ts +26 -7
- package/index.ts +13 -7
- package/message.ts +20 -9
- package/package.json +1 -1
- package/session-tracker.ts +31 -8
package/config.ts
CHANGED
|
@@ -75,6 +75,7 @@ export interface PluginConfig {
|
|
|
75
75
|
* input_required - Agent 等待用户输入
|
|
76
76
|
* run_completed - 任务执行完成(技术预留,暂未实现)
|
|
77
77
|
* run_failed - 任务执行失败
|
|
78
|
+
* run_cancelled - 用户主动中断任务
|
|
78
79
|
*/
|
|
79
80
|
events?: string[]
|
|
80
81
|
/** 去重时间窗口(秒),默认 60 */
|
|
@@ -259,7 +260,12 @@ channels:
|
|
|
259
260
|
# =============================================================================
|
|
260
261
|
# 只订阅你关心的事件类型,减少不必要通知。
|
|
261
262
|
#
|
|
262
|
-
#
|
|
263
|
+
# ⚠️ 全局 events 是主闸门:只有在此列表中的事件才会生成通知消息。
|
|
264
|
+
# 渠道级 events(channels.xxx.events)只能从全局列表中进一步收窄,
|
|
265
|
+
# 无法新增全局列表之外的事件。例如全局无 run_cancelled 时,
|
|
266
|
+
# 即使渠道配了 run_cancelled 也不会收到。
|
|
267
|
+
#
|
|
268
|
+
# 可选事件(各渠道也可单独配置 events,从全局列表中进一步筛选):
|
|
263
269
|
# permission_required - Agent 需要用户授权(如执行命令、读写文件)
|
|
264
270
|
# 触发: permission.asked / question.asked
|
|
265
271
|
# input_required - Agent 等待用户输入
|
|
@@ -267,11 +273,14 @@ channels:
|
|
|
267
273
|
# run_completed - 任务执行完成(技术预留,暂未实现)
|
|
268
274
|
# run_failed - 任务执行失败
|
|
269
275
|
# 触发: session.error
|
|
276
|
+
# run_cancelled - 用户主动中断任务(Ctrl+C 或点击中断按钮)
|
|
277
|
+
# 触发: session.error (MessageAbortedError)
|
|
270
278
|
# ---------------------------------------------------------------------------
|
|
271
279
|
events:
|
|
272
280
|
- permission_required # 权限请求通知(推荐开启)
|
|
273
281
|
- input_required # 等待输入通知(推荐开启)
|
|
274
282
|
- run_failed # 任务失败通知
|
|
283
|
+
- run_cancelled # 用户取消通知(推荐开启)
|
|
275
284
|
|
|
276
285
|
|
|
277
286
|
# =============================================================================
|
|
@@ -300,7 +309,7 @@ dedupe_seconds: 60 # 去重时间窗口(秒),0 或负数
|
|
|
300
309
|
#
|
|
301
310
|
# 抑制规则:
|
|
302
311
|
# permission_required / input_required: 活跃时抑制(屏上可见)
|
|
303
|
-
# run_failed / run_completed: 始终通知(异步结果,人可能走开)
|
|
312
|
+
# run_failed / run_completed / run_cancelled: 始终通知(异步结果,人可能走开)
|
|
304
313
|
# ---------------------------------------------------------------------------
|
|
305
314
|
suppress_when_active: true # true=开启会话感知抑制, false=不抑制
|
|
306
315
|
activity_timeout_ms: 15000 # 会话操作超时(毫秒)
|
|
@@ -309,7 +318,7 @@ activity_timeout_ms: 15000 # 会话操作超时(毫秒)
|
|
|
309
318
|
suppress_events_when_active: # 活跃时抑制哪些事件(不填继承默认)
|
|
310
319
|
- permission_required
|
|
311
320
|
- input_required
|
|
312
|
-
# run_failed / run_completed 不在列表中 → 始终通知
|
|
321
|
+
# run_failed / run_completed / run_cancelled 不在列表中 → 始终通知
|
|
313
322
|
session_stale_timeout_ms: 600000 # 超时会话自动淘汰(毫秒)
|
|
314
323
|
# 10 分钟无任何活动的会话从追踪 Map 移除
|
|
315
324
|
# 防止长期运行导致内存泄漏
|
|
@@ -416,6 +425,7 @@ const DEFAULT_CONFIG: Required<Pick<PluginConfig, "suppress_when_active" | "acti
|
|
|
416
425
|
"input_required",
|
|
417
426
|
"run_completed",
|
|
418
427
|
"run_failed",
|
|
428
|
+
"run_cancelled",
|
|
419
429
|
],
|
|
420
430
|
dedupe_seconds: 60,
|
|
421
431
|
suppress_when_active: true,
|
package/delayed-dispatcher.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Message } from "./message.js"
|
|
2
2
|
import type { Sender } from "./senders/types.js"
|
|
3
3
|
import { error, warn, info, debug } from "./log.js"
|
|
4
|
+
import { isTerminalOccluded } from "./terminator-detect.js"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* 远程延迟通知调度器
|
|
@@ -96,24 +97,46 @@ export class DelayedDispatcher {
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 将毫秒转换为人类可读的间隔描述
|
|
102
|
+
* 例: 30_000 → "30秒", 120_000 → "2分钟", 3_600_000 → "60分钟"
|
|
103
|
+
*/
|
|
104
|
+
private formatInterval(ms: number): string {
|
|
105
|
+
const sec = Math.round(ms / 1000)
|
|
106
|
+
if (sec < 60) return `${sec}秒`
|
|
107
|
+
const min = Math.round(sec / 60)
|
|
108
|
+
return `${min}分钟`
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
/**
|
|
100
112
|
* 在通知正文追加或替换延迟推送标记
|
|
101
113
|
*
|
|
102
114
|
* 清除正文末尾已有的旧标记行,追加最新标记。
|
|
103
115
|
* 标记格式:
|
|
104
116
|
* ─────────────────
|
|
105
|
-
* ⚠️
|
|
117
|
+
* ⚠️ 延迟 第2/3次(下次约 15:31:00 / 2分钟后)
|
|
106
118
|
*/
|
|
107
|
-
private markDelayBody(body: string, current: number, total: number,
|
|
119
|
+
private markDelayBody(body: string, current: number, total: number, nextDelayMs: number): string {
|
|
108
120
|
// 移除旧标记(从末尾 ─── 分隔线到最后)
|
|
109
121
|
const clean = body.replace(/\n─{3,}[\s\S]*$/, "")
|
|
110
122
|
if (current < total) {
|
|
111
|
-
const next = new Date(Date.now() +
|
|
123
|
+
const next = new Date(Date.now() + nextDelayMs)
|
|
112
124
|
const t = `${String(next.getHours()).padStart(2, "0")}:${String(next.getMinutes()).padStart(2, "0")}:${String(next.getSeconds()).padStart(2, "0")}`
|
|
113
|
-
return `${clean}\n─────────────────\n⚠️
|
|
125
|
+
return `${clean}\n─────────────────\n⚠️ 延迟 第${current}/${total}次(下次约 ${t} / ${this.formatInterval(nextDelayMs)}后)`
|
|
114
126
|
}
|
|
115
127
|
// 最后一次推送,不显示下次时间
|
|
116
|
-
return `${clean}\n─────────────────\n⚠️
|
|
128
|
+
return `${clean}\n─────────────────\n⚠️ 延迟 第${current}/${total}次(最终)`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 根据已发送次数计算本次延迟
|
|
133
|
+
*
|
|
134
|
+
* 指数退避:base × 2^count,上限 10 分钟
|
|
135
|
+
* count=0 → base, count=1 → base×2, count=2 → base×4, ...
|
|
136
|
+
*/
|
|
137
|
+
private getDelayMs(count: number): number {
|
|
138
|
+
const max = 600_000 // 10 分钟上限
|
|
139
|
+
return Math.min(this.delayMs * Math.pow(2, count), max)
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
/**
|
|
@@ -125,12 +148,22 @@ export class DelayedDispatcher {
|
|
|
125
148
|
const entry = chMap.get(ch)
|
|
126
149
|
if (!entry) return
|
|
127
150
|
|
|
151
|
+
const currentDelay = this.getDelayMs(entry.count)
|
|
152
|
+
|
|
128
153
|
entry.timeoutId = setTimeout(() => {
|
|
129
154
|
entry.timeoutId = null
|
|
130
155
|
|
|
131
|
-
//
|
|
156
|
+
// 发送前检查:如果终端子屏可见(用户已回到电脑前),取消本会话的所有待发延迟
|
|
157
|
+
if (isTerminalOccluded() === false) {
|
|
158
|
+
info(`远程延迟: 用户已回到终端,取消会话=${sid} 的延迟推送`)
|
|
159
|
+
this.cancelForSession(sid)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 在正文追加延迟标记(第几次 / 共几次 / 下次时间)
|
|
132
164
|
const sendCount = entry.count + 1 // 1-based
|
|
133
|
-
|
|
165
|
+
const nextDelay = this.getDelayMs(entry.count + 1)
|
|
166
|
+
msg.body = this.markDelayBody(msg.body, sendCount, this.maxCount, nextDelay)
|
|
134
167
|
|
|
135
168
|
// 发送延迟通知
|
|
136
169
|
const sender = this.senders.get(ch)
|
|
@@ -145,7 +178,7 @@ export class DelayedDispatcher {
|
|
|
145
178
|
entry.count++
|
|
146
179
|
if (entry.count < this.maxCount) {
|
|
147
180
|
this.scheduleOne(sid, ch, msg)
|
|
148
|
-
debug(`远程延迟: 重试 ${entry.count}/${this.maxCount} 会话=${sid} 渠道=${ch}`)
|
|
181
|
+
debug(`远程延迟: 重试 ${entry.count}/${this.maxCount} 会话=${sid} 渠道=${ch} 下次延迟=${this.getDelayMs(entry.count)}ms`)
|
|
149
182
|
} else {
|
|
150
183
|
// 达到最大次数,清理
|
|
151
184
|
chMap.delete(ch)
|
|
@@ -154,6 +187,6 @@ export class DelayedDispatcher {
|
|
|
154
187
|
}
|
|
155
188
|
info(`远程延迟: 已完成 会话=${sid} 渠道=${ch} (推送${entry.count}次)`)
|
|
156
189
|
}
|
|
157
|
-
},
|
|
190
|
+
}, currentDelay)
|
|
158
191
|
}
|
|
159
192
|
}
|
package/events.ts
CHANGED
|
@@ -71,14 +71,33 @@ export function route(
|
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// 会话错误 → run_failed
|
|
75
|
-
|
|
74
|
+
// 会话错误 → run_cancelled / run_failed
|
|
75
|
+
// MessageAbortedError = 用户主动中断(Ctrl+C 或点击中断按钮)
|
|
76
|
+
// 其他错误类型 = 真实失败
|
|
77
|
+
if (type === "session.error") {
|
|
76
78
|
const err = properties.error
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
|
|
80
|
+
// 用户主动中断
|
|
81
|
+
if (err?.name === "MessageAbortedError") {
|
|
82
|
+
if (enabled.has("run_cancelled")) {
|
|
83
|
+
const msg = err?.data?.message ?? ""
|
|
84
|
+
return makeMsg(
|
|
85
|
+
"run_cancelled",
|
|
86
|
+
msg ? `用户中断: ${truncate(msg, 200)}` : defaultBody("run_cancelled"),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
// run_cancelled 未启用 → 跳过,不降级为 run_failed
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 真实失败
|
|
94
|
+
if (enabled.has("run_failed")) {
|
|
95
|
+
const errMsg = err?.data?.message ?? err?.name ?? defaultBody("run_failed")
|
|
96
|
+
return makeMsg(
|
|
97
|
+
"run_failed",
|
|
98
|
+
`错误: ${truncate(String(errMsg), 200)}`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
// 会话空闲 → input_required
|
package/index.ts
CHANGED
|
@@ -86,9 +86,14 @@ const plugin: Plugin = async (_input, options) => {
|
|
|
86
86
|
|
|
87
87
|
// 会话生命周期事件
|
|
88
88
|
if (type === "session.created") {
|
|
89
|
-
const
|
|
90
|
-
tracker.register(sessionID,
|
|
91
|
-
debug(`→ 会话已创建, 会话=${sessionID}${
|
|
89
|
+
const userPrompt = properties.info?.title
|
|
90
|
+
tracker.register(sessionID, userPrompt)
|
|
91
|
+
debug(`→ 会话已创建, 会话=${sessionID}${userPrompt ? ` userPrompt="${userPrompt}"` : ""}`)
|
|
92
|
+
}
|
|
93
|
+
if (type === "session.updated") {
|
|
94
|
+
const topic = properties.info?.title
|
|
95
|
+
tracker.updateTopic(sessionID, topic)
|
|
96
|
+
debug(`→ 会话已更新, 会话=${sessionID}${topic ? ` topic="${topic}"` : ""}`)
|
|
92
97
|
}
|
|
93
98
|
if (type === "session.deleted") {
|
|
94
99
|
tracker.remove(sessionID)
|
|
@@ -104,11 +109,12 @@ const plugin: Plugin = async (_input, options) => {
|
|
|
104
109
|
const msg = route(event, cfg.events)
|
|
105
110
|
if (!msg) return // 不关心的事件
|
|
106
111
|
|
|
107
|
-
//
|
|
108
|
-
const
|
|
109
|
-
|
|
112
|
+
// 注入会话主题和任务描述,增强通知内容
|
|
113
|
+
const userPrompt = tracker.getUserPrompt(sessionID)
|
|
114
|
+
const sessionTopic = tracker.getSessionTopic(sessionID)
|
|
115
|
+
enrich(msg, userPrompt, sessionTopic)
|
|
110
116
|
|
|
111
|
-
debug(`→ 匹配通知: ${msg.event}${
|
|
117
|
+
debug(`→ 匹配通知: ${msg.event} sessionTopic="${sessionTopic ?? ""}" userPrompt="${userPrompt ?? ""}"`)
|
|
112
118
|
|
|
113
119
|
// 会话感知抑制判定
|
|
114
120
|
let shouldSuppress = cfg.suppress_when_active && suppressEvents.includes(msg.event)
|
package/message.ts
CHANGED
|
@@ -20,6 +20,7 @@ const EVENT_LABELS: Record<string, string> = {
|
|
|
20
20
|
input_required: "等待输入",
|
|
21
21
|
run_completed: "任务完成",
|
|
22
22
|
run_failed: "任务失败",
|
|
23
|
+
run_cancelled: "用户取消",
|
|
23
24
|
session_idle: "会话空闲",
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -54,6 +55,8 @@ export function defaultBody(event: string): string {
|
|
|
54
55
|
return "任务执行完成"
|
|
55
56
|
case "run_failed":
|
|
56
57
|
return "任务执行失败"
|
|
58
|
+
case "run_cancelled":
|
|
59
|
+
return "用户主动中断了任务"
|
|
57
60
|
default:
|
|
58
61
|
return `事件: ${event}`
|
|
59
62
|
}
|
|
@@ -88,21 +91,29 @@ function shortTitle(title: string, maxLen = 20): string {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/**
|
|
91
|
-
*
|
|
94
|
+
* 增强通知消息:注入会话上下文
|
|
92
95
|
*
|
|
93
|
-
* - 标题:`[
|
|
94
|
-
* -
|
|
96
|
+
* - 标题:`[主题] 事件标签`(替换 `opencode - 事件标签`)
|
|
97
|
+
* - 正文:用 `主题` / `任务` 行替换 `详情` 行
|
|
95
98
|
*
|
|
96
99
|
* @param msg 原始通知消息
|
|
97
|
-
* @param
|
|
100
|
+
* @param userPrompt 用户输入的问题/任务描述(创建时捕获)
|
|
101
|
+
* @param sessionTopic opencode 自动生成的会话主题
|
|
98
102
|
* @returns 增强后的消息(原地修改并返回)
|
|
99
103
|
*/
|
|
100
|
-
export function enrich(msg: Message,
|
|
101
|
-
if (!
|
|
104
|
+
export function enrich(msg: Message, userPrompt?: string, sessionTopic?: string): Message {
|
|
105
|
+
if (!userPrompt && !sessionTopic) return msg
|
|
102
106
|
|
|
107
|
+
// 标题优先用主题(更简洁),无主题则用用户提示词
|
|
108
|
+
const title = sessionTopic ?? userPrompt
|
|
103
109
|
const label = EVENT_LABELS[msg.event] ?? msg.event
|
|
104
|
-
msg.title = `[${shortTitle(
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
msg.title = `[${shortTitle(title!)}] ${label}`
|
|
111
|
+
|
|
112
|
+
// 替换"详情"行,按可用字段生成上下文行
|
|
113
|
+
const lines: string[] = []
|
|
114
|
+
if (sessionTopic) lines.push(`主题:${sessionTopic}`)
|
|
115
|
+
if (userPrompt) lines.push(`任务:${userPrompt}`)
|
|
116
|
+
// 如果两者都为空(前面 return 了)不可能到这里
|
|
117
|
+
msg.body = msg.body.replace(/^详情:.*$/m, lines.join("\n"))
|
|
107
118
|
return msg
|
|
108
119
|
}
|
package/package.json
CHANGED
package/session-tracker.ts
CHANGED
|
@@ -20,8 +20,10 @@ export interface SessionInfo {
|
|
|
20
20
|
lastActivity: number
|
|
21
21
|
/** 会话创建时间 */
|
|
22
22
|
createdAt: number
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/** 用户输入的问题/任务描述(创建时捕获) */
|
|
24
|
+
userPrompt?: string
|
|
25
|
+
/** opencode 自动生成的会话主题(session.updated 时更新) */
|
|
26
|
+
sessionTopic?: string
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 分钟
|
|
@@ -71,24 +73,45 @@ export class SessionTracker {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/** 注册新会话 */
|
|
74
|
-
register(sessionID: string,
|
|
76
|
+
register(sessionID: string, userPrompt?: string): void {
|
|
75
77
|
if (sessionID === "unknown") return
|
|
76
78
|
const existing = this.sessions.get(sessionID)
|
|
77
79
|
if (existing) {
|
|
78
|
-
if (
|
|
80
|
+
if (userPrompt) existing.userPrompt = userPrompt
|
|
79
81
|
} else {
|
|
80
82
|
this.sessions.set(sessionID, {
|
|
81
83
|
sessionID,
|
|
82
84
|
lastActivity: Date.now(),
|
|
83
85
|
createdAt: Date.now(),
|
|
84
|
-
|
|
86
|
+
userPrompt,
|
|
85
87
|
})
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
/** 更新会话主题(opencode 自动生成,来自 session.updated) */
|
|
92
|
+
updateTopic(sessionID: string, topic: string): void {
|
|
93
|
+
if (sessionID === "unknown" || !topic) return
|
|
94
|
+
const existing = this.sessions.get(sessionID)
|
|
95
|
+
if (existing) {
|
|
96
|
+
existing.sessionTopic = topic
|
|
97
|
+
} else {
|
|
98
|
+
this.sessions.set(sessionID, {
|
|
99
|
+
sessionID,
|
|
100
|
+
lastActivity: Date.now(),
|
|
101
|
+
createdAt: Date.now(),
|
|
102
|
+
sessionTopic: topic,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 获取用户提示词 */
|
|
108
|
+
getUserPrompt(sessionID: string): string | undefined {
|
|
109
|
+
return this.sessions.get(sessionID)?.userPrompt
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 获取会话主题 */
|
|
113
|
+
getSessionTopic(sessionID: string): string | undefined {
|
|
114
|
+
return this.sessions.get(sessionID)?.sessionTopic
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
/** 移除会话 */
|