@sillybit/opencode-plugin-everynotify 0.1.1 → 0.3.4

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
@@ -1,6 +1,6 @@
1
1
  # EveryNotify — Multi-Service Notifications for opencode
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/opencode-plugin-everynotify.svg)](https://www.npmjs.com/package/opencode-plugin-everynotify)
3
+ [![npm version](https://img.shields.io/npm/v/@sillybit/opencode-plugin-everynotify.svg)](https://www.npmjs.com/package/@sillybit/opencode-plugin-everynotify)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
6
  EveryNotify is a lightweight, zero-dependency notification plugin for [opencode](https://github.com/opencode-ai/plugin) designed to keep you informed about your AI-driven development sessions. It dispatches notifications to multiple services simultaneously, ensuring you stay updated on task progress, errors, and interaction requests, even when you're not actively monitoring your terminal.
@@ -25,15 +25,55 @@ In long-running development tasks or deep research sessions, it's common to swit
25
25
  - ✅ **Fault Tolerance**: Isolated service calls ensure that a failure in one provider doesn't block others.
26
26
  - ✅ **Zero Runtime Dependencies**: Built entirely on standard Node.js APIs and native `fetch()`.
27
27
  - ✅ **Privacy & Control**: Completely opt-in; no notifications are sent until you enable and configure a service.
28
+ - ✅ **Event Filtering**: Selectively enable or disable notifications for specific event types.
29
+ - ✅ **Rich Content**: Notifications include the actual assistant response text for better context.
30
+ - ✅ **Truncation Direction**: Choose whether to keep the beginning or end of long messages when they exceed service limits.
28
31
 
29
32
  ## Installation
30
33
 
31
34
  Install EveryNotify into your opencode environment using npm:
32
35
 
33
36
  ```bash
34
- npm install opencode-plugin-everynotify
37
+ npm install @sillybit/opencode-plugin-everynotify
35
38
  ```
36
39
 
40
+ ## Using with opencode
41
+
42
+ opencode does not auto-load npm packages; you must **register the plugin** in your opencode config so it is loaded at startup.
43
+
44
+ ### Option 1: Register via config (recommended)
45
+
46
+ Add EveryNotify to the `plugin` array in your opencode config. opencode will install and load it automatically (using Bun) when you run opencode.
47
+
48
+ **Global config** (all projects): edit `~/.config/opencode/opencode.json`:
49
+
50
+ ```json
51
+ {
52
+ "plugin": ["@sillybit/opencode-plugin-everynotify"]
53
+ }
54
+ ```
55
+
56
+ **Project config** (single project): add or edit `opencode.json` in your project root:
57
+
58
+ ```json
59
+ {
60
+ "plugin": ["@sillybit/opencode-plugin-everynotify"]
61
+ }
62
+ ```
63
+
64
+ You can list multiple plugins in the same array, e.g. `["@sillybit/opencode-plugin-everynotify", "opencode-wakatime"]`.
65
+
66
+ ### Option 2: Local plugin directory
67
+
68
+ Alternatively, place the plugin in opencode’s plugin directory so it is loaded as a local plugin:
69
+
70
+ - **Project-only**: `.opencode/plugins/` (e.g. symlink or copy from `node_modules/@sillybit/opencode-plugin-everynotify`)
71
+ - **All projects**: `~/.config/opencode/plugins/`
72
+
73
+ Files in these directories are loaded automatically; no `plugin` entry in opencode config is required for them.
74
+
75
+ After the plugin is loaded, configure your notification services (see [Configuration](#configuration)) and optionally add a `.everynotify.json` for per-project overrides.
76
+
37
77
  ## Configuration
38
78
 
39
79
  EveryNotify utilizes a simple JSON configuration file named `.everynotify.json`. The plugin aggregates configuration from two potential scopes:
@@ -67,15 +107,132 @@ Create your `.everynotify.json` with the tokens for the services you want to use
67
107
  "discord": {
68
108
  "enabled": false,
69
109
  "webhookUrl": "https://discord.com/api/webhooks/0000/XXXX"
110
+ },
111
+ "log": {
112
+ "enabled": true,
113
+ "level": "warn"
114
+ },
115
+ "events": {
116
+ "complete": true,
117
+ "subagent_complete": true,
118
+ "error": true,
119
+ "permission": false,
120
+ "question": true
121
+ },
122
+ "truncateFrom": "end"
123
+ }
124
+ ```
125
+
126
+ ## Configuration Options
127
+
128
+ ### Event Filtering
129
+
130
+ You can control which events trigger notifications by adding an `events` block to your `.everynotify.json`. All events are enabled (`true`) by default to ensure backward compatibility.
131
+
132
+ | Event | Description |
133
+ | ------------------- | -------------------------------------------------------------------------- |
134
+ | `complete` | The main opencode session has finished its task and is now idle. |
135
+ | `subagent_complete` | A subagent has completed its specific assigned task. |
136
+ | `error` | A fatal error or crash occurred during the session. |
137
+ | `permission` | opencode is waiting for you to grant permission for a tool or file access. |
138
+ | `question` | The `question` tool was used to ask you for clarification. |
139
+
140
+ ### Message Truncation
141
+
142
+ Each notification service has a maximum message length (e.g., Pushover: 1024 chars, Discord: 2000 chars). When a message exceeds the limit, EveryNotify truncates it and adds `...` as an indicator.
143
+
144
+ The `truncateFrom` option controls which part of the message is kept:
145
+
146
+ | Value | Behavior | Best For |
147
+ | --------- | ---------------------------------- | --------------------------------------------- |
148
+ | `"end"` | Keep beginning, trim end (default) | When the start of the message has the context |
149
+ | `"start"` | Keep end, trim beginning | When the conclusion/result at the end matters |
150
+
151
+ **Global setting** applies to all services:
152
+
153
+ ```json
154
+ {
155
+ "truncateFrom": "start"
156
+ }
157
+ ```
158
+
159
+ **Per-service override** — for example, keep message endings on Pushover but beginnings on Slack:
160
+
161
+ ```json
162
+ {
163
+ "truncateFrom": "end",
164
+ "pushover": {
165
+ "enabled": true,
166
+ "token": "...",
167
+ "userKey": "...",
168
+ "truncateFrom": "start"
169
+ }
170
+ }
171
+ ```
172
+
173
+ Service-level `truncateFrom` takes priority over the global setting.
174
+
175
+ ### Rich Message Content
176
+
177
+ EveryNotify provides rich context in its notifications by extracting the assistant's last response. Instead of generic "Task completed" messages, you receive the actual summary or answer provided by opencode.
178
+
179
+ **Notification Priority:**
180
+
181
+ 1. **Errors**: The specific error message always takes top priority.
182
+ 2. **Assistant Text**: The actual text from the assistant's final response.
183
+ 3. **Fallback**: If no text is found, it defaults to a generic "Task completed" message.
184
+
185
+ **Message Format Example:**
186
+
187
+ ```text
188
+ [complete] my-project
189
+ I've successfully implemented the authentication system with JWT tokens and refresh token rotation. The API endpoints are secured and tests are passing. (elapsed: 2m 18s)
190
+ ```
191
+
192
+ ### File-Based Logging
193
+
194
+ EveryNotify includes an optional file-based logging system to help you troubleshoot service delivery or configuration issues without cluttering your terminal.
195
+
196
+ ```json
197
+ {
198
+ "log": {
199
+ "enabled": true,
200
+ "level": "warn"
70
201
  }
71
202
  }
72
203
  ```
73
204
 
205
+ **Options:**
206
+
207
+ - `enabled`: Set to `true` to activate file-based logging (default: `false`).
208
+ - `level`: The minimum severity to log.
209
+ - `"error"`: Only records failed service dispatches and critical system errors.
210
+ - `"warn"`: Records errors plus non-critical warnings like missing configuration files (default).
211
+
212
+ **Log File Details:**
213
+
214
+ - **Location**: `~/.config/opencode/.everynotify.log`
215
+ - **Format**: `[ISO-8601] [LEVEL] [EveryNotify] Message`
216
+ - **Rotation**: Logs are automatically rotated every 7 days (based on file modification time).
217
+ - **Cleanup**: EveryNotify maintains a maximum of 4 rotated files (e.g., `.everynotify.log.2026-02-01`), automatically deleting the oldest.
218
+
219
+ **Example Entry:**
220
+
221
+ ```text
222
+ [2026-02-08T14:30:45.123Z] [ERROR] [EveryNotify] Service dispatch failed: Pushover: Network timeout
223
+ [2026-02-08T14:30:45.456Z] [WARN] [EveryNotify] Config file not found at ~/.config/opencode/.everynotify.json
224
+ ```
225
+
74
226
  ## Usage
75
227
 
76
- EveryNotify is a "fire-and-forget" plugin. Once installed and configured, it requires no manual intervention. opencode will automatically detect and load the plugin, which then runs silently in the background.
228
+ EveryNotify is a "fire-and-forget" plugin. Once you have [registered it with opencode](#using-with-opencode) and [configured](#configuration) at least one service with `"enabled": true`, it runs automatically. opencode loads the plugin at startup; EveryNotify then listens for events and sends notifications in the background with no further action needed.
77
229
 
78
- When an event is triggered, EveryNotify builds a descriptive message (e.g., `[complete] my-project (elapsed: 12m 30s)`) and dispatches it to all services marked as `"enabled": true`.
230
+ When an event is triggered, EveryNotify builds a rich message containing the event type, project name, the actual assistant response, and the total elapsed time.
231
+
232
+ Example:
233
+ `[complete] my-project: I've finished the refactor. (elapsed: 12m 30s)`
234
+
235
+ Notifications are dispatched to all services marked as `"enabled": true`.
79
236
 
80
237
  ## Supported Services
81
238
 
@@ -107,6 +264,10 @@ For Pushover users, you can customize the `priority` level:
107
264
  - `1`: High priority (bypasses quiet hours)
108
265
  - `2`: Emergency priority (requires acknowledgment)
109
266
 
267
+ ### Message Truncation Direction
268
+
269
+ See [Message Truncation](#message-truncation) under Configuration Options for details on the `truncateFrom` setting. This is especially useful for services with tight message limits like Pushover (1024 chars), where the end of a message often contains the most important information (the result or conclusion).
270
+
110
271
  ### Session Enrichment
111
272
 
112
273
  EveryNotify automatically calculates the time elapsed since the first user message in a session. This duration is included in the notification text to give you context on how long the task took to complete.
package/dist/config.d.ts CHANGED
@@ -20,10 +20,14 @@ export declare const DEFAULT_CONFIG: EverynotifyConfig;
20
20
  * @returns full path to config file
21
21
  */
22
22
  export declare function getConfigPath(scope: "global" | "project", directory: string): string;
23
+ export interface LoadConfigResult {
24
+ config: EverynotifyConfig;
25
+ warnings: string[];
26
+ }
23
27
  /**
24
28
  * Load configuration from global and project scopes
25
29
  * Merge order: defaults ← global ← project (project wins)
26
30
  * @param directory project directory
27
- * @returns merged EverynotifyConfig
31
+ * @returns config and any warnings encountered during loading
28
32
  */
29
- export declare function loadConfig(directory: string): EverynotifyConfig;
33
+ export declare function loadConfig(directory: string): LoadConfigResult;
@@ -6,12 +6,9 @@
6
6
  * - Timeout: 5s per service call (via AbortController)
7
7
  * - Error isolation: One service failing doesn't block others (Promise.allSettled)
8
8
  */
9
- import type { EverynotifyConfig, NotificationPayload } from "./types";
10
- /**
11
- * Truncate text to max length, appending "… [truncated]" if over limit
12
- * Shared utility function used by all services
13
- */
14
- export declare function truncate(text: string, maxLength: number): string;
9
+ import type { EverynotifyConfig, NotificationPayload, TruncationMode } from "./types";
10
+ import type { Logger } from "./logger";
11
+ export declare function truncate(text: string, maxLength: number, from?: TruncationMode): string;
15
12
  /**
16
13
  * Dispatcher interface returned by createDispatcher
17
14
  */
@@ -22,7 +19,8 @@ interface Dispatcher {
22
19
  * Create a dispatcher that sends notifications to all enabled services
23
20
  *
24
21
  * @param config - EverynotifyConfig with enabled services
22
+ * @param logger - Logger instance for error logging
25
23
  * @returns Dispatcher with dispatch() function
26
24
  */
27
- export declare function createDispatcher(config: EverynotifyConfig): Dispatcher;
25
+ export declare function createDispatcher(config: EverynotifyConfig, logger: Logger): Dispatcher;
28
26
  export {};
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import * as path2 from "path";
2
+ import * as path3 from "path";
3
3
 
4
4
  // src/config.ts
5
5
  import * as fs from "fs";
@@ -24,7 +24,19 @@ var DEFAULT_CONFIG = {
24
24
  discord: {
25
25
  enabled: false,
26
26
  webhookUrl: ""
27
- }
27
+ },
28
+ log: {
29
+ enabled: false,
30
+ level: "warn"
31
+ },
32
+ events: {
33
+ complete: true,
34
+ subagent_complete: true,
35
+ error: true,
36
+ permission: true,
37
+ question: true
38
+ },
39
+ truncateFrom: "end"
28
40
  };
29
41
  function getConfigPath(scope, directory) {
30
42
  if (scope === "global") {
@@ -46,9 +58,18 @@ function deepMerge(target, source) {
46
58
  if (source.discord) {
47
59
  result.discord = { ...result.discord, ...source.discord };
48
60
  }
61
+ if (source.log) {
62
+ result.log = { ...result.log, ...source.log };
63
+ }
64
+ if (source.events) {
65
+ result.events = { ...result.events, ...source.events };
66
+ }
67
+ if (source.truncateFrom !== undefined) {
68
+ result.truncateFrom = source.truncateFrom;
69
+ }
49
70
  return result;
50
71
  }
51
- function loadConfigFile(filePath) {
72
+ function loadConfigFile(filePath, warnings) {
52
73
  try {
53
74
  if (!fs.existsSync(filePath)) {
54
75
  return null;
@@ -58,208 +79,182 @@ function loadConfigFile(filePath) {
58
79
  return parsed;
59
80
  } catch (error) {
60
81
  const errorMsg = error instanceof Error ? error.message : String(error);
61
- console.error(`[EveryNotify] Failed to load config from ${filePath}: ${errorMsg}`);
82
+ warnings.push(`Failed to load config from ${filePath}: ${errorMsg}`);
62
83
  return null;
63
84
  }
64
85
  }
65
86
  function loadConfig(directory) {
87
+ const warnings = [];
66
88
  let config = { ...DEFAULT_CONFIG };
67
89
  const globalPath = getConfigPath("global", directory);
68
- const globalConfig = loadConfigFile(globalPath);
90
+ const globalConfig = loadConfigFile(globalPath, warnings);
69
91
  if (globalConfig) {
70
92
  config = deepMerge(config, globalConfig);
71
93
  }
72
94
  const projectPath = getConfigPath("project", directory);
73
- const projectConfig = loadConfigFile(projectPath);
95
+ const projectConfig = loadConfigFile(projectPath, warnings);
74
96
  if (projectConfig) {
75
97
  config = deepMerge(config, projectConfig);
76
98
  }
77
99
  const allDisabled = !config.pushover.enabled && !config.telegram.enabled && !config.slack.enabled && !config.discord.enabled;
78
100
  if (allDisabled) {
79
- console.error("[EveryNotify] No services configured. Enable services in .everynotify.json");
101
+ warnings.push("No services configured. Enable services in .everynotify.json");
80
102
  }
81
- return config;
103
+ return { config, warnings };
82
104
  }
83
105
 
84
106
  // src/services/pushover.ts
85
- function truncate(text, maxLength) {
86
- if (text.length <= maxLength) {
87
- return text;
88
- }
89
- const suffix = "… [truncated]";
90
- return text.slice(0, maxLength - suffix.length) + suffix;
91
- }
92
107
  async function send(config, payload, signal) {
93
- try {
94
- const body = new URLSearchParams({
95
- token: config.token,
96
- user: config.userKey,
97
- message: truncate(payload.message, 1024),
98
- title: truncate(payload.title, 250),
99
- priority: String(config.priority ?? 0)
100
- });
101
- const response = await fetch("https://api.pushover.net/1/messages.json", {
102
- method: "POST",
103
- headers: {
104
- "Content-Type": "application/x-www-form-urlencoded"
105
- },
106
- body: body.toString(),
107
- signal
108
- });
109
- if (!response.ok) {
110
- const errorText = await response.text();
111
- console.error(`[EveryNotify] Pushover error: ${response.status} ${errorText}`);
112
- return;
113
- }
114
- } catch (error) {
115
- const message = error instanceof Error ? error.message : String(error);
116
- console.error(`[EveryNotify] Pushover failed: ${message}`);
108
+ const body = new URLSearchParams({
109
+ token: config.token,
110
+ user: config.userKey,
111
+ message: truncate(payload.message, 1024, config.truncateFrom),
112
+ title: truncate(payload.title, 250, config.truncateFrom),
113
+ priority: String(config.priority ?? 0)
114
+ });
115
+ const response = await fetch("https://api.pushover.net/1/messages.json", {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/x-www-form-urlencoded"
119
+ },
120
+ body: body.toString(),
121
+ signal
122
+ });
123
+ if (!response.ok) {
124
+ const errorText = await response.text();
125
+ throw new Error(`Pushover API error: ${response.status} ${errorText}`);
117
126
  }
118
127
  }
119
128
 
120
129
  // src/services/telegram.ts
121
- function truncate2(text, maxLength) {
122
- if (text.length <= maxLength) {
123
- return text;
124
- }
125
- const suffix = "… [truncated]";
126
- return text.slice(0, maxLength - suffix.length) + suffix;
127
- }
128
- function formatMessage(payload) {
129
- const truncatedTitle = truncate2(payload.title, 250);
130
- const truncatedMessage = truncate2(payload.message, 3840);
130
+ function formatMessage(payload, truncateFrom) {
131
+ const truncatedTitle = truncate(payload.title, 250, truncateFrom);
132
+ const truncatedMessage = truncate(payload.message, 3840, truncateFrom);
131
133
  return `<b>${truncatedTitle}</b>
132
134
  ${truncatedMessage}`;
133
135
  }
134
136
  async function send2(config, payload, signal) {
135
- try {
136
- const text = formatMessage(payload);
137
- const body = JSON.stringify({
138
- chat_id: config.chatId,
139
- text,
140
- parse_mode: "HTML"
141
- });
142
- const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
143
- method: "POST",
144
- headers: {
145
- "Content-Type": "application/json"
146
- },
147
- body,
148
- signal
149
- });
150
- if (!response.ok) {
151
- const errorText = await response.text();
152
- console.error(`[EveryNotify] Telegram error: ${response.status} ${errorText}`);
153
- return;
154
- }
155
- } catch (error) {
156
- const message = error instanceof Error ? error.message : String(error);
157
- console.error(`[EveryNotify] Telegram failed: ${message}`);
137
+ const text = formatMessage(payload, config.truncateFrom);
138
+ const body = JSON.stringify({
139
+ chat_id: config.chatId,
140
+ text,
141
+ parse_mode: "HTML"
142
+ });
143
+ const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json"
147
+ },
148
+ body,
149
+ signal
150
+ });
151
+ if (!response.ok) {
152
+ const errorText = await response.text();
153
+ throw new Error(`Telegram API error: ${response.status} ${errorText}`);
158
154
  }
159
155
  }
160
156
 
161
157
  // src/services/slack.ts
162
- function truncate3(text, maxLength) {
163
- if (text.length <= maxLength) {
164
- return text;
165
- }
166
- const suffix = "… [truncated]";
167
- return text.slice(0, maxLength - suffix.length) + suffix;
168
- }
169
158
  function formatSlackMessage(payload) {
170
159
  return `*${payload.title}*
171
160
  ${payload.message}`;
172
161
  }
173
162
  async function send3(config, payload, signal) {
174
- try {
175
- const text = truncate3(formatSlackMessage(payload), 40000);
176
- const response = await fetch(config.webhookUrl, {
177
- method: "POST",
178
- headers: {
179
- "Content-Type": "application/json"
180
- },
181
- body: JSON.stringify({ text }),
182
- signal
183
- });
184
- if (!response.ok) {
185
- const errorText = await response.text();
186
- console.error(`[EveryNotify] Slack error: ${response.status} ${errorText}`);
187
- return;
188
- }
189
- } catch (error) {
190
- const message = error instanceof Error ? error.message : String(error);
191
- console.error(`[EveryNotify] Slack failed: ${message}`);
163
+ const text = truncate(formatSlackMessage(payload), 40000, config.truncateFrom);
164
+ const response = await fetch(config.webhookUrl, {
165
+ method: "POST",
166
+ headers: {
167
+ "Content-Type": "application/json"
168
+ },
169
+ body: JSON.stringify({ text }),
170
+ signal
171
+ });
172
+ if (!response.ok) {
173
+ const errorText = await response.text();
174
+ throw new Error(`Slack API error: ${response.status} ${errorText}`);
192
175
  }
193
176
  }
194
177
 
195
178
  // src/services/discord.ts
196
- function truncate4(text, maxLength) {
197
- if (text.length <= maxLength) {
198
- return text;
199
- }
200
- const suffix = "… [truncated]";
201
- return text.slice(0, maxLength - suffix.length) + suffix;
202
- }
203
179
  function formatDiscordMessage(payload) {
204
180
  return `**${payload.title}**
205
181
  ${payload.message}`;
206
182
  }
207
183
  async function send4(config, payload, signal) {
208
- try {
209
- const content = truncate4(formatDiscordMessage(payload), 2000);
210
- const response = await fetch(config.webhookUrl, {
211
- method: "POST",
212
- headers: {
213
- "Content-Type": "application/json"
214
- },
215
- body: JSON.stringify({ content }),
216
- signal
217
- });
218
- if (response.status === 429) {
219
- const retryAfter = response.headers.get("Retry-After");
220
- console.error(`[EveryNotify] Discord rate limited. Retry-After: ${retryAfter}s`);
221
- return;
222
- }
223
- if (!response.ok) {
224
- const errorText = await response.text();
225
- console.error(`[EveryNotify] Discord error: ${response.status} ${errorText}`);
226
- return;
227
- }
228
- } catch (error) {
229
- const message = error instanceof Error ? error.message : String(error);
230
- console.error(`[EveryNotify] Discord failed: ${message}`);
184
+ const content = truncate(formatDiscordMessage(payload), 2000, config.truncateFrom);
185
+ const response = await fetch(config.webhookUrl, {
186
+ method: "POST",
187
+ headers: {
188
+ "Content-Type": "application/json"
189
+ },
190
+ body: JSON.stringify({ content }),
191
+ signal
192
+ });
193
+ if (response.status === 429) {
194
+ const retryAfter = response.headers.get("Retry-After");
195
+ throw new Error(`Discord rate limited. Retry-After: ${retryAfter}s`);
196
+ }
197
+ if (!response.ok) {
198
+ const errorText = await response.text();
199
+ throw new Error(`Discord API error: ${response.status} ${errorText}`);
231
200
  }
232
201
  }
233
202
 
234
203
  // src/dispatcher.ts
235
- function createDispatcher(config) {
204
+ function truncate(text, maxLength, from = "end") {
205
+ if (text.length <= maxLength) {
206
+ return text;
207
+ }
208
+ const indicator = "...";
209
+ if (maxLength <= indicator.length) {
210
+ return indicator;
211
+ }
212
+ if (from === "start") {
213
+ return indicator + text.slice(text.length - (maxLength - indicator.length));
214
+ }
215
+ return text.slice(0, maxLength - indicator.length) + indicator;
216
+ }
217
+ function createDispatcher(config, logger) {
236
218
  const services = [];
219
+ const globalTruncateFrom = config.truncateFrom ?? "end";
237
220
  if (config.pushover.enabled) {
238
221
  services.push({
239
222
  name: "Pushover",
240
223
  send,
241
- config: config.pushover
224
+ config: {
225
+ ...config.pushover,
226
+ truncateFrom: config.pushover.truncateFrom ?? globalTruncateFrom
227
+ }
242
228
  });
243
229
  }
244
230
  if (config.telegram.enabled) {
245
231
  services.push({
246
232
  name: "Telegram",
247
233
  send: send2,
248
- config: config.telegram
234
+ config: {
235
+ ...config.telegram,
236
+ truncateFrom: config.telegram.truncateFrom ?? globalTruncateFrom
237
+ }
249
238
  });
250
239
  }
251
240
  if (config.slack.enabled) {
252
241
  services.push({
253
242
  name: "Slack",
254
243
  send: send3,
255
- config: config.slack
244
+ config: {
245
+ ...config.slack,
246
+ truncateFrom: config.slack.truncateFrom ?? globalTruncateFrom
247
+ }
256
248
  });
257
249
  }
258
250
  if (config.discord.enabled) {
259
251
  services.push({
260
252
  name: "Discord",
261
253
  send: send4,
262
- config: config.discord
254
+ config: {
255
+ ...config.discord,
256
+ truncateFrom: config.discord.truncateFrom ?? globalTruncateFrom
257
+ }
263
258
  });
264
259
  }
265
260
  const lastDispatchTime = new Map;
@@ -285,22 +280,119 @@ function createDispatcher(config) {
285
280
  const results = await Promise.allSettled(promises);
286
281
  results.forEach((result, index) => {
287
282
  if (result.status === "rejected") {
288
- console.error(`[EveryNotify] ${services[index].name} failed:`, result.reason);
283
+ const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
284
+ logger.error(`${services[index].name} failed: ${errorMsg}`);
289
285
  }
290
286
  });
291
287
  }
292
288
  return { dispatch };
293
289
  }
294
290
 
291
+ // src/logger.ts
292
+ import * as fs2 from "node:fs";
293
+ import * as path2 from "node:path";
294
+ import * as os2 from "node:os";
295
+ function getLogFilePath() {
296
+ return path2.join(os2.homedir(), ".config", "opencode", ".everynotify.log");
297
+ }
298
+ function createLogger(config, fsDeps) {
299
+ if (!config.log.enabled) {
300
+ return { error: () => {}, warn: () => {} };
301
+ }
302
+ const _fs = {
303
+ mkdirSync: fsDeps?.mkdirSync ?? fs2.mkdirSync,
304
+ appendFileSync: fsDeps?.appendFileSync ?? fs2.appendFileSync,
305
+ statSync: fsDeps?.statSync ?? fs2.statSync,
306
+ renameSync: fsDeps?.renameSync ?? fs2.renameSync,
307
+ readdirSync: fsDeps?.readdirSync ?? fs2.readdirSync,
308
+ unlinkSync: fsDeps?.unlinkSync ?? fs2.unlinkSync
309
+ };
310
+ const logFilePath = getLogFilePath();
311
+ const logDir = path2.dirname(logFilePath);
312
+ const level = config.log.level || "warn";
313
+ let disabled = false;
314
+ try {
315
+ _fs.mkdirSync(logDir, { recursive: true });
316
+ } catch (error) {
317
+ const message = error instanceof Error ? error.message : String(error);
318
+ console.error(`[EveryNotify] Failed to create log directory: ${message}`);
319
+ disabled = true;
320
+ }
321
+ function rotateIfNeeded() {
322
+ try {
323
+ const stat = _fs.statSync(logFilePath);
324
+ const ageMs = Date.now() - stat.mtime.getTime();
325
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
326
+ if (ageMs > sevenDaysMs) {
327
+ const mtimeDate = stat.mtime.toISOString().split("T")[0];
328
+ const rotatedPath = `${logFilePath}.${mtimeDate}`;
329
+ _fs.renameSync(logFilePath, rotatedPath);
330
+ cleanupRotatedFiles();
331
+ }
332
+ } catch (error) {
333
+ if (error.code !== "ENOENT") {
334
+ const message = error instanceof Error ? error.message : String(error);
335
+ console.error(`[EveryNotify] Rotation check failed: ${message}`);
336
+ }
337
+ }
338
+ }
339
+ function cleanupRotatedFiles() {
340
+ try {
341
+ const dir = path2.dirname(logFilePath);
342
+ const baseName = path2.basename(logFilePath);
343
+ const files = _fs.readdirSync(dir).filter((f) => f.startsWith(`${baseName}.`) && /\d{4}-\d{2}-\d{2}$/.test(f)).sort();
344
+ while (files.length > 4) {
345
+ const oldest = files.shift();
346
+ _fs.unlinkSync(path2.join(dir, oldest));
347
+ }
348
+ } catch (error) {
349
+ const message = error instanceof Error ? error.message : String(error);
350
+ console.error(`[EveryNotify] Cleanup failed: ${message}`);
351
+ }
352
+ }
353
+ function writeLog(levelStr, msg) {
354
+ if (disabled)
355
+ return;
356
+ try {
357
+ rotateIfNeeded();
358
+ const timestamp = new Date().toISOString();
359
+ const line = `[${timestamp}] [${levelStr}] [EveryNotify] ${msg}
360
+ `;
361
+ _fs.appendFileSync(logFilePath, line, "utf-8");
362
+ } catch (error) {
363
+ const message = error instanceof Error ? error.message : String(error);
364
+ console.error(`[EveryNotify] Log write failed: ${message}`);
365
+ }
366
+ }
367
+ return {
368
+ error(msg) {
369
+ writeLog("ERROR", msg);
370
+ },
371
+ warn(msg) {
372
+ if (level === "error")
373
+ return;
374
+ writeLog("WARN", msg);
375
+ }
376
+ };
377
+ }
378
+
295
379
  // src/index.ts
296
380
  var EverynotifyPlugin = async (input) => {
297
381
  const { client, directory } = input;
298
- const config = loadConfig(directory);
299
- const { dispatch } = createDispatcher(config);
382
+ const { config, warnings } = loadConfig(directory);
383
+ const logger = createLogger(config);
384
+ for (const warning of warnings) {
385
+ logger.warn(warning);
386
+ }
387
+ const { dispatch } = createDispatcher(config, logger);
388
+ function isEventEnabled(eventType) {
389
+ return config.events[eventType] !== false;
390
+ }
300
391
  async function buildPayload(eventType, sessionID, extraMessage) {
301
- const projectName = directory ? path2.basename(directory) : null;
392
+ const projectName = directory ? path3.basename(directory) : null;
302
393
  let elapsedSeconds = null;
303
394
  let isSubagent = false;
395
+ let assistantText = null;
304
396
  try {
305
397
  if (sessionID) {
306
398
  const sessionResult = await client.session.get({
@@ -320,6 +412,17 @@ var EverynotifyPlugin = async (input) => {
320
412
  const now = Date.now();
321
413
  elapsedSeconds = Math.floor((now - startTime) / 1000);
322
414
  }
415
+ const lastAssistantMessage = [...messages].reverse().find((msg) => msg.info?.role === "assistant");
416
+ if (lastAssistantMessage?.parts) {
417
+ const textParts = lastAssistantMessage.parts.filter((part) => part.type === "text");
418
+ if (textParts.length > 0) {
419
+ const lastTextPart = textParts[textParts.length - 1];
420
+ const text = lastTextPart.text?.trim();
421
+ if (text) {
422
+ assistantText = text;
423
+ }
424
+ }
425
+ }
323
426
  }
324
427
  }
325
428
  } catch (error) {}
@@ -328,7 +431,7 @@ var EverynotifyPlugin = async (input) => {
328
431
  finalEventType = "subagent_complete";
329
432
  }
330
433
  const title = `[${finalEventType}] ${projectName || "opencode"}`;
331
- let message = extraMessage || "Event occurred";
434
+ let message = extraMessage ?? assistantText ?? "Task completed";
332
435
  if (elapsedSeconds !== null) {
333
436
  const minutes = Math.floor(elapsedSeconds / 60);
334
437
  const seconds = elapsedSeconds % 60;
@@ -348,40 +451,60 @@ var EverynotifyPlugin = async (input) => {
348
451
  try {
349
452
  const sessionID = event.properties?.sessionID || null;
350
453
  if (event.type === "session.idle") {
454
+ if (!isEventEnabled("complete")) {
455
+ return;
456
+ }
351
457
  const payload = await buildPayload("complete", sessionID);
458
+ if (payload.eventType === "subagent_complete" && !isEventEnabled("subagent_complete")) {
459
+ return;
460
+ }
352
461
  await dispatch(payload);
353
462
  } else if (event.type === "session.error") {
463
+ if (!isEventEnabled("error")) {
464
+ return;
465
+ }
354
466
  const rawError = event.properties?.error;
355
467
  const errorMessage = rawError?.data?.message ?? rawError?.name ?? "Unknown error";
356
468
  const payload = await buildPayload("error", sessionID, errorMessage);
357
469
  await dispatch(payload);
358
470
  } else if (event.type === "permission.updated") {
471
+ if (!isEventEnabled("permission")) {
472
+ return;
473
+ }
359
474
  const payload = await buildPayload("permission", sessionID);
360
475
  await dispatch(payload);
361
476
  }
362
477
  } catch (error) {
363
478
  const errorMsg = error instanceof Error ? error.message : String(error);
364
- console.error(`[EveryNotify] Event hook error: ${errorMsg}`);
479
+ logger.error(`Event hook error: ${errorMsg}`);
365
480
  }
366
481
  }
367
- async function permissionAskHook(_input, _output) {
482
+ async function permissionAskHook(input2, _output) {
368
483
  try {
369
- const payload = await buildPayload("permission", null);
484
+ if (!isEventEnabled("permission")) {
485
+ return;
486
+ }
487
+ const sessionID = input2?.sessionID ?? null;
488
+ const payload = await buildPayload("permission", sessionID);
370
489
  await dispatch(payload);
371
490
  } catch (error) {
372
491
  const errorMsg = error instanceof Error ? error.message : String(error);
373
- console.error(`[EveryNotify] Permission.ask hook error: ${errorMsg}`);
492
+ logger.error(`Permission.ask hook error: ${errorMsg}`);
374
493
  }
375
494
  }
376
495
  async function toolExecuteBeforeHook(input2, _output) {
377
496
  try {
497
+ if (!isEventEnabled("question")) {
498
+ return;
499
+ }
378
500
  if (input2.tool === "question") {
379
- const payload = await buildPayload("question", null);
501
+ const sessionID = input2?.sessionID ?? null;
502
+ const payload = await buildPayload("question", sessionID);
380
503
  await dispatch(payload);
381
504
  }
382
505
  } catch (error) {
383
506
  const errorMsg = error instanceof Error ? error.message : String(error);
384
- console.error(`[EveryNotify] Tool.execute.before hook error: ${errorMsg}`);
507
+ logger.error(`Tool.execute.before hook error: ${errorMsg}`);
385
508
  }
386
509
  }
387
510
  return {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * EveryNotify Plugin — File-Based Logger
3
+ *
4
+ * Provides a simple file-based logger with 7-day rotation.
5
+ * Writes to ~/.config/opencode/.everynotify.log with automatic cleanup.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import type { EverynotifyConfig } from "./types";
9
+ export interface FsDeps {
10
+ mkdirSync: typeof fs.mkdirSync;
11
+ appendFileSync: typeof fs.appendFileSync;
12
+ statSync: typeof fs.statSync;
13
+ renameSync: typeof fs.renameSync;
14
+ readdirSync: typeof fs.readdirSync;
15
+ unlinkSync: typeof fs.unlinkSync;
16
+ }
17
+ /**
18
+ * Logger interface with error and warn methods
19
+ */
20
+ export interface Logger {
21
+ error(msg: string): void;
22
+ warn(msg: string): void;
23
+ }
24
+ /**
25
+ * Returns the absolute path to the log file
26
+ * @returns Log file path: ~/.config/opencode/.everynotify.log
27
+ */
28
+ export declare function getLogFilePath(): string;
29
+ /**
30
+ * Creates a logger instance based on configuration
31
+ * @param config - EveryNotify configuration object
32
+ * @param fsDeps - Optional fs operations override for testing
33
+ * @returns Logger instance (no-op if logging disabled)
34
+ */
35
+ export declare function createLogger(config: EverynotifyConfig, fsDeps?: Partial<FsDeps>): Logger;
package/dist/types.d.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  * All types for notification payloads, service configurations, and dispatch functions.
5
5
  * No validation logic here — types only.
6
6
  */
7
+ /**
8
+ * Truncation direction for messages exceeding service limits
9
+ * - "end": Keep beginning, truncate end (default)
10
+ * - "start": Keep end, truncate beginning
11
+ */
12
+ export type TruncationMode = "start" | "end";
7
13
  /**
8
14
  * Event types that trigger notifications
9
15
  */
@@ -32,6 +38,7 @@ export interface PushoverConfig {
32
38
  token: string;
33
39
  userKey: string;
34
40
  priority?: number;
41
+ truncateFrom?: TruncationMode;
35
42
  }
36
43
  /**
37
44
  * Telegram service configuration
@@ -43,6 +50,7 @@ export interface TelegramConfig {
43
50
  enabled: boolean;
44
51
  botToken: string;
45
52
  chatId: string;
53
+ truncateFrom?: TruncationMode;
46
54
  }
47
55
  /**
48
56
  * Slack service configuration
@@ -52,6 +60,7 @@ export interface TelegramConfig {
52
60
  export interface SlackConfig {
53
61
  enabled: boolean;
54
62
  webhookUrl: string;
63
+ truncateFrom?: TruncationMode;
55
64
  }
56
65
  /**
57
66
  * Discord service configuration
@@ -61,6 +70,33 @@ export interface SlackConfig {
61
70
  export interface DiscordConfig {
62
71
  enabled: boolean;
63
72
  webhookUrl: string;
73
+ truncateFrom?: TruncationMode;
74
+ }
75
+ /**
76
+ * Logging configuration
77
+ * Controls debug output and error logging behavior
78
+ * - enabled: whether logging is active
79
+ * - level: minimum log level to output ("error" or "warn")
80
+ */
81
+ export interface LogConfig {
82
+ enabled: boolean;
83
+ level?: "error" | "warn";
84
+ }
85
+ /**
86
+ * Events configuration
87
+ * Controls which event types trigger notifications
88
+ * - complete: main session completion events
89
+ * - subagent_complete: subagent task completion events
90
+ * - error: session error events
91
+ * - permission: permission request events
92
+ * - question: question tool usage events
93
+ */
94
+ export interface EventsConfig {
95
+ complete: boolean;
96
+ subagent_complete: boolean;
97
+ error: boolean;
98
+ permission: boolean;
99
+ question: boolean;
64
100
  }
65
101
  /**
66
102
  * Top-level configuration object containing all service configs
@@ -70,6 +106,9 @@ export interface EverynotifyConfig {
70
106
  telegram: TelegramConfig;
71
107
  slack: SlackConfig;
72
108
  discord: DiscordConfig;
109
+ log: LogConfig;
110
+ events: EventsConfig;
111
+ truncateFrom?: TruncationMode;
73
112
  }
74
113
  /**
75
114
  * Function signature for service send functions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sillybit/opencode-plugin-everynotify",
3
- "version": "0.1.1",
3
+ "version": "0.3.4",
4
4
  "description": "Multi-service notification plugin for opencode — Pushover, Telegram, Slack, Discord",
5
5
  "author": "Sillybit <https://sillybit.io>",
6
6
  "repository": {
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "devDependencies": {
27
27
  "@opencode-ai/plugin": "^1.1.53",
28
+ "@types/bun": "^1.3.8",
28
29
  "@types/node": "^22.0.0",
29
30
  "typescript": "^5.9.3"
30
31
  },