opencode-termux-notify 0.1.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/assets/audio/bip-bop-01.mp3 +0 -0
- package/assets/audio/bip-bop-03.mp3 +0 -0
- package/assets/audio/nope-03.mp3 +0 -0
- package/assets/audio/staplebops-06.mp3 +0 -0
- package/assets/audio/yup-01.mp3 +0 -0
- package/dist/audio.d.ts +4 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +55 -0
- package/dist/audio.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +47 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +17 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +40 -0
- package/dist/constants.js.map +1 -0
- package/dist/dedup.d.ts +3 -0
- package/dist/dedup.d.ts.map +1 -0
- package/dist/dedup.js +57 -0
- package/dist/dedup.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +4 -0
- package/dist/env.js.map +1 -0
- package/dist/events.d.ts +9 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +52 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +3 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +23 -0
- package/dist/notify.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/v1.d.ts +21 -0
- package/dist/v1.d.ts.map +1 -0
- package/dist/v1.js +133 -0
- package/dist/v1.js.map +1 -0
- package/index.js +4 -0
- package/package.json +80 -0
- package/src/audio.ts +50 -0
- package/src/config.ts +50 -0
- package/src/constants.ts +43 -0
- package/src/dedup.ts +58 -0
- package/src/env.ts +3 -0
- package/src/events.ts +58 -0
- package/src/index.ts +127 -0
- package/src/notify.ts +25 -0
- package/src/types.ts +39 -0
- package/src/v1.ts +140 -0
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, writeFile, rename } from "node:fs/promises"
|
|
2
|
+
import type { ResolvedOpts } from "./types.js"
|
|
3
|
+
|
|
4
|
+
type SharedState = {
|
|
5
|
+
seen: Record<string, number>
|
|
6
|
+
lastBySession: Record<string, number>
|
|
7
|
+
lastGlobal: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function shouldNotifyShared(evtId: string, sessionKey: string, opts: ResolvedOpts): Promise<boolean> {
|
|
11
|
+
const now = Date.now()
|
|
12
|
+
let state: SharedState = {
|
|
13
|
+
seen: {},
|
|
14
|
+
lastBySession: {},
|
|
15
|
+
lastGlobal: 0,
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(opts.sharedPath, "utf8")
|
|
19
|
+
state = JSON.parse(raw)
|
|
20
|
+
for (const [k, exp] of Object.entries(state.seen || {})) {
|
|
21
|
+
if (now > exp) delete state.seen[k]
|
|
22
|
+
}
|
|
23
|
+
} catch {}
|
|
24
|
+
state.seen ??= {}
|
|
25
|
+
state.lastBySession ??= {}
|
|
26
|
+
|
|
27
|
+
if (evtId && state.seen[evtId] && now < (state.seen[evtId] as number)) return false
|
|
28
|
+
|
|
29
|
+
const lastForKey: number = state.lastBySession[sessionKey] || 0
|
|
30
|
+
if (now - lastForKey < opts.sessionCooldown) return false
|
|
31
|
+
if (now - (state.lastGlobal || 0) < opts.globalCooldown) return false
|
|
32
|
+
|
|
33
|
+
if (evtId) state.seen[evtId] = now + opts.seenTTL
|
|
34
|
+
state.lastBySession[sessionKey] = now
|
|
35
|
+
state.lastGlobal = now
|
|
36
|
+
|
|
37
|
+
const seenKeys = Object.keys(state.seen)
|
|
38
|
+
if (seenKeys.length > 300) {
|
|
39
|
+
const sorted = seenKeys.sort((a, b) => (state.seen[b] as number) - (state.seen[a] as number))
|
|
40
|
+
const keep = new Set(sorted.slice(0, 200))
|
|
41
|
+
for (const k of seenKeys) if (!keep.has(k)) delete state.seen[k]
|
|
42
|
+
}
|
|
43
|
+
const sessionKeys = Object.keys(state.lastBySession)
|
|
44
|
+
if (sessionKeys.length > 200) {
|
|
45
|
+
const cutoff = now - opts.sessionCooldown * 4
|
|
46
|
+
for (const k of sessionKeys) if ((state.lastBySession[k] as number) < cutoff) delete state.lastBySession[k]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const tmp = opts.sharedPath + ".tmp." + process.pid
|
|
51
|
+
await writeFile(tmp, JSON.stringify(state), "utf8")
|
|
52
|
+
await rename(tmp, opts.sharedPath)
|
|
53
|
+
} catch (e: unknown) {
|
|
54
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
55
|
+
console.error("[termux-notify] shared state write failed:", msg)
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
}
|
package/src/env.ts
ADDED
package/src/events.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { NotifyKind } from "./types.js"
|
|
2
|
+
|
|
3
|
+
export async function classifyEvent(
|
|
4
|
+
event: any,
|
|
5
|
+
ctx: any,
|
|
6
|
+
): Promise<{ kind: NotifyKind; sound: string; sessionID: string; evtId: string; isSubagent: boolean } | null> {
|
|
7
|
+
const t: string | undefined = event?.type
|
|
8
|
+
if (!t) return null
|
|
9
|
+
|
|
10
|
+
const sessionID: string = event.properties?.sessionID || event.data?.sessionID || event.sessionID || event.properties?.id || "global"
|
|
11
|
+
const evtId: string =
|
|
12
|
+
event.id || event.eventID || `${t}:${sessionID}:${event.created || ""}:${event.properties?.id || event.properties?.requestID || ""}`
|
|
13
|
+
|
|
14
|
+
if (t === "question.asked" || t === "question.v2.asked" || t === "question.updated" || t.startsWith("question")) {
|
|
15
|
+
return { kind: "question", sound: "question", sessionID, evtId, isSubagent: false }
|
|
16
|
+
}
|
|
17
|
+
if (t === "permission.asked" || t === "permission.v2.asked" || t.startsWith("permission")) {
|
|
18
|
+
return { kind: "permission", sound: "permission", sessionID, evtId, isSubagent: false }
|
|
19
|
+
}
|
|
20
|
+
if (
|
|
21
|
+
t === "session.error" ||
|
|
22
|
+
t === "session.execution.failed" ||
|
|
23
|
+
t === "session.step.failed" ||
|
|
24
|
+
t === "error" ||
|
|
25
|
+
t.endsWith(".error")
|
|
26
|
+
) {
|
|
27
|
+
return { kind: "error", sound: "error", sessionID, evtId, isSubagent: false }
|
|
28
|
+
}
|
|
29
|
+
if (t === "session.status" && event.properties?.status?.type === "idle") {
|
|
30
|
+
let isSubagent = false
|
|
31
|
+
try {
|
|
32
|
+
if (ctx?.session?.get) {
|
|
33
|
+
const info: any = await ctx.session.get({ sessionID })
|
|
34
|
+
const raw = info?.info || info?.data || info
|
|
35
|
+
isSubagent = Boolean(raw?.parentID || raw?.parentId || raw?.parent)
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
if (!isSubagent && event.properties?.parentID) isSubagent = true
|
|
39
|
+
const sound = isSubagent ? "subagent_done" : "done"
|
|
40
|
+
const kind: NotifyKind = isSubagent ? "subagent_done" : "done"
|
|
41
|
+
return { kind, sound, sessionID, evtId, isSubagent }
|
|
42
|
+
}
|
|
43
|
+
if (t === "session.idle" || t === "session.execution.succeeded") {
|
|
44
|
+
let isSubagent = false
|
|
45
|
+
try {
|
|
46
|
+
if (ctx?.session?.get) {
|
|
47
|
+
const info: any = await ctx.session.get({ sessionID })
|
|
48
|
+
const raw = info?.info || info?.data || info
|
|
49
|
+
isSubagent = Boolean(raw?.parentID || raw?.parentId)
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
const sound = isSubagent ? "subagent_done" : "done"
|
|
53
|
+
const kind: NotifyKind = isSubagent ? "subagent_done" : "done"
|
|
54
|
+
return { kind, sound, sessionID, evtId, isSubagent }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-termux-notify — Termux-native notifications for OpenCode (V2)
|
|
3
|
+
*
|
|
4
|
+
* 6 builtin attention sounds:
|
|
5
|
+
* default → bip-bop-01.mp3
|
|
6
|
+
* question → bip-bop-03.mp3
|
|
7
|
+
* permission → staplebops-06.mp3
|
|
8
|
+
* error → nope-03.mp3
|
|
9
|
+
* done → bip-bop-01.mp3
|
|
10
|
+
* subagent_done → yup-01.mp3
|
|
11
|
+
*
|
|
12
|
+
* Audio bundled at assets/audio/*.mp3, played via `termux-media-player play <file>`
|
|
13
|
+
* (termux-notification --sound is boolean only — no file arg)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { define } from "@opencode-ai/plugin/v2/promise"
|
|
17
|
+
import { playAudio } from "./audio.js"
|
|
18
|
+
import { getEnabledKinds, getContent, getTitle, resolveOpts } from "./config.js"
|
|
19
|
+
import { shouldNotifyShared } from "./dedup.js"
|
|
20
|
+
import { isTermuxEnvironment } from "./env.js"
|
|
21
|
+
import { classifyEvent } from "./events.js"
|
|
22
|
+
import { notify } from "./notify.js"
|
|
23
|
+
import type { TermuxNotifyOptions } from "./types.js"
|
|
24
|
+
|
|
25
|
+
export type { TermuxNotifyOptions } from "./types.js"
|
|
26
|
+
export { DEFAULTS, PRIORITY_BY_KIND, SOUND_FILES, VIBRATE_PATTERNS } from "./constants.js"
|
|
27
|
+
|
|
28
|
+
export default define({
|
|
29
|
+
id: "termux-notify",
|
|
30
|
+
setup: async (ctx: any): Promise<any> => {
|
|
31
|
+
const userOpts: TermuxNotifyOptions = (ctx.options ?? {}) as TermuxNotifyOptions
|
|
32
|
+
const opts = resolveOpts(userOpts)
|
|
33
|
+
const enabledKinds = getEnabledKinds(userOpts)
|
|
34
|
+
|
|
35
|
+
if (opts.requireTermux && !isTermuxEnvironment()) {
|
|
36
|
+
console.warn("[termux-notify] Not in Termux (TERMUX_VERSION/PREFIX missing) — plugin will still listen but notifications may fail. Set { requireTermux:false } to silence this.")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!ctx?.event?.subscribe) {
|
|
40
|
+
console.error("[termux-notify] ctx.event.subscribe not available — check OpenCode version >= 1.18")
|
|
41
|
+
return () => {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const controller = new AbortController()
|
|
45
|
+
const events: AsyncIterable<any> = ctx.event.subscribe({ signal: controller.signal })
|
|
46
|
+
|
|
47
|
+
const active = new Set<string>()
|
|
48
|
+
const errored = new Set<string>()
|
|
49
|
+
|
|
50
|
+
void (async () => {
|
|
51
|
+
try {
|
|
52
|
+
for await (const event of events as AsyncIterable<any>) {
|
|
53
|
+
if (!event?.type) continue
|
|
54
|
+
|
|
55
|
+
if (event.type === "session.status") {
|
|
56
|
+
const sid: string | undefined = event.properties?.sessionID
|
|
57
|
+
if (!sid) continue
|
|
58
|
+
const st: string | undefined = event.properties?.status?.type
|
|
59
|
+
if (st === "busy" || st === "retry") {
|
|
60
|
+
active.add(sid)
|
|
61
|
+
errored.delete(sid)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (st !== "idle") continue
|
|
65
|
+
if (!active.has(sid)) continue
|
|
66
|
+
active.delete(sid)
|
|
67
|
+
if (errored.has(sid)) {
|
|
68
|
+
errored.delete(sid)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (event.type === "session.error") {
|
|
73
|
+
const sid: string | undefined = event.properties?.sessionID
|
|
74
|
+
if (sid && active.has(sid)) errored.add(sid)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const classified = await classifyEvent(event, ctx)
|
|
78
|
+
if (!classified) continue
|
|
79
|
+
|
|
80
|
+
const { kind, sound, sessionID } = classified
|
|
81
|
+
if (!enabledKinds.has(kind) && !enabledKinds.has(sound)) continue
|
|
82
|
+
|
|
83
|
+
const evtId: string = classified.evtId
|
|
84
|
+
const sessionKey = `${sessionID}:${kind}`
|
|
85
|
+
|
|
86
|
+
const ok = await shouldNotifyShared(evtId, sessionKey, opts)
|
|
87
|
+
if (!ok) continue
|
|
88
|
+
|
|
89
|
+
const nid = `opencode-${sessionID}-${kind}`
|
|
90
|
+
|
|
91
|
+
let sessionName = sessionID.slice(0, 8)
|
|
92
|
+
try {
|
|
93
|
+
if (ctx?.session?.get) {
|
|
94
|
+
const info: any = await ctx.session.get({ sessionID })
|
|
95
|
+
const raw = info?.info || info?.data || info
|
|
96
|
+
const candidate: string | undefined = raw?.title || raw?.slug || raw?.summary?.title || raw?.id
|
|
97
|
+
if (candidate && typeof candidate === "string" && candidate.trim()) {
|
|
98
|
+
sessionName = candidate.trim().slice(0, 40)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
const title = getTitle(kind, sessionName, userOpts)
|
|
104
|
+
const content = getContent(kind, userOpts)
|
|
105
|
+
|
|
106
|
+
const notifySubagents: boolean = (userOpts as any).notifySubagents !== false
|
|
107
|
+
if (kind === "subagent_done" && !notifySubagents) {
|
|
108
|
+
await playAudio(sound, opts)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await notify(title, content, nid, sound, opts)
|
|
114
|
+
} catch (err: unknown) {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
116
|
+
console.error(`[termux-notify] Failed to send ${kind} notification:`, msg)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (e: unknown) {
|
|
120
|
+
const err = e as Error & { name?: string }
|
|
121
|
+
if (err?.name !== "AbortError") console.error("[termux-notify] event loop error:", e)
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
return () => controller.abort()
|
|
126
|
+
},
|
|
127
|
+
})
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { VIBRATE_PATTERNS, PRIORITY_BY_KIND } from "./constants.js"
|
|
3
|
+
import { playAudio } from "./audio.js"
|
|
4
|
+
import type { ResolvedOpts } from "./types.js"
|
|
5
|
+
|
|
6
|
+
export async function notify(title: string, content: string, nid: string, soundName: string, opts: ResolvedOpts): Promise<void> {
|
|
7
|
+
const vibrate = opts.vibrate ? VIBRATE_PATTERNS[soundName] || VIBRATE_PATTERNS.default : undefined
|
|
8
|
+
const priority = opts.priority || PRIORITY_BY_KIND[soundName] || "high"
|
|
9
|
+
const baseArgs = ["--id", nid, "--title", title, "--content", content, "--priority", priority]
|
|
10
|
+
if (vibrate) baseArgs.push("--vibrate", vibrate)
|
|
11
|
+
|
|
12
|
+
const notifPromise = new Promise<void>((resolve, reject) => {
|
|
13
|
+
const p = spawn(opts.bin, baseArgs, { stdio: "ignore" })
|
|
14
|
+
p.on("error", reject)
|
|
15
|
+
p.on("close", (code: number | null) => (code === 0 ? resolve() : reject(new Error(`termux-notification exit ${code}`))))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const audioPromise = playAudio(soundName, opts)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await notifPromise
|
|
22
|
+
} finally {
|
|
23
|
+
void audioPromise.catch(() => {})
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DEFAULTS } from "./constants.js"
|
|
2
|
+
|
|
3
|
+
export interface TermuxNotifyOptions {
|
|
4
|
+
bin?: string
|
|
5
|
+
mediaBin?: string
|
|
6
|
+
sharedPath?: string
|
|
7
|
+
seenTTL?: number
|
|
8
|
+
sessionCooldown?: number
|
|
9
|
+
globalCooldown?: number
|
|
10
|
+
sound?: boolean
|
|
11
|
+
playSound?: boolean
|
|
12
|
+
vibrate?: boolean
|
|
13
|
+
requireTermux?: boolean
|
|
14
|
+
priority?: "high" | "low" | "default" | "max" | (string & {})
|
|
15
|
+
kinds?: Array<"default" | "question" | "permission" | "error" | "done" | "subagent_done" | "idle">
|
|
16
|
+
notifySubagents?: boolean
|
|
17
|
+
title_default?: string
|
|
18
|
+
title_done?: string
|
|
19
|
+
title_subagent_done?: string
|
|
20
|
+
title_question?: string
|
|
21
|
+
title_permission?: string
|
|
22
|
+
title_error?: string
|
|
23
|
+
content_default?: string
|
|
24
|
+
content_done?: string
|
|
25
|
+
content_subagent_done?: string
|
|
26
|
+
content_question?: string
|
|
27
|
+
content_permission?: string
|
|
28
|
+
content_error?: string
|
|
29
|
+
// legacy
|
|
30
|
+
vibrateIdle?: string
|
|
31
|
+
vibrateError?: string
|
|
32
|
+
titleIdle?: string
|
|
33
|
+
titleError?: string
|
|
34
|
+
contentIdle?: string
|
|
35
|
+
contentError?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type NotifyKind = "default" | "question" | "permission" | "error" | "done" | "subagent_done"
|
|
39
|
+
export type ResolvedOpts = typeof DEFAULTS
|
package/src/v1.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-termux-notify — Termux-native notifications for OpenCode (V1)
|
|
3
|
+
*
|
|
4
|
+
* V1 plugin API: export const TermuxNotify: Plugin = async (input, options) => ({ event })
|
|
5
|
+
* Loaded via `plugin: ["opencode-termux-notify"]` or `["opencode-termux-notify/v1"]`
|
|
6
|
+
* V2 is available at `opencode-termux-notify/v2`
|
|
7
|
+
*
|
|
8
|
+
* 6 builtin attention sounds:
|
|
9
|
+
* default → bip-bop-01.mp3
|
|
10
|
+
* question → bip-bop-03.mp3
|
|
11
|
+
* permission → staplebops-06.mp3
|
|
12
|
+
* error → nope-03.mp3
|
|
13
|
+
* done → bip-bop-01.mp3
|
|
14
|
+
* subagent_done → yup-01.mp3
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
18
|
+
import { playAudio } from "./audio.js"
|
|
19
|
+
import { getEnabledKinds, getContent, getTitle, resolveOpts } from "./config.js"
|
|
20
|
+
import { shouldNotifyShared } from "./dedup.js"
|
|
21
|
+
import { isTermuxEnvironment } from "./env.js"
|
|
22
|
+
import { classifyEvent } from "./events.js"
|
|
23
|
+
import { notify } from "./notify.js"
|
|
24
|
+
import type { TermuxNotifyOptions } from "./types.js"
|
|
25
|
+
|
|
26
|
+
export type { TermuxNotifyOptions } from "./types.js"
|
|
27
|
+
export { DEFAULTS, PRIORITY_BY_KIND, SOUND_FILES, VIBRATE_PATTERNS } from "./constants.js"
|
|
28
|
+
|
|
29
|
+
export const TermuxNotify: Plugin = async (input, options) => {
|
|
30
|
+
const userOpts: TermuxNotifyOptions = (options ?? {}) as TermuxNotifyOptions
|
|
31
|
+
const opts = resolveOpts(userOpts)
|
|
32
|
+
const enabledKinds = getEnabledKinds(userOpts)
|
|
33
|
+
|
|
34
|
+
if (opts.requireTermux && !isTermuxEnvironment()) {
|
|
35
|
+
console.warn(
|
|
36
|
+
"[termux-notify] Not in Termux (TERMUX_VERSION/PREFIX missing) — plugin will still listen but notifications may fail. Set { requireTermux:false } to silence this.",
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const active = new Set<string>()
|
|
41
|
+
const errored = new Set<string>()
|
|
42
|
+
|
|
43
|
+
// Adapter so classifyEvent and session-title logic can use V2-style ctx.session.get
|
|
44
|
+
// V1 client uses client.session.get({ path: { id } }) -> { data: Session }
|
|
45
|
+
const ctxAdapter: any = {
|
|
46
|
+
session: {
|
|
47
|
+
get: async ({ sessionID }: { sessionID: string }) => {
|
|
48
|
+
try {
|
|
49
|
+
const client: any = (input as any).client
|
|
50
|
+
if (!client?.session?.get) return undefined
|
|
51
|
+
let raw: any
|
|
52
|
+
// try V2-style first, then V1-style
|
|
53
|
+
try {
|
|
54
|
+
raw = await client.session.get({ sessionID })
|
|
55
|
+
if (raw && (raw.info || raw.data || raw.title || raw.id)) return raw
|
|
56
|
+
} catch {}
|
|
57
|
+
try {
|
|
58
|
+
raw = await client.session.get({ path: { id: sessionID } })
|
|
59
|
+
} catch {}
|
|
60
|
+
// SDK gen returns { data, ... } or direct
|
|
61
|
+
const data = raw?.data ?? raw
|
|
62
|
+
return data
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
event: async ({ event }: { event: any }) => {
|
|
72
|
+
if (!event?.type) return
|
|
73
|
+
|
|
74
|
+
// replicate V2 status tracking to avoid duplicate idle notifications after errors
|
|
75
|
+
if (event.type === "session.status") {
|
|
76
|
+
const sid: string | undefined = event.properties?.sessionID
|
|
77
|
+
if (!sid) return
|
|
78
|
+
const st: string | undefined = event.properties?.status?.type
|
|
79
|
+
if (st === "busy" || st === "retry") {
|
|
80
|
+
active.add(sid)
|
|
81
|
+
errored.delete(sid)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (st !== "idle") return
|
|
85
|
+
if (!active.has(sid)) return
|
|
86
|
+
active.delete(sid)
|
|
87
|
+
if (errored.has(sid)) {
|
|
88
|
+
errored.delete(sid)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (event.type === "session.error") {
|
|
93
|
+
const sid: string | undefined = event.properties?.sessionID
|
|
94
|
+
if (sid && active.has(sid)) errored.add(sid)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const classified = await classifyEvent(event, ctxAdapter)
|
|
98
|
+
if (!classified) return
|
|
99
|
+
|
|
100
|
+
const { kind, sound, sessionID } = classified
|
|
101
|
+
if (!enabledKinds.has(kind) && !enabledKinds.has(sound)) return
|
|
102
|
+
|
|
103
|
+
const evtId: string = classified.evtId
|
|
104
|
+
const sessionKey = `${sessionID}:${kind}`
|
|
105
|
+
|
|
106
|
+
const ok = await shouldNotifyShared(evtId, sessionKey, opts)
|
|
107
|
+
if (!ok) return
|
|
108
|
+
|
|
109
|
+
const nid = `opencode-${sessionID}-${kind}`
|
|
110
|
+
|
|
111
|
+
let sessionName = sessionID.slice(0, 8)
|
|
112
|
+
try {
|
|
113
|
+
const info: any = await ctxAdapter.session.get({ sessionID })
|
|
114
|
+
const raw = info?.info || info?.data || info
|
|
115
|
+
const candidate: string | undefined = raw?.title || raw?.slug || raw?.summary?.title || raw?.id
|
|
116
|
+
if (candidate && typeof candidate === "string" && candidate.trim()) {
|
|
117
|
+
sessionName = candidate.trim().slice(0, 40)
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
const title = getTitle(kind, sessionName, userOpts)
|
|
122
|
+
const content = getContent(kind, userOpts)
|
|
123
|
+
|
|
124
|
+
const notifySubagents: boolean = (userOpts as any).notifySubagents !== false
|
|
125
|
+
if (kind === "subagent_done" && !notifySubagents) {
|
|
126
|
+
await playAudio(sound, opts)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await notify(title, content, nid, sound, opts)
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
134
|
+
console.error(`[termux-notify] Failed to send ${kind} notification:`, msg)
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default TermuxNotify
|