@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 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
- # 可选事件(各渠道也可单独配置 events 覆盖此全局列表):
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,
@@ -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
- * ⚠️ 延迟推送 第2/3次(下次约 15:31:00
117
+ * ⚠️ 延迟 第2/3次(下次约 15:31:00 / 2分钟后)
106
118
  */
107
- private markDelayBody(body: string, current: number, total: number, delayMs: number): string {
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() + delayMs)
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⚠️ 延迟推送 第${current}/${total}次(下次约 ${t})`
125
+ return `${clean}\n─────────────────\n⚠️ 延迟 第${current}/${total}次(下次约 ${t} / ${this.formatInterval(nextDelayMs)}后)`
114
126
  }
115
127
  // 最后一次推送,不显示下次时间
116
- return `${clean}\n─────────────────\n⚠️ 延迟推送 第${current}/${total}次(最终)`
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
- msg.body = this.markDelayBody(msg.body, sendCount, this.maxCount, this.delayMs)
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
- }, this.delayMs)
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
- if (type === "session.error" && enabled.has("run_failed")) {
74
+ // 会话错误 → run_cancelled / run_failed
75
+ // MessageAbortedError = 用户主动中断(Ctrl+C 或点击中断按钮)
76
+ // 其他错误类型 = 真实失败
77
+ if (type === "session.error") {
76
78
  const err = properties.error
77
- const errMsg = err?.message ?? err?.name ?? defaultBody("run_failed")
78
- return makeMsg(
79
- "run_failed",
80
- `错误: ${truncate(String(errMsg), 200)}`,
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 title = properties.info?.title
90
- tracker.register(sessionID, title)
91
- debug(`→ 会话已创建, 会话=${sessionID}${title ? ` title="${title}"` : ""}`)
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 sessionTitle = tracker.getSessionTitle(sessionID)
109
- enrich(msg, sessionTitle)
112
+ // 注入会话主题和任务描述,增强通知内容
113
+ const userPrompt = tracker.getUserPrompt(sessionID)
114
+ const sessionTopic = tracker.getSessionTopic(sessionID)
115
+ enrich(msg, userPrompt, sessionTopic)
110
116
 
111
- debug(`→ 匹配通知: ${msg.event}${sessionTitle ? ` sessionTitle="${sessionTitle}"` : ""}`)
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
- * - 标题:`[短标题] 事件标签`(替换 `opencode - 事件标签`)
94
- * - 正文:追加一行 `任务:完整标题`
96
+ * - 标题:`[主题] 事件标签`(替换 `opencode - 事件标签`)
97
+ * - 正文:用 `主题` / `任务` 行替换 `详情` 行
95
98
  *
96
99
  * @param msg 原始通知消息
97
- * @param sessionTitle 会话标题(用户输入的问题/任务描述),为空则不增强
100
+ * @param userPrompt 用户输入的问题/任务描述(创建时捕获)
101
+ * @param sessionTopic opencode 自动生成的会话主题
98
102
  * @returns 增强后的消息(原地修改并返回)
99
103
  */
100
- export function enrich(msg: Message, sessionTitle?: string): Message {
101
- if (!sessionTitle) return msg
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(sessionTitle)}] ${label}`
105
- // 用"任务"行替换"详情"行(详情的事件细节在会话标题下显得冗余)
106
- msg.body = msg.body.replace(/^详情:.*$/m, `任务:${sessionTitle}`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freely01/opencode-notify",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "opencode 通知插件 - 监听会话事件并通过多渠道推送通知",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -20,8 +20,10 @@ export interface SessionInfo {
20
20
  lastActivity: number
21
21
  /** 会话创建时间 */
22
22
  createdAt: number
23
- /** 会话标题(用户输入的问题/任务描述) */
24
- title?: string
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, title?: string): void {
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 (title) existing.title = title
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
- title,
86
+ userPrompt,
85
87
  })
86
88
  }
87
89
  }
88
90
 
89
- /** 获取会话标题 */
90
- getSessionTitle(sessionID: string): string | undefined {
91
- return this.sessions.get(sessionID)?.title
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
  /** 移除会话 */