@sillybit/opencode-plugin-everynotify 0.1.1 → 0.2.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/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,54 @@ 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.
28
30
 
29
31
  ## Installation
30
32
 
31
33
  Install EveryNotify into your opencode environment using npm:
32
34
 
33
35
  ```bash
34
- npm install opencode-plugin-everynotify
36
+ npm install @sillybit/opencode-plugin-everynotify
35
37
  ```
36
38
 
39
+ ## Using with opencode
40
+
41
+ opencode does not auto-load npm packages; you must **register the plugin** in your opencode config so it is loaded at startup.
42
+
43
+ ### Option 1: Register via config (recommended)
44
+
45
+ Add EveryNotify to the `plugin` array in your opencode config. opencode will install and load it automatically (using Bun) when you run opencode.
46
+
47
+ **Global config** (all projects): edit `~/.config/opencode/opencode.json`:
48
+
49
+ ```json
50
+ {
51
+ "plugin": ["@sillybit/opencode-plugin-everynotify"]
52
+ }
53
+ ```
54
+
55
+ **Project config** (single project): add or edit `opencode.json` in your project root:
56
+
57
+ ```json
58
+ {
59
+ "plugin": ["@sillybit/opencode-plugin-everynotify"]
60
+ }
61
+ ```
62
+
63
+ You can list multiple plugins in the same array, e.g. `["@sillybit/opencode-plugin-everynotify", "opencode-wakatime"]`.
64
+
65
+ ### Option 2: Local plugin directory
66
+
67
+ Alternatively, place the plugin in opencode’s plugin directory so it is loaded as a local plugin:
68
+
69
+ - **Project-only**: `.opencode/plugins/` (e.g. symlink or copy from `node_modules/@sillybit/opencode-plugin-everynotify`)
70
+ - **All projects**: `~/.config/opencode/plugins/`
71
+
72
+ Files in these directories are loaded automatically; no `plugin` entry in opencode config is required for them.
73
+
74
+ After the plugin is loaded, configure your notification services (see [Configuration](#configuration)) and optionally add a `.everynotify.json` for per-project overrides.
75
+
37
76
  ## Configuration
38
77
 
39
78
  EveryNotify utilizes a simple JSON configuration file named `.everynotify.json`. The plugin aggregates configuration from two potential scopes:
@@ -67,15 +106,96 @@ Create your `.everynotify.json` with the tokens for the services you want to use
67
106
  "discord": {
68
107
  "enabled": false,
69
108
  "webhookUrl": "https://discord.com/api/webhooks/0000/XXXX"
109
+ },
110
+ "log": {
111
+ "enabled": true,
112
+ "level": "warn"
113
+ },
114
+ "events": {
115
+ "complete": true,
116
+ "subagent_complete": true,
117
+ "error": true,
118
+ "permission": false,
119
+ "question": true
120
+ }
121
+ }
122
+ ```
123
+
124
+ ## Configuration Options
125
+
126
+ ### Event Filtering
127
+
128
+ 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.
129
+
130
+ | Event | Description |
131
+ | ------------------- | -------------------------------------------------------------------------- |
132
+ | `complete` | The main opencode session has finished its task and is now idle. |
133
+ | `subagent_complete` | A subagent has completed its specific assigned task. |
134
+ | `error` | A fatal error or crash occurred during the session. |
135
+ | `permission` | opencode is waiting for you to grant permission for a tool or file access. |
136
+ | `question` | The `question` tool was used to ask you for clarification. |
137
+
138
+ ### Rich Message Content
139
+
140
+ 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.
141
+
142
+ **Notification Priority:**
143
+
144
+ 1. **Errors**: The specific error message always takes top priority.
145
+ 2. **Assistant Text**: The actual text from the assistant's final response.
146
+ 3. **Fallback**: If no text is found, it defaults to a generic "Task completed" message.
147
+
148
+ **Message Format Example:**
149
+
150
+ ```text
151
+ [complete] my-project
152
+ 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)
153
+ ```
154
+
155
+ ### File-Based Logging
156
+
157
+ EveryNotify includes an optional file-based logging system to help you troubleshoot service delivery or configuration issues without cluttering your terminal.
158
+
159
+ ```json
160
+ {
161
+ "log": {
162
+ "enabled": true,
163
+ "level": "warn"
70
164
  }
71
165
  }
72
166
  ```
73
167
 
168
+ **Options:**
169
+
170
+ - `enabled`: Set to `true` to activate file-based logging (default: `false`).
171
+ - `level`: The minimum severity to log.
172
+ - `"error"`: Only records failed service dispatches and critical system errors.
173
+ - `"warn"`: Records errors plus non-critical warnings like missing configuration files (default).
174
+
175
+ **Log File Details:**
176
+
177
+ - **Location**: `~/.config/opencode/.everynotify.log`
178
+ - **Format**: `[ISO-8601] [LEVEL] [EveryNotify] Message`
179
+ - **Rotation**: Logs are automatically rotated every 7 days (based on file modification time).
180
+ - **Cleanup**: EveryNotify maintains a maximum of 4 rotated files (e.g., `.everynotify.log.2026-02-01`), automatically deleting the oldest.
181
+
182
+ **Example Entry:**
183
+
184
+ ```text
185
+ [2026-02-08T14:30:45.123Z] [ERROR] [EveryNotify] Service dispatch failed: Pushover: Network timeout
186
+ [2026-02-08T14:30:45.456Z] [WARN] [EveryNotify] Config file not found at ~/.config/opencode/.everynotify.json
187
+ ```
188
+
74
189
  ## Usage
75
190
 
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.
191
+ 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.
192
+
193
+ 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.
194
+
195
+ Example:
196
+ `[complete] my-project: I've finished the refactor. (elapsed: 12m 30s)`
77
197
 
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`.
198
+ Notifications are dispatched to all services marked as `"enabled": true`.
79
199
 
80
200
  ## Supported Services
81
201
 
@@ -7,6 +7,7 @@
7
7
  * - Error isolation: One service failing doesn't block others (Promise.allSettled)
8
8
  */
9
9
  import type { EverynotifyConfig, NotificationPayload } from "./types";
10
+ import type { Logger } from "./logger";
10
11
  /**
11
12
  * Truncate text to max length, appending "… [truncated]" if over limit
12
13
  * Shared utility function used by all services
@@ -22,7 +23,8 @@ interface Dispatcher {
22
23
  * Create a dispatcher that sends notifications to all enabled services
23
24
  *
24
25
  * @param config - EverynotifyConfig with enabled services
26
+ * @param logger - Logger instance for error logging
25
27
  * @returns Dispatcher with dispatch() function
26
28
  */
27
- export declare function createDispatcher(config: EverynotifyConfig): Dispatcher;
29
+ export declare function createDispatcher(config: EverynotifyConfig, logger: Logger): Dispatcher;
28
30
  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,6 +24,17 @@ var DEFAULT_CONFIG = {
24
24
  discord: {
25
25
  enabled: false,
26
26
  webhookUrl: ""
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
27
38
  }
28
39
  };
29
40
  function getConfigPath(scope, directory) {
@@ -46,6 +57,12 @@ function deepMerge(target, source) {
46
57
  if (source.discord) {
47
58
  result.discord = { ...result.discord, ...source.discord };
48
59
  }
60
+ if (source.log) {
61
+ result.log = { ...result.log, ...source.log };
62
+ }
63
+ if (source.events) {
64
+ result.events = { ...result.events, ...source.events };
65
+ }
49
66
  return result;
50
67
  }
51
68
  function loadConfigFile(filePath) {
@@ -232,7 +249,7 @@ async function send4(config, payload, signal) {
232
249
  }
233
250
 
234
251
  // src/dispatcher.ts
235
- function createDispatcher(config) {
252
+ function createDispatcher(config, logger) {
236
253
  const services = [];
237
254
  if (config.pushover.enabled) {
238
255
  services.push({
@@ -285,22 +302,109 @@ function createDispatcher(config) {
285
302
  const results = await Promise.allSettled(promises);
286
303
  results.forEach((result, index) => {
287
304
  if (result.status === "rejected") {
305
+ const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
288
306
  console.error(`[EveryNotify] ${services[index].name} failed:`, result.reason);
307
+ logger.error(`${services[index].name} failed: ${errorMsg}`);
289
308
  }
290
309
  });
291
310
  }
292
311
  return { dispatch };
293
312
  }
294
313
 
314
+ // src/logger.ts
315
+ import * as fs2 from "node:fs";
316
+ import * as path2 from "node:path";
317
+ import * as os2 from "node:os";
318
+ function getLogFilePath() {
319
+ return path2.join(os2.homedir(), ".config", "opencode", ".everynotify.log");
320
+ }
321
+ function createLogger(config) {
322
+ if (!config.log.enabled) {
323
+ return { error: () => {}, warn: () => {} };
324
+ }
325
+ const logFilePath = getLogFilePath();
326
+ const logDir = path2.dirname(logFilePath);
327
+ const level = config.log.level || "warn";
328
+ let disabled = false;
329
+ try {
330
+ fs2.mkdirSync(logDir, { recursive: true });
331
+ } catch (error) {
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ console.error(`[EveryNotify] Failed to create log directory: ${message}`);
334
+ disabled = true;
335
+ }
336
+ function writeLog(levelStr, msg) {
337
+ if (disabled)
338
+ return;
339
+ try {
340
+ rotateIfNeeded(logFilePath);
341
+ const timestamp = new Date().toISOString();
342
+ const line = `[${timestamp}] [${levelStr}] [EveryNotify] ${msg}
343
+ `;
344
+ fs2.appendFileSync(logFilePath, line, "utf-8");
345
+ } catch (error) {
346
+ const message = error instanceof Error ? error.message : String(error);
347
+ console.error(`[EveryNotify] Log write failed: ${message}`);
348
+ }
349
+ }
350
+ return {
351
+ error(msg) {
352
+ writeLog("ERROR", msg);
353
+ },
354
+ warn(msg) {
355
+ if (level === "error")
356
+ return;
357
+ writeLog("WARN", msg);
358
+ }
359
+ };
360
+ }
361
+ function rotateIfNeeded(logFilePath) {
362
+ try {
363
+ const stat = fs2.statSync(logFilePath);
364
+ const ageMs = Date.now() - stat.mtime.getTime();
365
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
366
+ if (ageMs > sevenDaysMs) {
367
+ const mtimeDate = stat.mtime.toISOString().split("T")[0];
368
+ const rotatedPath = `${logFilePath}.${mtimeDate}`;
369
+ fs2.renameSync(logFilePath, rotatedPath);
370
+ cleanupRotatedFiles(logFilePath);
371
+ }
372
+ } catch (error) {
373
+ if (error.code !== "ENOENT") {
374
+ const message = error instanceof Error ? error.message : String(error);
375
+ console.error(`[EveryNotify] Rotation check failed: ${message}`);
376
+ }
377
+ }
378
+ }
379
+ function cleanupRotatedFiles(logFilePath) {
380
+ try {
381
+ const dir = path2.dirname(logFilePath);
382
+ const baseName = path2.basename(logFilePath);
383
+ const files = fs2.readdirSync(dir).filter((f) => f.startsWith(`${baseName}.`) && /\d{4}-\d{2}-\d{2}$/.test(f)).sort();
384
+ while (files.length > 4) {
385
+ const oldest = files.shift();
386
+ fs2.unlinkSync(path2.join(dir, oldest));
387
+ }
388
+ } catch (error) {
389
+ const message = error instanceof Error ? error.message : String(error);
390
+ console.error(`[EveryNotify] Cleanup failed: ${message}`);
391
+ }
392
+ }
393
+
295
394
  // src/index.ts
296
395
  var EverynotifyPlugin = async (input) => {
297
396
  const { client, directory } = input;
298
397
  const config = loadConfig(directory);
299
- const { dispatch } = createDispatcher(config);
398
+ const logger = createLogger(config);
399
+ const { dispatch } = createDispatcher(config, logger);
400
+ function isEventEnabled(eventType) {
401
+ return config.events[eventType] !== false;
402
+ }
300
403
  async function buildPayload(eventType, sessionID, extraMessage) {
301
- const projectName = directory ? path2.basename(directory) : null;
404
+ const projectName = directory ? path3.basename(directory) : null;
302
405
  let elapsedSeconds = null;
303
406
  let isSubagent = false;
407
+ let assistantText = null;
304
408
  try {
305
409
  if (sessionID) {
306
410
  const sessionResult = await client.session.get({
@@ -320,6 +424,17 @@ var EverynotifyPlugin = async (input) => {
320
424
  const now = Date.now();
321
425
  elapsedSeconds = Math.floor((now - startTime) / 1000);
322
426
  }
427
+ const lastAssistantMessage = [...messages].reverse().find((msg) => msg.info?.role === "assistant");
428
+ if (lastAssistantMessage?.parts) {
429
+ const textParts = lastAssistantMessage.parts.filter((part) => part.type === "text");
430
+ if (textParts.length > 0) {
431
+ const lastTextPart = textParts[textParts.length - 1];
432
+ const text = lastTextPart.text?.trim();
433
+ if (text) {
434
+ assistantText = text;
435
+ }
436
+ }
437
+ }
323
438
  }
324
439
  }
325
440
  } catch (error) {}
@@ -328,7 +443,7 @@ var EverynotifyPlugin = async (input) => {
328
443
  finalEventType = "subagent_complete";
329
444
  }
330
445
  const title = `[${finalEventType}] ${projectName || "opencode"}`;
331
- let message = extraMessage || "Event occurred";
446
+ let message = extraMessage ?? assistantText ?? "Task completed";
332
447
  if (elapsedSeconds !== null) {
333
448
  const minutes = Math.floor(elapsedSeconds / 60);
334
449
  const seconds = elapsedSeconds % 60;
@@ -348,40 +463,63 @@ var EverynotifyPlugin = async (input) => {
348
463
  try {
349
464
  const sessionID = event.properties?.sessionID || null;
350
465
  if (event.type === "session.idle") {
466
+ if (!isEventEnabled("complete")) {
467
+ return;
468
+ }
351
469
  const payload = await buildPayload("complete", sessionID);
470
+ if (payload.eventType === "subagent_complete" && !isEventEnabled("subagent_complete")) {
471
+ return;
472
+ }
352
473
  await dispatch(payload);
353
474
  } else if (event.type === "session.error") {
475
+ if (!isEventEnabled("error")) {
476
+ return;
477
+ }
354
478
  const rawError = event.properties?.error;
355
479
  const errorMessage = rawError?.data?.message ?? rawError?.name ?? "Unknown error";
356
480
  const payload = await buildPayload("error", sessionID, errorMessage);
357
481
  await dispatch(payload);
358
482
  } else if (event.type === "permission.updated") {
483
+ if (!isEventEnabled("permission")) {
484
+ return;
485
+ }
359
486
  const payload = await buildPayload("permission", sessionID);
360
487
  await dispatch(payload);
361
488
  }
362
489
  } catch (error) {
363
490
  const errorMsg = error instanceof Error ? error.message : String(error);
364
491
  console.error(`[EveryNotify] Event hook error: ${errorMsg}`);
492
+ logger.error(`Event hook error: ${errorMsg}`);
365
493
  }
366
494
  }
367
- async function permissionAskHook(_input, _output) {
495
+ async function permissionAskHook(input2, _output) {
368
496
  try {
369
- const payload = await buildPayload("permission", null);
497
+ if (!isEventEnabled("permission")) {
498
+ return;
499
+ }
500
+ const sessionID = input2?.sessionID ?? null;
501
+ const payload = await buildPayload("permission", sessionID);
370
502
  await dispatch(payload);
371
503
  } catch (error) {
372
504
  const errorMsg = error instanceof Error ? error.message : String(error);
373
505
  console.error(`[EveryNotify] Permission.ask hook error: ${errorMsg}`);
506
+ logger.error(`Permission.ask hook error: ${errorMsg}`);
374
507
  }
375
508
  }
376
509
  async function toolExecuteBeforeHook(input2, _output) {
377
510
  try {
511
+ if (!isEventEnabled("question")) {
512
+ return;
513
+ }
378
514
  if (input2.tool === "question") {
379
- const payload = await buildPayload("question", null);
515
+ const sessionID = input2?.sessionID ?? null;
516
+ const payload = await buildPayload("question", sessionID);
380
517
  await dispatch(payload);
381
518
  }
382
519
  } catch (error) {
383
520
  const errorMsg = error instanceof Error ? error.message : String(error);
384
521
  console.error(`[EveryNotify] Tool.execute.before hook error: ${errorMsg}`);
522
+ logger.error(`Tool.execute.before hook error: ${errorMsg}`);
385
523
  }
386
524
  }
387
525
  return {
@@ -0,0 +1,25 @@
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 type { EverynotifyConfig } from "./types";
8
+ /**
9
+ * Logger interface with error and warn methods
10
+ */
11
+ export interface Logger {
12
+ error(msg: string): void;
13
+ warn(msg: string): void;
14
+ }
15
+ /**
16
+ * Returns the absolute path to the log file
17
+ * @returns Log file path: ~/.config/opencode/.everynotify.log
18
+ */
19
+ export declare function getLogFilePath(): string;
20
+ /**
21
+ * Creates a logger instance based on configuration
22
+ * @param config - EveryNotify configuration object
23
+ * @returns Logger instance (no-op if logging disabled)
24
+ */
25
+ export declare function createLogger(config: EverynotifyConfig): Logger;
package/dist/types.d.ts CHANGED
@@ -62,6 +62,32 @@ export interface DiscordConfig {
62
62
  enabled: boolean;
63
63
  webhookUrl: string;
64
64
  }
65
+ /**
66
+ * Logging configuration
67
+ * Controls debug output and error logging behavior
68
+ * - enabled: whether logging is active
69
+ * - level: minimum log level to output ("error" or "warn")
70
+ */
71
+ export interface LogConfig {
72
+ enabled: boolean;
73
+ level?: "error" | "warn";
74
+ }
75
+ /**
76
+ * Events configuration
77
+ * Controls which event types trigger notifications
78
+ * - complete: main session completion events
79
+ * - subagent_complete: subagent task completion events
80
+ * - error: session error events
81
+ * - permission: permission request events
82
+ * - question: question tool usage events
83
+ */
84
+ export interface EventsConfig {
85
+ complete: boolean;
86
+ subagent_complete: boolean;
87
+ error: boolean;
88
+ permission: boolean;
89
+ question: boolean;
90
+ }
65
91
  /**
66
92
  * Top-level configuration object containing all service configs
67
93
  */
@@ -70,6 +96,8 @@ export interface EverynotifyConfig {
70
96
  telegram: TelegramConfig;
71
97
  slack: SlackConfig;
72
98
  discord: DiscordConfig;
99
+ log: LogConfig;
100
+ events: EventsConfig;
73
101
  }
74
102
  /**
75
103
  * 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.2.0",
4
4
  "description": "Multi-service notification plugin for opencode — Pushover, Telegram, Slack, Discord",
5
5
  "author": "Sillybit <https://sillybit.io>",
6
6
  "repository": {