opencode-plugin-apprise 1.2.1 → 1.2.3

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 CHANGED
@@ -5,7 +5,7 @@ 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 foreground sessions go idle (background tasks are excluded).
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
10
  - Notifications for permission requests with dual-mechanism reliability.
11
11
 
@@ -64,7 +64,7 @@ For complete configuration options, see: https://github.com/caronc/apprise#confi
64
64
 
65
65
  | Setting | Value |
66
66
  |---------|:------|
67
- | Maximum message length | 1,500 characters |
67
+ | Idle notification delay | 30 seconds |
68
68
  | Deduplication TTL | 5 minutes (max 100 entries) |
69
69
  | Question notification delay | 30 seconds |
70
70
  | Apprise CLI timeout | 30 seconds |
@@ -73,7 +73,7 @@ For complete configuration options, see: https://github.com/caronc/apprise#confi
73
73
 
74
74
  ### Idle
75
75
 
76
- Fires when a foreground session goes idle. Only sessions where the user has sent at least one message are tracked — background agent sessions are excluded. Includes the last user request, agent response, and todo status.
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.
77
77
 
78
78
  **Severity**: info
79
79
 
@@ -124,10 +124,6 @@ For a complete list, see: https://github.com/caronc/apprise#supported-notificati
124
124
 
125
125
  ## How It Works
126
126
 
127
- ### Message Truncation
128
-
129
- 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.
130
-
131
127
  ### Deduplication
132
128
 
133
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.
@@ -146,7 +142,6 @@ Identical notifications are suppressed for 5 minutes. Duplicates are identified
146
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`.
147
143
  - **Notifications not reaching a specific service**: Set `OPENCODE_NOTIFY_TAG` to match the tag assigned to that service in your Apprise config.
148
144
  - **Too many notifications**: Deduplication suppresses identical notifications for 5 minutes.
149
- - **Notifications cut off**: Messages are truncated at 1,500 characters.
150
145
  - **Apprise command hangs**: The CLI timeout is 30 seconds. If Apprise doesn't respond in time, the notification fails silently.
151
146
 
152
147
  ## Contributing
@@ -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, truncateLength?: number): FormattedNotification;
6
+ export declare function formatNotification(payload: NotificationPayload): FormattedNotification;
@@ -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, interactiveSessions: Set<string>): NonNullable<Hooks["event"]>;
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, truncateLength = DEFAULT_TRUNCATE_LENGTH) {
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, DEFAULT_TRUNCATE_LENGTH);
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);
@@ -231,62 +204,89 @@ async function sendHookNotification(hookName, config, dedup, payload) {
231
204
 
232
205
  // src/hooks/idle.ts
233
206
  function extractText(parts) {
234
- const texts = parts.filter((p) => p.type === "text" && p.text).map((p) => p.text);
207
+ const textParts = parts.filter((p) => p.type === "text" && p.text);
208
+ const nonSynthetic = textParts.filter((p) => !p.synthetic);
209
+ const source = nonSynthetic.length > 0 ? nonSynthetic : textParts;
210
+ const texts = source.map((p) => p.text);
235
211
  return texts.join(`
236
212
  `).trim() || undefined;
237
213
  }
238
- function createIdleHook(ctx, config, dedup, interactiveSessions) {
214
+ function isFullySyntheticMessage(parts) {
215
+ const textParts = parts.filter((p) => p.type === "text");
216
+ return textParts.length > 0 && textParts.every((p) => p.synthetic === true);
217
+ }
218
+ function createIdleHook(ctx, config, dedup, delayMs = 30000) {
219
+ const pendingTimers = new Map;
239
220
  return async ({ event }) => {
240
221
  if (event.type !== "session.status")
241
222
  return;
242
223
  const props = event.properties;
243
- if (props.status.type !== "idle")
244
- return;
245
- if (!props.sessionID)
224
+ const sessionID = props.sessionID;
225
+ if (!sessionID)
246
226
  return;
247
- if (!interactiveSessions.has(props.sessionID))
248
- return;
249
- let userRequest = undefined;
250
- let agentResponse = undefined;
251
- let todoStatus = undefined;
252
- try {
253
- const messagesResponse = await ctx.client.session.messages({
254
- path: { id: props.sessionID }
255
- });
256
- const messages = messagesResponse.data ?? [];
257
- for (let i = messages.length - 1;i >= 0; i--) {
258
- const msg = messages[i];
259
- if (msg?.info?.role === "user") {
260
- userRequest = extractText(msg.parts);
261
- break;
262
- }
227
+ if (props.status.type !== "idle") {
228
+ const timer2 = pendingTimers.get(sessionID);
229
+ if (timer2) {
230
+ clearTimeout(timer2);
231
+ pendingTimers.delete(sessionID);
263
232
  }
264
- if (userRequest) {
233
+ return;
234
+ }
235
+ const existing = pendingTimers.get(sessionID);
236
+ if (existing) {
237
+ clearTimeout(existing);
238
+ }
239
+ const timer = setTimeout(async () => {
240
+ pendingTimers.delete(sessionID);
241
+ let userRequest = undefined;
242
+ let agentResponse = undefined;
243
+ let todoStatus = undefined;
244
+ try {
245
+ const sessionResponse = await ctx.client.session.get({ path: { id: sessionID } });
246
+ const sessionInfo = sessionResponse.data;
247
+ if (sessionInfo.parentID)
248
+ return;
249
+ const messagesResponse = await ctx.client.session.messages({
250
+ path: { id: sessionID }
251
+ });
252
+ const messages = messagesResponse.data ?? [];
265
253
  for (let i = messages.length - 1;i >= 0; i--) {
266
254
  const msg = messages[i];
267
- if (msg?.info?.role === "assistant") {
268
- agentResponse = extractText(msg.parts);
255
+ if (msg?.info?.role === "user") {
256
+ if (isFullySyntheticMessage(msg.parts))
257
+ continue;
258
+ userRequest = extractText(msg.parts);
269
259
  break;
270
260
  }
271
261
  }
272
- }
273
- try {
274
- const todosResponse = await ctx.client.session.todo({
275
- path: { id: props.sessionID }
276
- });
277
- if (todosResponse.data) {
278
- todoStatus = formatTodoStatus(todosResponse.data);
262
+ if (userRequest) {
263
+ for (let i = messages.length - 1;i >= 0; i--) {
264
+ const msg = messages[i];
265
+ if (msg?.info?.role === "assistant") {
266
+ agentResponse = extractText(msg.parts);
267
+ break;
268
+ }
269
+ }
279
270
  }
280
- } catch {}
281
- } catch (err) {
282
- console.warn("[opencode-plugin-apprise] failed to fetch session data:", err);
283
- }
284
- const payload = createPayload("idle", "\uD83D\uDCE2 OpenCode Attention Required", {
285
- userRequest,
286
- agentResponse,
287
- todoStatus
288
- });
289
- await sendHookNotification("idle", config, dedup, payload);
271
+ try {
272
+ const todosResponse = await ctx.client.session.todo({
273
+ path: { id: sessionID }
274
+ });
275
+ if (todosResponse.data) {
276
+ todoStatus = formatTodoStatus(todosResponse.data);
277
+ }
278
+ } catch {}
279
+ const payload = createPayload("idle", "\uD83D\uDCE2 OpenCode Attention Required", {
280
+ userRequest,
281
+ agentResponse,
282
+ todoStatus
283
+ });
284
+ await sendHookNotification("idle", config, dedup, payload);
285
+ } catch (err) {
286
+ console.warn("[opencode-plugin-apprise] failed to fetch session data:", err);
287
+ }
288
+ }, delayMs);
289
+ pendingTimers.set(sessionID, timer);
290
290
  };
291
291
  }
292
292
 
@@ -378,8 +378,7 @@ var plugin = async (input) => {
378
378
  return {};
379
379
  }
380
380
  const dedup = createDedupChecker();
381
- const interactiveSessions = new Set;
382
- const idleHook = createIdleHook(input, config, dedup, interactiveSessions);
381
+ const idleHook = createIdleHook(input, config, dedup);
383
382
  const questionHook = createQuestionHook(config, dedup);
384
383
  const permissionHooks = createPermissionHooks(config, dedup);
385
384
  const combinedEventHook = async ({ event }) => {
@@ -387,13 +386,9 @@ var plugin = async (input) => {
387
386
  await permissionHooks.eventFallback({ event });
388
387
  await idleHook({ event });
389
388
  };
390
- const chatMessageHook = async (input2) => {
391
- interactiveSessions.add(input2.sessionID);
392
- };
393
389
  return {
394
390
  event: combinedEventHook,
395
- "permission.ask": permissionHooks.permissionAsk,
396
- "chat.message": chatMessageHook
391
+ "permission.ask": permissionHooks.permissionAsk
397
392
  };
398
393
  };
399
394
  var src_default = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugin-apprise",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "OpenCode plugin that sends rich notifications via Apprise CLI when the agent needs your attention",
5
5
  "type": "module",
6
6
  "main": "dist/opencode-plugin-apprise.js",