opencode-plugin-apprise 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-plugin-apprise contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # opencode-plugin-apprise
2
+
3
+ OpenCode plugin for multi-service notifications via Apprise.
4
+
5
+ ## Features
6
+
7
+ - Multi-service support for 128+ notification services via Apprise.
8
+ - Automatic notifications when sessions go idle.
9
+ - Delayed notifications for Question tool prompts (30-second grace period).
10
+ - Alerts when sessions transition to idle after activity.
11
+ - Notifications for permission requests with dual-mechanism reliability.
12
+
13
+ ## Prerequisites
14
+
15
+ - OpenCode
16
+ - Python 3.x
17
+ - Apprise (`pip install apprise`)
18
+
19
+ ## Quick Start
20
+
21
+ 1. Install the plugin by adding it to your `opencode.json` plugin array:
22
+
23
+ ```json
24
+ "plugins": ["opencode-plugin-apprise"]
25
+ ```
26
+
27
+ Or use the CLI:
28
+ `opencode plugins add opencode-plugin-apprise`
29
+
30
+ 2. Configure Apprise with your notification URLs in a default Apprise config file such as `~/.apprise`, `~/.apprise.yml`, or `~/.config/apprise/apprise.yml`.
31
+
32
+ Example:
33
+
34
+ ```yaml
35
+ # ~/.config/apprise/apprise.yml
36
+ urls:
37
+ - slack://TokenA/TokenB/TokenC
38
+ - discord://webhook_id/webhook_token
39
+ - tgram://bottoken/ChatID
40
+ ```
41
+
42
+ 3. Restart OpenCode — the plugin will automatically detect Apprise and use your configured services.
43
+
44
+ ## Configuration
45
+
46
+ The plugin relies on Apprise's default configuration file behavior.
47
+
48
+ ### Apprise Config File Locations
49
+
50
+ Apprise automatically looks for config files in these locations (in order):
51
+
52
+ - `~/.apprise`
53
+ - `~/.apprise.yml`
54
+ - `~/.config/apprise/apprise.yml`
55
+
56
+ For complete configuration options, see: https://github.com/caronc/apprise#configuration-file
57
+
58
+ ### Environment Variables
59
+
60
+ | Variable | Required | Description |
61
+ |----------|----------|-------------|
62
+ | `OPENCODE_NOTIFY_TAG` | No | Apprise tag for filtering which configured services receive notifications. When set, only services matching this tag in your Apprise config will be notified. |
63
+
64
+ ### Behavior Defaults
65
+
66
+ | Setting | Value |
67
+ |---------|:------|
68
+ | Maximum message length | 1,500 characters |
69
+ | Deduplication TTL | 5 minutes (max 100 entries) |
70
+ | Question notification delay | 30 seconds |
71
+ | Apprise CLI timeout | 30 seconds |
72
+
73
+ ## Notification Triggers
74
+
75
+ ### Idle
76
+
77
+ Fires immediately when OpenCode emits a `session.idle` event. Includes the last user request, agent response, and todo status.
78
+
79
+ **Severity**: info
80
+
81
+ ```
82
+ 📢 OpenCode Attention Required
83
+ 📝 Request: Build a REST API
84
+ 🤖 Response: I've created the Express server...
85
+ 📋 Todo: ✅ 3 done | ▶️ 1 in_progress | ⚪ 2 pending
86
+ ```
87
+
88
+ ### Question
89
+
90
+ Fires 30 seconds after the Question tool is invoked. If the user answers within 30 seconds, the notification is cancelled. This prevents spam for quick interactions.
91
+
92
+ **Severity**: warning
93
+
94
+ ```
95
+ ❓ OpenCode Question
96
+ ❓ Question: Deploy to production?
97
+ Options:
98
+ 1. yes
99
+ 2. no
100
+ 3. cancel
101
+ ```
102
+
103
+ ### Background
104
+
105
+ Fires when a session's status transitions to `idle` after being active. This indicates the agent has finished working and the session is waiting.
106
+
107
+ **Severity**: success
108
+
109
+ ```
110
+ ✅ Background Task Complete
111
+ Task: Session ses_abc123
112
+ ```
113
+
114
+ ### Permission
115
+
116
+ Fires when a tool requires explicit user permission. Uses two mechanisms for reliability: the primary `permission.ask` hook and a fallback `permission.updated` event listener. Permissions are deduplicated by ID to prevent double notifications.
117
+
118
+ **Severity**: warning
119
+
120
+ ```
121
+ 🔐 OpenCode Permission Required
122
+ 🔧 Tool: bash
123
+ ⚡ Action: execute rm -rf node_modules
124
+ ```
125
+
126
+ ## Supported Services
127
+
128
+ Apprise supports many services. Use these URL formats:
129
+
130
+ - **Slack**: `slack://TokenA/TokenB/TokenC`
131
+ - **Discord**: `discord://webhook_id/webhook_token`
132
+ - **Telegram**: `tgram://bottoken/ChatID`
133
+ - **Email**: `mailto://user:pass@gmail.com`
134
+
135
+ For a complete list, see: https://github.com/caronc/apprise#supported-notifications
136
+
137
+ ## How It Works
138
+
139
+ ### Message Truncation
140
+
141
+ Messages exceeding 1,500 characters are truncated. For messages with more than 10 lines, the first 5 and last 5 lines are preserved with a `...(truncated)` marker. Otherwise, a simple character truncation is applied.
142
+
143
+ ### Deduplication
144
+
145
+ Identical notifications are suppressed for 5 minutes. Duplicates are identified by a hash of the notification type, title, user request, and question text. The cache holds a maximum of 100 entries with LRU eviction.
146
+
147
+ ### Notification Severity Mapping
148
+
149
+ | Event | Apprise Type |
150
+ |-------|:-------------|
151
+ | Idle | info |
152
+ | Question | warning |
153
+ | Background | success |
154
+ | Permission | warning |
155
+
156
+ ## Troubleshooting
157
+
158
+ - **apprise CLI not found**: Run `pip install apprise` to install the required dependency.
159
+ - **No notifications received**: Check your Apprise config file (`~/.apprise`, `~/.apprise.yml`, or `~/.config/apprise/apprise.yml`) and test with `apprise -t test -b test`.
160
+ - **Notifications not reaching a specific service**: Set `OPENCODE_NOTIFY_TAG` to match the tag assigned to that service in your Apprise config.
161
+ - **Too many notifications**: Deduplication suppresses identical notifications for 5 minutes.
162
+ - **Notifications cut off**: Messages are truncated at 1,500 characters.
163
+ - **Apprise command hangs**: The CLI timeout is 30 seconds. If Apprise doesn't respond in time, the notification fails silently.
164
+
165
+ ## Contributing
166
+
167
+ Contributions are welcome! Please:
168
+
169
+ 1. Fork the repository
170
+ 2. Create a feature branch (`git checkout -b feat/my-feature`)
171
+ 3. Write tests first (TDD)
172
+ 4. Ensure all tests pass (`bun test`)
173
+ 5. Submit a pull request
174
+
175
+ **Development setup:**
176
+ ```bash
177
+ git clone https://github.com/or1is1/opencode-plugin-apprise.git
178
+ cd opencode-plugin-apprise
179
+ bun install
180
+ pip install apprise
181
+ bun test
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,10 @@
1
+ import type { PluginConfig } from "./types.js";
2
+ /**
3
+ * Load minimal plugin configuration.
4
+ * All behavior handled by Apprise defaults — no environment variables.
5
+ */
6
+ export declare function loadConfig(): PluginConfig;
7
+ /**
8
+ * Validate plugin configuration (no-op — no required fields).
9
+ */
10
+ export declare function validateConfig(_config: PluginConfig): void;
@@ -0,0 +1,6 @@
1
+ import type { NotificationPayload } from "./types.js";
2
+ export interface DedupChecker {
3
+ isDuplicate(payload: NotificationPayload): boolean;
4
+ clear(): void;
5
+ }
6
+ export declare function createDedupChecker(): DedupChecker;
@@ -0,0 +1,8 @@
1
+ import type { FormattedNotification, NotificationPayload } from "./types.js";
2
+ export declare const DEFAULT_TRUNCATE_LENGTH = 1500;
3
+ export declare function truncateText(text: string, maxLength: number): string;
4
+ export declare function formatTodoStatus(todos: Array<{
5
+ status: string;
6
+ content: string;
7
+ }>): string;
8
+ export declare function formatNotification(payload: NotificationPayload, truncateLength?: number): FormattedNotification;
@@ -0,0 +1,4 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import type { DedupChecker } from "../dedup.js";
3
+ import type { PluginConfig } from "../types.js";
4
+ export declare function createBackgroundHook(config: PluginConfig, dedup: DedupChecker): NonNullable<Hooks["event"]>;
@@ -0,0 +1,4 @@
1
+ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
+ import type { DedupChecker } from "../dedup.js";
3
+ import type { PluginConfig } from "../types.js";
4
+ export declare function createIdleHook(ctx: PluginInput, config: PluginConfig, dedup: DedupChecker): NonNullable<Hooks["event"]>;
@@ -0,0 +1,10 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import type { DedupChecker } from "../dedup.js";
3
+ import type { PluginConfig } from "../types.js";
4
+ export interface PermissionHooks {
5
+ /** Primary: permission.ask hook */
6
+ permissionAsk: NonNullable<Hooks["permission.ask"]>;
7
+ /** Fallback: event hook watching permission.updated */
8
+ eventFallback: NonNullable<Hooks["event"]>;
9
+ }
10
+ export declare function createPermissionHooks(config: PluginConfig, dedup: DedupChecker): PermissionHooks;
@@ -0,0 +1,8 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import type { DedupChecker } from "../dedup.js";
3
+ import type { PluginConfig } from "../types.js";
4
+ export interface QuestionHooks {
5
+ before: NonNullable<Hooks["tool.execute.before"]>;
6
+ after: NonNullable<Hooks["tool.execute.after"]>;
7
+ }
8
+ export declare function createQuestionHooks(config: PluginConfig, dedup: DedupChecker, delayMs?: number): QuestionHooks;
@@ -0,0 +1,4 @@
1
+ import type { DedupChecker } from "../dedup.js";
2
+ import type { HookEventType, NotificationContext, NotificationPayload, PluginConfig } from "../types.js";
3
+ export declare function createPayload(type: HookEventType, title: string, context?: Partial<NotificationContext>): NotificationPayload;
4
+ export declare function sendHookNotification(hookName: string, config: PluginConfig, dedup: DedupChecker, payload: NotificationPayload): Promise<void>;
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ declare const plugin: Plugin;
3
+ export default plugin;
@@ -0,0 +1,8 @@
1
+ import type { FormattedNotification, PluginConfig } from "./types.js";
2
+ export interface NotifierResult {
3
+ success: boolean;
4
+ exitCode: number;
5
+ stderr: string;
6
+ }
7
+ export declare function checkAppriseInstalled(): Promise<boolean>;
8
+ export declare function sendNotification(config: PluginConfig, notification: FormattedNotification): Promise<NotifierResult>;
@@ -0,0 +1,421 @@
1
+ // src/config.ts
2
+ function loadConfig() {
3
+ return {
4
+ tag: process.env.OPENCODE_NOTIFY_TAG
5
+ };
6
+ }
7
+ function validateConfig(_config) {}
8
+
9
+ // src/dedup.ts
10
+ function createDedupChecker() {
11
+ const TTL_MS = 5 * 60 * 1000;
12
+ const MAX_SIZE = 100;
13
+ const seen = new Map;
14
+ function hashPayload(payload) {
15
+ const key = `${payload.type}:${payload.title}:${payload.context.userRequest ?? ""}:${payload.context.question ?? ""}`;
16
+ let hash = 5381;
17
+ for (let i = 0;i < key.length; i++) {
18
+ hash = (hash << 5) + hash ^ key.charCodeAt(i);
19
+ hash = hash >>> 0;
20
+ }
21
+ return hash.toString(16);
22
+ }
23
+ function evictExpired() {
24
+ const now = Date.now();
25
+ for (const [hash, ts] of seen) {
26
+ if (now - ts > TTL_MS)
27
+ seen.delete(hash);
28
+ }
29
+ }
30
+ function evictOldestIfFull() {
31
+ if (seen.size >= MAX_SIZE) {
32
+ const firstKey = seen.keys().next().value;
33
+ if (firstKey !== undefined)
34
+ seen.delete(firstKey);
35
+ }
36
+ }
37
+ return {
38
+ isDuplicate(payload) {
39
+ evictExpired();
40
+ const hash = hashPayload(payload);
41
+ if (seen.has(hash))
42
+ return true;
43
+ evictOldestIfFull();
44
+ seen.set(hash, Date.now());
45
+ return false;
46
+ },
47
+ clear() {
48
+ seen.clear();
49
+ }
50
+ };
51
+ }
52
+
53
+ // src/formatter.ts
54
+ var TYPE_MAP = {
55
+ idle: "info",
56
+ question: "warning",
57
+ background: "success",
58
+ permission: "warning"
59
+ };
60
+ var DEFAULT_TRUNCATE_LENGTH = 1500;
61
+ var TRUNCATE_LINE_THRESHOLD = 10;
62
+ var TRUNCATE_HEAD_LINES = 5;
63
+ var TRUNCATE_TAIL_LINES = 5;
64
+ var TRUNCATE_MARKER = `
65
+ ...(truncated)`;
66
+ function truncateText(text, maxLength) {
67
+ if (text.length <= maxLength)
68
+ return text;
69
+ const lines = text.split(`
70
+ `);
71
+ if (lines.length <= TRUNCATE_LINE_THRESHOLD) {
72
+ const keepLength = maxLength - TRUNCATE_MARKER.length;
73
+ return text.slice(0, keepLength) + TRUNCATE_MARKER;
74
+ }
75
+ const head = lines.slice(0, TRUNCATE_HEAD_LINES).join(`
76
+ `);
77
+ const tail = lines.slice(-TRUNCATE_TAIL_LINES).join(`
78
+ `);
79
+ const result = head + TRUNCATE_MARKER + `
80
+ ` + tail;
81
+ if (result.length > maxLength) {
82
+ return text.slice(0, maxLength - TRUNCATE_MARKER.length) + TRUNCATE_MARKER;
83
+ }
84
+ return result;
85
+ }
86
+ function formatTodoStatus(todos) {
87
+ const done = todos.filter((todo) => todo.status === "completed").length;
88
+ const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
89
+ const pending = todos.filter((todo) => todo.status === "pending").length;
90
+ const parts = [];
91
+ if (done > 0)
92
+ parts.push(`✅ ${done} done`);
93
+ if (inProgress > 0)
94
+ parts.push(`▶️ ${inProgress} in_progress`);
95
+ if (pending > 0)
96
+ parts.push(`⚪ ${pending} pending`);
97
+ return parts.length > 0 ? parts.join(" | ") : "No todos";
98
+ }
99
+ function formatNotification(payload, truncateLength = DEFAULT_TRUNCATE_LENGTH) {
100
+ const { type, title, context } = payload;
101
+ const notificationType = TYPE_MAP[type] ?? "info";
102
+ let body;
103
+ switch (type) {
104
+ case "idle": {
105
+ const parts = [];
106
+ if (context.userRequest)
107
+ parts.push(`\uD83D\uDCDD Request: ${context.userRequest}`);
108
+ if (context.agentResponse)
109
+ parts.push(`\uD83E\uDD16 Response: ${context.agentResponse}`);
110
+ if (context.todoStatus)
111
+ parts.push(`\uD83D\uDCCB Todo: ${context.todoStatus}`);
112
+ body = parts.join(`
113
+
114
+ `);
115
+ break;
116
+ }
117
+ case "question": {
118
+ const parts = [];
119
+ if (context.userRequest)
120
+ parts.push(`\uD83D\uDCDD Request: ${context.userRequest}`);
121
+ if (context.question)
122
+ parts.push(`❓ Question: ${context.question}`);
123
+ if (context.options && context.options.length > 0) {
124
+ parts.push(`Options:
125
+ ${context.options.map((option, index) => ` ${index + 1}. ${option}`).join(`
126
+ `)}`);
127
+ }
128
+ body = parts.join(`
129
+
130
+ `);
131
+ break;
132
+ }
133
+ case "background": {
134
+ const parts = [];
135
+ if (context.taskName)
136
+ parts.push(`Task: ${context.taskName}`);
137
+ if (context.agentResponse)
138
+ parts.push(`Result: ${context.agentResponse}`);
139
+ body = parts.join(`
140
+
141
+ `);
142
+ break;
143
+ }
144
+ case "permission": {
145
+ const parts = [];
146
+ if (context.toolName)
147
+ parts.push(`\uD83D\uDD27 Tool: ${context.toolName}`);
148
+ if (context.action)
149
+ parts.push(`⚡ Action: ${context.action}`);
150
+ body = parts.join(`
151
+
152
+ `);
153
+ break;
154
+ }
155
+ default:
156
+ body = "";
157
+ }
158
+ body = truncateText(body, truncateLength);
159
+ return { title, body, notificationType };
160
+ }
161
+
162
+ // src/notifier.ts
163
+ var APPRISE_TIMEOUT_MS = 30000;
164
+ function getErrorMessage(error) {
165
+ if (error instanceof Error) {
166
+ return error.message;
167
+ }
168
+ return String(error);
169
+ }
170
+ async function checkAppriseInstalled() {
171
+ try {
172
+ const proc = Bun.spawn(["apprise", "--version"], {
173
+ timeout: APPRISE_TIMEOUT_MS,
174
+ stderr: "pipe"
175
+ });
176
+ const exitCode = await proc.exited;
177
+ return exitCode === 0;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+ async function sendNotification(config, notification) {
183
+ const args = [
184
+ "apprise",
185
+ "-t",
186
+ notification.title,
187
+ "-b",
188
+ notification.body,
189
+ "--notification-type",
190
+ notification.notificationType
191
+ ];
192
+ if (config.tag) {
193
+ args.push("--tag", config.tag);
194
+ }
195
+ try {
196
+ const proc = Bun.spawn(args, {
197
+ timeout: APPRISE_TIMEOUT_MS,
198
+ stderr: "pipe"
199
+ });
200
+ const exitCode = await proc.exited;
201
+ const stderr = await new Response(proc.stderr).text();
202
+ return {
203
+ success: exitCode === 0,
204
+ exitCode,
205
+ stderr
206
+ };
207
+ } catch (error) {
208
+ return {
209
+ success: false,
210
+ exitCode: -1,
211
+ stderr: getErrorMessage(error)
212
+ };
213
+ }
214
+ }
215
+
216
+ // src/hooks/shared.ts
217
+ var EMPTY_CONTEXT = {
218
+ userRequest: undefined,
219
+ agentResponse: undefined,
220
+ question: undefined,
221
+ options: undefined,
222
+ todoStatus: undefined,
223
+ taskName: undefined,
224
+ toolName: undefined,
225
+ action: undefined
226
+ };
227
+ function createPayload(type, title, context = {}) {
228
+ return {
229
+ type,
230
+ title,
231
+ context: { ...EMPTY_CONTEXT, ...context }
232
+ };
233
+ }
234
+ async function sendHookNotification(hookName, config, dedup, payload) {
235
+ if (dedup.isDuplicate(payload))
236
+ return;
237
+ try {
238
+ const formatted = formatNotification(payload, DEFAULT_TRUNCATE_LENGTH);
239
+ await sendNotification(config, formatted);
240
+ } catch (err) {
241
+ console.warn(`[opencode-plugin-apprise] ${hookName} hook error:`, err);
242
+ }
243
+ }
244
+
245
+ // src/hooks/background.ts
246
+ function createBackgroundHook(config, dedup) {
247
+ return async ({ event }) => {
248
+ if (event.type !== "session.status")
249
+ return;
250
+ const props = event.properties;
251
+ if (props.status.type !== "idle")
252
+ return;
253
+ const payload = createPayload("background", "✅ Background Task Complete", {
254
+ taskName: `Session ${props.sessionID}`
255
+ });
256
+ await sendHookNotification("background", config, dedup, payload);
257
+ };
258
+ }
259
+
260
+ // src/hooks/idle.ts
261
+ function extractText(message) {
262
+ if (!message || typeof message !== "object") {
263
+ return;
264
+ }
265
+ const parts = Array.isArray(message) ? message.map((p) => typeof p === "string" ? p : p.text || "") : [];
266
+ return parts.join(`
267
+ `).trim() || undefined;
268
+ }
269
+ function createIdleHook(ctx, config, dedup) {
270
+ return async ({ event }) => {
271
+ if (event.type !== "session.idle")
272
+ return;
273
+ const props = event.properties;
274
+ if (!props.sessionID)
275
+ return;
276
+ let userRequest = undefined;
277
+ let agentResponse = undefined;
278
+ let todoStatus = undefined;
279
+ try {
280
+ const messagesResponse = await ctx.client.session.messages({
281
+ path: { id: props.sessionID }
282
+ });
283
+ const messages = messagesResponse.data ?? [];
284
+ for (let i = messages.length - 1;i >= 0; i--) {
285
+ const msg = messages[i];
286
+ if (msg?.role === "user") {
287
+ userRequest = extractText(msg.content);
288
+ break;
289
+ }
290
+ }
291
+ if (userRequest) {
292
+ for (let i = messages.length - 1;i >= 0; i--) {
293
+ const msg = messages[i];
294
+ if (msg?.role === "assistant") {
295
+ agentResponse = extractText(msg.content);
296
+ break;
297
+ }
298
+ }
299
+ }
300
+ try {
301
+ const todosResponse = await ctx.client.session.todo({
302
+ path: { id: props.sessionID }
303
+ });
304
+ if (todosResponse.data) {
305
+ todoStatus = formatTodoStatus(todosResponse.data);
306
+ }
307
+ } catch {}
308
+ } catch (err) {
309
+ console.warn("[opencode-plugin-apprise] failed to fetch session data:", err);
310
+ }
311
+ const payload = createPayload("idle", "\uD83D\uDCE2 OpenCode Attention Required", {
312
+ userRequest,
313
+ agentResponse,
314
+ todoStatus
315
+ });
316
+ await sendHookNotification("idle", config, dedup, payload);
317
+ };
318
+ }
319
+
320
+ // src/hooks/permission.ts
321
+ function createPermissionHooks(config, dedup) {
322
+ const notifiedPermissions = new Set;
323
+ async function notifyPermission(permission) {
324
+ const permId = permission.id ?? "unknown";
325
+ if (notifiedPermissions.has(permId))
326
+ return;
327
+ notifiedPermissions.add(permId);
328
+ const toolName = permission.toolName ?? "Unknown Tool";
329
+ const action = permission.action ?? "Unknown Action";
330
+ const payload = createPayload("permission", "\uD83D\uDD10 OpenCode Permission Required", {
331
+ toolName,
332
+ action
333
+ });
334
+ await sendHookNotification("permission", config, dedup, payload);
335
+ }
336
+ const permissionAsk = async (input, _output) => {
337
+ await notifyPermission(input);
338
+ };
339
+ const eventFallback = async ({ event }) => {
340
+ if (event.type !== "permission.updated")
341
+ return;
342
+ const permission = event.properties;
343
+ await notifyPermission(permission);
344
+ };
345
+ return { permissionAsk, eventFallback };
346
+ }
347
+
348
+ // src/hooks/question.ts
349
+ function createQuestionHooks(config, dedup, delayMs = 30000) {
350
+ const timers = new Map;
351
+ const before = async ({ tool, callID }, input) => {
352
+ if (tool.toLowerCase() !== "question")
353
+ return;
354
+ const args = input?.args;
355
+ const question = typeof args?.question === "string" ? args.question : undefined;
356
+ const options = Array.isArray(args?.options) ? args.options.filter((option) => typeof option === "string") : undefined;
357
+ const timer = setTimeout(async () => {
358
+ if (!question)
359
+ return;
360
+ const payload = createPayload("question", "❓ OpenCode Question", {
361
+ question,
362
+ options,
363
+ toolName: "Question"
364
+ });
365
+ await sendHookNotification("question", config, dedup, payload);
366
+ }, delayMs);
367
+ timers.set(callID, timer);
368
+ };
369
+ const after = async ({
370
+ tool,
371
+ callID
372
+ }) => {
373
+ if (tool.toLowerCase() !== "question")
374
+ return;
375
+ const timer = timers.get(callID);
376
+ if (timer) {
377
+ clearTimeout(timer);
378
+ timers.delete(callID);
379
+ }
380
+ };
381
+ return { before, after };
382
+ }
383
+
384
+ // src/index.ts
385
+ var plugin = async (input) => {
386
+ let config;
387
+ try {
388
+ config = loadConfig();
389
+ validateConfig(config);
390
+ } catch (err) {
391
+ console.warn("[opencode-plugin-apprise] Configuration error:", err instanceof Error ? err.message : err);
392
+ console.warn("[opencode-plugin-apprise] Plugin disabled due to configuration error.");
393
+ return {};
394
+ }
395
+ const appriseInstalled = await checkAppriseInstalled();
396
+ if (!appriseInstalled) {
397
+ console.warn("[opencode-plugin-apprise] apprise CLI not found. Install with: pip install apprise");
398
+ console.warn("[opencode-plugin-apprise] Plugin disabled.");
399
+ return {};
400
+ }
401
+ const dedup = createDedupChecker();
402
+ const idleHook = createIdleHook(input, config, dedup);
403
+ const questionHooks = createQuestionHooks(config, dedup);
404
+ const backgroundHook = createBackgroundHook(config, dedup);
405
+ const permissionHooks = createPermissionHooks(config, dedup);
406
+ const combinedEventHook = async ({ event }) => {
407
+ await idleHook({ event });
408
+ await backgroundHook({ event });
409
+ await permissionHooks.eventFallback({ event });
410
+ };
411
+ return {
412
+ event: combinedEventHook,
413
+ "tool.execute.before": questionHooks.before,
414
+ "tool.execute.after": questionHooks.after,
415
+ "permission.ask": permissionHooks.permissionAsk
416
+ };
417
+ };
418
+ var src_default = plugin;
419
+ export {
420
+ src_default as default
421
+ };
@@ -0,0 +1,25 @@
1
+ export interface PluginConfig {
2
+ tag?: string;
3
+ }
4
+ export type HookEventType = "idle" | "question" | "background" | "permission";
5
+ export type AppriseNotificationType = "info" | "warning" | "success" | "failure";
6
+ export interface NotificationContext {
7
+ userRequest: string | undefined;
8
+ agentResponse: string | undefined;
9
+ question: string | undefined;
10
+ options: string[] | undefined;
11
+ todoStatus: string | undefined;
12
+ taskName: string | undefined;
13
+ toolName: string | undefined;
14
+ action: string | undefined;
15
+ }
16
+ export interface NotificationPayload {
17
+ type: HookEventType;
18
+ title: string;
19
+ context: NotificationContext;
20
+ }
21
+ export interface FormattedNotification {
22
+ title: string;
23
+ body: string;
24
+ notificationType: AppriseNotificationType;
25
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "opencode-plugin-apprise",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin that sends rich notifications via Apprise CLI when the agent needs your attention",
5
+ "type": "module",
6
+ "main": "dist/opencode-plugin-apprise.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/opencode-plugin-apprise.js",
12
+ "default": "./dist/opencode-plugin-apprise.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md", "LICENSE"],
16
+ "scripts": {
17
+ "build": "bun build src/index.ts --outfile dist/opencode-plugin-apprise.js --target node --format esm && tsc --emitDeclarationOnly",
18
+ "test": "bun test",
19
+ "prepublishOnly": "bun run build"
20
+ },
21
+ "keywords": ["opencode", "plugin", "apprise", "notification", "slack", "discord", "telegram", "attention"],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/or1is1/opencode-plugin-apprise.git"
27
+ },
28
+ "homepage": "https://github.com/or1is1/opencode-plugin-apprise#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/or1is1/opencode-plugin-apprise/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "peerDependencies": {
36
+ "@opencode-ai/plugin": ">=1.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@opencode-ai/plugin": "^1.2.15",
40
+ "@types/bun": "latest",
41
+ "typescript": "^5.9.3"
42
+ }
43
+ }