opencode-plugin-apprise 1.2.0 → 1.2.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/README.md +3 -21
- package/dist/formatter.d.ts +1 -3
- package/dist/hooks/idle.d.ts +1 -1
- package/dist/opencode-plugin-apprise.js +65 -82
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,9 +5,8 @@ OpenCode plugin for multi-service notifications via Apprise.
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- Multi-service support for 128+ notification services via Apprise.
|
|
8
|
-
- Automatic notifications when sessions go idle.
|
|
8
|
+
- Automatic notifications when foreground sessions go idle, with a 30-second grace period (background tasks are excluded).
|
|
9
9
|
- Delayed notifications for Question tool prompts (30-second grace period).
|
|
10
|
-
- Alerts when sessions transition to idle after activity.
|
|
11
10
|
- Notifications for permission requests with dual-mechanism reliability.
|
|
12
11
|
|
|
13
12
|
## Prerequisites
|
|
@@ -65,7 +64,7 @@ For complete configuration options, see: https://github.com/caronc/apprise#confi
|
|
|
65
64
|
|
|
66
65
|
| Setting | Value |
|
|
67
66
|
|---------|:------|
|
|
68
|
-
|
|
|
67
|
+
| Idle notification delay | 30 seconds |
|
|
69
68
|
| Deduplication TTL | 5 minutes (max 100 entries) |
|
|
70
69
|
| Question notification delay | 30 seconds |
|
|
71
70
|
| Apprise CLI timeout | 30 seconds |
|
|
@@ -74,7 +73,7 @@ For complete configuration options, see: https://github.com/caronc/apprise#confi
|
|
|
74
73
|
|
|
75
74
|
### Idle
|
|
76
75
|
|
|
77
|
-
Fires
|
|
76
|
+
Fires 30 seconds after a foreground session goes idle. If the session becomes active again within 30 seconds, the notification is cancelled. Only root sessions (no parent) are notified — background agent sessions are automatically excluded. Includes the last user request, agent response, and todo status.
|
|
78
77
|
|
|
79
78
|
**Severity**: info
|
|
80
79
|
|
|
@@ -100,17 +99,6 @@ Options:
|
|
|
100
99
|
3. cancel
|
|
101
100
|
```
|
|
102
101
|
|
|
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
102
|
### Permission
|
|
115
103
|
|
|
116
104
|
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.
|
|
@@ -136,10 +124,6 @@ For a complete list, see: https://github.com/caronc/apprise#supported-notificati
|
|
|
136
124
|
|
|
137
125
|
## How It Works
|
|
138
126
|
|
|
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
127
|
### Deduplication
|
|
144
128
|
|
|
145
129
|
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.
|
|
@@ -150,7 +134,6 @@ Identical notifications are suppressed for 5 minutes. Duplicates are identified
|
|
|
150
134
|
|-------|:-------------|
|
|
151
135
|
| Idle | info |
|
|
152
136
|
| Question | warning |
|
|
153
|
-
| Background | success |
|
|
154
137
|
| Permission | warning |
|
|
155
138
|
|
|
156
139
|
## Troubleshooting
|
|
@@ -159,7 +142,6 @@ Identical notifications are suppressed for 5 minutes. Duplicates are identified
|
|
|
159
142
|
- **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
143
|
- **Notifications not reaching a specific service**: Set `OPENCODE_NOTIFY_TAG` to match the tag assigned to that service in your Apprise config.
|
|
161
144
|
- **Too many notifications**: Deduplication suppresses identical notifications for 5 minutes.
|
|
162
|
-
- **Notifications cut off**: Messages are truncated at 1,500 characters.
|
|
163
145
|
- **Apprise command hangs**: The CLI timeout is 30 seconds. If Apprise doesn't respond in time, the notification fails silently.
|
|
164
146
|
|
|
165
147
|
## Contributing
|
package/dist/formatter.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
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
2
|
export declare function formatTodoStatus(todos: Array<{
|
|
5
3
|
status: string;
|
|
6
4
|
content: string;
|
|
7
5
|
}>): string;
|
|
8
|
-
export declare function formatNotification(payload: NotificationPayload
|
|
6
|
+
export declare function formatNotification(payload: NotificationPayload): FormattedNotification;
|
package/dist/hooks/idle.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
2
2
|
import type { DedupChecker } from "../dedup.js";
|
|
3
3
|
import type { PluginConfig } from "../types.js";
|
|
4
|
-
export declare function createIdleHook(ctx: PluginInput, config: PluginConfig, dedup: DedupChecker,
|
|
4
|
+
export declare function createIdleHook(ctx: PluginInput, config: PluginConfig, dedup: DedupChecker, delayMs?: number): NonNullable<Hooks["event"]>;
|
|
@@ -56,32 +56,6 @@ var TYPE_MAP = {
|
|
|
56
56
|
question: "warning",
|
|
57
57
|
permission: "warning"
|
|
58
58
|
};
|
|
59
|
-
var DEFAULT_TRUNCATE_LENGTH = 1500;
|
|
60
|
-
var TRUNCATE_LINE_THRESHOLD = 10;
|
|
61
|
-
var TRUNCATE_HEAD_LINES = 5;
|
|
62
|
-
var TRUNCATE_TAIL_LINES = 5;
|
|
63
|
-
var TRUNCATE_MARKER = `
|
|
64
|
-
...(truncated)`;
|
|
65
|
-
function truncateText(text, maxLength) {
|
|
66
|
-
if (text.length <= maxLength)
|
|
67
|
-
return text;
|
|
68
|
-
const lines = text.split(`
|
|
69
|
-
`);
|
|
70
|
-
if (lines.length <= TRUNCATE_LINE_THRESHOLD) {
|
|
71
|
-
const keepLength = maxLength - TRUNCATE_MARKER.length;
|
|
72
|
-
return text.slice(0, keepLength) + TRUNCATE_MARKER;
|
|
73
|
-
}
|
|
74
|
-
const head = lines.slice(0, TRUNCATE_HEAD_LINES).join(`
|
|
75
|
-
`);
|
|
76
|
-
const tail = lines.slice(-TRUNCATE_TAIL_LINES).join(`
|
|
77
|
-
`);
|
|
78
|
-
const result = head + TRUNCATE_MARKER + `
|
|
79
|
-
` + tail;
|
|
80
|
-
if (result.length > maxLength) {
|
|
81
|
-
return text.slice(0, maxLength - TRUNCATE_MARKER.length) + TRUNCATE_MARKER;
|
|
82
|
-
}
|
|
83
|
-
return result;
|
|
84
|
-
}
|
|
85
59
|
function formatTodoStatus(todos) {
|
|
86
60
|
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
87
61
|
const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
|
|
@@ -95,7 +69,7 @@ function formatTodoStatus(todos) {
|
|
|
95
69
|
parts.push(`⚪ ${pending} pending`);
|
|
96
70
|
return parts.length > 0 ? parts.join(" | ") : "No todos";
|
|
97
71
|
}
|
|
98
|
-
function formatNotification(payload
|
|
72
|
+
function formatNotification(payload) {
|
|
99
73
|
const { type, title, context } = payload;
|
|
100
74
|
const notificationType = TYPE_MAP[type] ?? "info";
|
|
101
75
|
let body;
|
|
@@ -143,7 +117,6 @@ ${context.options.map((option, index) => ` ${index + 1}. ${option}`).join(`
|
|
|
143
117
|
default:
|
|
144
118
|
body = "";
|
|
145
119
|
}
|
|
146
|
-
body = truncateText(body, truncateLength);
|
|
147
120
|
return { title, body, notificationType };
|
|
148
121
|
}
|
|
149
122
|
|
|
@@ -222,7 +195,7 @@ async function sendHookNotification(hookName, config, dedup, payload) {
|
|
|
222
195
|
if (dedup.isDuplicate(payload))
|
|
223
196
|
return;
|
|
224
197
|
try {
|
|
225
|
-
const formatted = formatNotification(payload
|
|
198
|
+
const formatted = formatNotification(payload);
|
|
226
199
|
await sendNotification(config, formatted);
|
|
227
200
|
} catch (err) {
|
|
228
201
|
console.warn(`[opencode-plugin-apprise] ${hookName} hook error:`, err);
|
|
@@ -230,66 +203,81 @@ async function sendHookNotification(hookName, config, dedup, payload) {
|
|
|
230
203
|
}
|
|
231
204
|
|
|
232
205
|
// src/hooks/idle.ts
|
|
233
|
-
function extractText(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
const parts = Array.isArray(message) ? message.map((p) => typeof p === "string" ? p : p.text || "") : [];
|
|
238
|
-
return parts.join(`
|
|
206
|
+
function extractText(parts) {
|
|
207
|
+
const texts = parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
|
|
208
|
+
return texts.join(`
|
|
239
209
|
`).trim() || undefined;
|
|
240
210
|
}
|
|
241
|
-
function createIdleHook(ctx, config, dedup,
|
|
211
|
+
function createIdleHook(ctx, config, dedup, delayMs = 30000) {
|
|
212
|
+
const pendingTimers = new Map;
|
|
242
213
|
return async ({ event }) => {
|
|
243
214
|
if (event.type !== "session.status")
|
|
244
215
|
return;
|
|
245
216
|
const props = event.properties;
|
|
246
|
-
|
|
217
|
+
const sessionID = props.sessionID;
|
|
218
|
+
if (!sessionID)
|
|
247
219
|
return;
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
let agentResponse = undefined;
|
|
254
|
-
let todoStatus = undefined;
|
|
255
|
-
try {
|
|
256
|
-
const messagesResponse = await ctx.client.session.messages({
|
|
257
|
-
path: { id: props.sessionID }
|
|
258
|
-
});
|
|
259
|
-
const messages = messagesResponse.data ?? [];
|
|
260
|
-
for (let i = messages.length - 1;i >= 0; i--) {
|
|
261
|
-
const msg = messages[i];
|
|
262
|
-
if (msg?.role === "user") {
|
|
263
|
-
userRequest = extractText(msg.content);
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
220
|
+
if (props.status.type !== "idle") {
|
|
221
|
+
const timer2 = pendingTimers.get(sessionID);
|
|
222
|
+
if (timer2) {
|
|
223
|
+
clearTimeout(timer2);
|
|
224
|
+
pendingTimers.delete(sessionID);
|
|
266
225
|
}
|
|
267
|
-
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const existing = pendingTimers.get(sessionID);
|
|
229
|
+
if (existing) {
|
|
230
|
+
clearTimeout(existing);
|
|
231
|
+
}
|
|
232
|
+
const timer = setTimeout(async () => {
|
|
233
|
+
pendingTimers.delete(sessionID);
|
|
234
|
+
let userRequest = undefined;
|
|
235
|
+
let agentResponse = undefined;
|
|
236
|
+
let todoStatus = undefined;
|
|
237
|
+
try {
|
|
238
|
+
const sessionResponse = await ctx.client.session.get({ path: { id: sessionID } });
|
|
239
|
+
const sessionInfo = sessionResponse.data;
|
|
240
|
+
if (sessionInfo.parentID)
|
|
241
|
+
return;
|
|
242
|
+
const messagesResponse = await ctx.client.session.messages({
|
|
243
|
+
path: { id: sessionID }
|
|
244
|
+
});
|
|
245
|
+
const messages = messagesResponse.data ?? [];
|
|
268
246
|
for (let i = messages.length - 1;i >= 0; i--) {
|
|
269
247
|
const msg = messages[i];
|
|
270
|
-
if (msg?.role === "
|
|
271
|
-
|
|
248
|
+
if (msg?.info?.role === "user") {
|
|
249
|
+
userRequest = extractText(msg.parts);
|
|
272
250
|
break;
|
|
273
251
|
}
|
|
274
252
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
253
|
+
if (userRequest) {
|
|
254
|
+
for (let i = messages.length - 1;i >= 0; i--) {
|
|
255
|
+
const msg = messages[i];
|
|
256
|
+
if (msg?.info?.role === "assistant") {
|
|
257
|
+
agentResponse = extractText(msg.parts);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
282
261
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
262
|
+
try {
|
|
263
|
+
const todosResponse = await ctx.client.session.todo({
|
|
264
|
+
path: { id: sessionID }
|
|
265
|
+
});
|
|
266
|
+
if (todosResponse.data) {
|
|
267
|
+
todoStatus = formatTodoStatus(todosResponse.data);
|
|
268
|
+
}
|
|
269
|
+
} catch {}
|
|
270
|
+
const payload = createPayload("idle", "\uD83D\uDCE2 OpenCode Attention Required", {
|
|
271
|
+
userRequest,
|
|
272
|
+
agentResponse,
|
|
273
|
+
todoStatus
|
|
274
|
+
});
|
|
275
|
+
await sendHookNotification("idle", config, dedup, payload);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.warn("[opencode-plugin-apprise] failed to fetch session data:", err);
|
|
278
|
+
}
|
|
279
|
+
}, delayMs);
|
|
280
|
+
pendingTimers.set(sessionID, timer);
|
|
293
281
|
};
|
|
294
282
|
}
|
|
295
283
|
|
|
@@ -381,8 +369,7 @@ var plugin = async (input) => {
|
|
|
381
369
|
return {};
|
|
382
370
|
}
|
|
383
371
|
const dedup = createDedupChecker();
|
|
384
|
-
const
|
|
385
|
-
const idleHook = createIdleHook(input, config, dedup, interactiveSessions);
|
|
372
|
+
const idleHook = createIdleHook(input, config, dedup);
|
|
386
373
|
const questionHook = createQuestionHook(config, dedup);
|
|
387
374
|
const permissionHooks = createPermissionHooks(config, dedup);
|
|
388
375
|
const combinedEventHook = async ({ event }) => {
|
|
@@ -390,13 +377,9 @@ var plugin = async (input) => {
|
|
|
390
377
|
await permissionHooks.eventFallback({ event });
|
|
391
378
|
await idleHook({ event });
|
|
392
379
|
};
|
|
393
|
-
const chatMessageHook = async (input2) => {
|
|
394
|
-
interactiveSessions.add(input2.sessionID);
|
|
395
|
-
};
|
|
396
380
|
return {
|
|
397
381
|
event: combinedEventHook,
|
|
398
|
-
"permission.ask": permissionHooks.permissionAsk
|
|
399
|
-
"chat.message": chatMessageHook
|
|
382
|
+
"permission.ask": permissionHooks.permissionAsk
|
|
400
383
|
};
|
|
401
384
|
};
|
|
402
385
|
var src_default = plugin;
|
package/package.json
CHANGED