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.
Files changed (60) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/assets/audio/bip-bop-01.mp3 +0 -0
  5. package/assets/audio/bip-bop-03.mp3 +0 -0
  6. package/assets/audio/nope-03.mp3 +0 -0
  7. package/assets/audio/staplebops-06.mp3 +0 -0
  8. package/assets/audio/yup-01.mp3 +0 -0
  9. package/dist/audio.d.ts +4 -0
  10. package/dist/audio.d.ts.map +1 -0
  11. package/dist/audio.js +55 -0
  12. package/dist/audio.js.map +1 -0
  13. package/dist/config.d.ts +6 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +47 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/constants.d.ts +17 -0
  18. package/dist/constants.d.ts.map +1 -0
  19. package/dist/constants.js +40 -0
  20. package/dist/constants.js.map +1 -0
  21. package/dist/dedup.d.ts +3 -0
  22. package/dist/dedup.d.ts.map +1 -0
  23. package/dist/dedup.js +57 -0
  24. package/dist/dedup.js.map +1 -0
  25. package/dist/env.d.ts +2 -0
  26. package/dist/env.d.ts.map +1 -0
  27. package/dist/env.js +4 -0
  28. package/dist/env.js.map +1 -0
  29. package/dist/events.d.ts +9 -0
  30. package/dist/events.d.ts.map +1 -0
  31. package/dist/events.js +52 -0
  32. package/dist/events.js.map +1 -0
  33. package/dist/index.d.ts +19 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +119 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/notify.d.ts +3 -0
  38. package/dist/notify.d.ts.map +1 -0
  39. package/dist/notify.js +23 -0
  40. package/dist/notify.js.map +1 -0
  41. package/dist/types.d.ts +37 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +2 -0
  44. package/dist/types.js.map +1 -0
  45. package/dist/v1.d.ts +21 -0
  46. package/dist/v1.d.ts.map +1 -0
  47. package/dist/v1.js +133 -0
  48. package/dist/v1.js.map +1 -0
  49. package/index.js +4 -0
  50. package/package.json +80 -0
  51. package/src/audio.ts +50 -0
  52. package/src/config.ts +50 -0
  53. package/src/constants.ts +43 -0
  54. package/src/dedup.ts +58 -0
  55. package/src/env.ts +3 -0
  56. package/src/events.ts +58 -0
  57. package/src/index.ts +127 -0
  58. package/src/notify.ts +25 -0
  59. package/src/types.ts +39 -0
  60. 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
@@ -0,0 +1,3 @@
1
+ export function isTermuxEnvironment(): boolean {
2
+ return Boolean(process.env.TERMUX_VERSION || process.env.PREFIX?.includes("com.termux"))
3
+ }
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