Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3d74fbe
fix(events): migrate to new permission and session event system
ramarivera Jan 3, 2026
e6ab312
feat(logging): add JSONL file logging for debugging
ramarivera Jan 3, 2026
47ef189
fix(logging): remove console.log statements that break in plugin env
ramarivera Jan 3, 2026
b3d4d80
fix(logging): only log when OPENCODE_NOTIFIER_DEBUG=true
ramarivera Jan 3, 2026
d813309
docs(readme): update debugging section for JSONL logging
ramarivera Jan 3, 2026
65bd93d
fix(logging): use dot prefix for log file (.opencode_notifier_logs.js…
ramarivera Jan 3, 2026
645d77a
docs(readme): update log filename to use dot prefix
ramarivera Jan 3, 2026
2273353
refactor(config): deduplicate logging code with helper function
ramarivera Jan 3, 2026
d4e2d77
refactor(logging): centralize all logging in debug-logging.ts
ramarivera Jan 3, 2026
9416534
docs(readme): note that config must be valid JSON without comments
ramarivera Jan 3, 2026
9634dbb
feat(config): re-add JSONC support with enhanced logging
ramarivera Jan 3, 2026
5d65736
fix(build): mark jsonc-parser and node-notifier as external
ramarivera Jan 3, 2026
cd6db2d
feat: add volume control and fix double-sound on error
ramarivera Jan 3, 2026
4467667
feat: add custom notification image support
ramarivera Jan 3, 2026
1b9e471
chore: revert to contentImage for macOS notifications
ramarivera Jan 3, 2026
ce4b12c
test: add comprehensive Jest unit tests
ramarivera Jan 3, 2026
0874802
fix: correct Plugin export signature
ramarivera Jan 3, 2026
efed14b
fix: add missing PluginInput parameter to NotifierPlugin
ramarivera Jan 3, 2026
a23e364
fix: separate production exports from test exports to prevent OpenCod…
ramarivera Jan 3, 2026
768fd4f
fix: bidirectional debouncing for error/idle race conditions (cancell…
ramarivera Jan 3, 2026
97c97af
fix: skip both notifications on cancellation (no notification on cancel)
ramarivera Jan 3, 2026
a700235
fix: use cancellation flag instead of timing for reliable debouncing
ramarivera Jan 3, 2026
a9e282b
feat: add subagent event type with custom notifications
ramarivera Jan 3, 2026
b5f168e
test: add comprehensive subagent detection test coverage
ramarivera Jan 3, 2026
1c7417f
fix: resolve linting errors in subagent detection tests
ramarivera Jan 3, 2026
4188a55
fix(slop): remove AI-generated code smell and align with project stan…
ramarivera Jan 3, 2026
56aa7dc
feat: add message templating with session title and session caching
ramarivera Jan 3, 2026
e78dd6d
fix: restore notification cancellation and add TUI toast alerts for e…
ramarivera Jan 3, 2026
09ca7d9
fix: robust subagent detection and improved cancellation delay
ramarivera Jan 3, 2026
8515bfa
fix(slop): purge AI-generated code smell and refine type safety
ramarivera Jan 3, 2026
f74f16c
fix: prevent TUI crash by removing global session lookup side effects
ramarivera Jan 4, 2026
7142ab4
Revert "fix: prevent TUI crash by removing global session lookup side…
ramarivera Jan 4, 2026
11595ae
fix: restore background session lookup and remove experimental cache
ramarivera Jan 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The plugin works out of the box on all platforms. For best results:

## Configuration

To customize the plugin, create `~/.config/opencode/opencode-notifier.json`:
To customize the plugin, create `~/.config/opencode/opencode-notifier.json`. The file supports standard JSON and JSON with comments (JSONC).

```json
{
Expand Down Expand Up @@ -124,6 +124,47 @@ Use your own sound files:

If a custom sound file path is provided but the file doesn't exist, the plugin will fall back to the bundled sound.

## Debugging

To enable debug logging and see which events are being received:

```bash
export OPENCODE_NOTIFIER_DEBUG=true
opencode
```

This will create `.opencode_notifier_logs.jsonl` in your current directory with detailed logs:
- Plugin initialization with full config
- Every event received
- Each notification/sound triggered with config values

**Example log output**:
```jsonl
{"timestamp":"2026-01-03T19:30:00.000Z","action":"pluginInit","configLoaded":true,"config":{"events":{"permission":{"sound":true,"notification":true},...}}}
{"timestamp":"2026-01-03T19:30:05.000Z","action":"eventReceived","eventType":"session.status",...}
{"timestamp":"2026-01-03T19:30:05.001Z","action":"handleEvent","eventType":"complete","message":"OpenCode has finished","sessionTitle":"My Project",...}
```

**Note**: Logging only occurs when `OPENCODE_NOTIFIER_DEBUG=true`. No log file is created in normal use.

## Technical Notes

### Event System Migration (v0.1.8+)

Version 0.1.8 migrates to OpenCode's new event system:

| Event Type | Old Event (deprecated) | New Event (v0.1.8+) |
|------------|------------------------|---------------------|
| Permission requests | `permission.updated` | `permission.asked` |
| Session completion | `session.idle` | `session.status` (with `type: "idle"`) |
| Errors | `session.error` | `session.error` (unchanged) |

**Why this matters**: The old events (`permission.updated`, `session.idle`) are deprecated in OpenCode core and may not fire reliably. If you're experiencing notification issues, ensure you're using v0.1.8 or later.

**Source verification**: Event structure documented from OpenCode core:
- `permission.asked`: `packages/opencode/src/permission/next.ts`
- `session.status`: `packages/opencode/src/session/status.ts`

## License

MIT
334 changes: 334 additions & 0 deletions __tests__/error-debounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import { createNotifierPlugin, timeProvider } from '../src/plugin';
import type { EventWithProperties } from '../src/plugin';
import type { NotifierConfig } from '../src/config';

// Mock dependencies
jest.mock('../src/notify', () => ({
sendNotification: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../src/sound', () => ({
playSound: jest.fn().mockResolvedValue(undefined),
}));

const mockConfig: NotifierConfig = {
sound: true,
notification: true,
timeout: 5,
volume: 0.5,
events: {
permission: { sound: true, notification: true },
complete: { sound: true, notification: true },
error: { sound: true, notification: true },
subagent: { sound: false, notification: false },
},
messages: {
permission: 'Permission required',
complete: 'Task complete',
error: 'Error occurred',
subagent: 'Subagent complete',
},
sounds: {
permission: null,
complete: null,
error: null,
subagent: null,
},
images: {
permission: null,
complete: null,
error: null,
subagent: null,
},
};

jest.mock('../src/config', () => ({
isEventSoundEnabled: jest.fn((config: NotifierConfig, eventType: string) => config.events[eventType as keyof typeof config.events].sound),
isEventNotificationEnabled: jest.fn((config: NotifierConfig, eventType: string) => config.events[eventType as keyof typeof config.events].notification),
getMessage: jest.fn((config: NotifierConfig, eventType: string) => config.messages[eventType as keyof typeof config.messages]),
getSoundPath: jest.fn(() => null),
getVolume: jest.fn(() => 0.5),
getImagePath: jest.fn(() => null),
RACE_CONDITION_DEBOUNCE_MS: 150,
}));

import { sendNotification } from '../src/notify';
import { playSound } from '../src/sound';

describe('Error + Complete Race Condition', () => {
let mockNow = 0;

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockNow = 0;
timeProvider.now = jest.fn(() => mockNow);
});

afterEach(() => {
jest.useRealTimers();
timeProvider.now = Date.now;
});

it('should trigger error notification when session.error occurs', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

const eventPromise = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});

jest.runAllTimers();
await eventPromise;

expect(sendNotification).toHaveBeenCalledWith('Error occurred', 5, null, 'OpenCode');
expect(playSound).toHaveBeenCalledWith('error', null, 0.5);
});

it('should skip idle notification within 150ms after error', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

// Trigger error at time 0
mockNow = 0;
const errorPromise = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});

jest.runAllTimers();
await errorPromise;

// Clear mocks to track only the next call
jest.clearAllMocks();

// Advance time by 100ms (within debounce window)
mockNow = 100;

// Trigger idle event
const eventPromise = plugin.event({
event: {
type: 'session.status',
properties: {
status: { type: 'idle' },
},
} as EventWithProperties,
});

await jest.advanceTimersByTimeAsync(100);
await eventPromise;

// Should NOT trigger complete notification/sound
expect(sendNotification).not.toHaveBeenCalled();
expect(playSound).not.toHaveBeenCalled();
});

it('should allow idle notification after 150ms debounce window', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

// Trigger error at time 0
mockNow = 0;
const errorPromise = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});

jest.runAllTimers();
await errorPromise;

// Clear mocks
jest.clearAllMocks();

// Advance time by 200ms (outside debounce window)
mockNow = 200;

// Trigger idle event
const eventPromise = plugin.event({
event: {
type: 'session.status',
properties: {
status: { type: 'idle' },
},
} as EventWithProperties,
});

await jest.advanceTimersByTimeAsync(200);
await eventPromise;

// Should trigger complete notification/sound
expect(sendNotification).toHaveBeenCalledWith('Task complete', 5, null, 'OpenCode');
expect(playSound).toHaveBeenCalledWith('complete', null, 0.5);
});

it('should handle multiple errors with debounce reset', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

// First error at time 0
mockNow = 0;
const errorPromise1 = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});
jest.runAllTimers();
await errorPromise1;

// Second error at time 1000 (resets debounce)
mockNow = 1000;
const errorPromise2 = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});
jest.runAllTimers();
await errorPromise2;

jest.clearAllMocks();

// Advance to 1100ms (100ms from second error, within new debounce window)
mockNow = 1100;

// Idle should still be skipped
const eventPromise = plugin.event({
event: {
type: 'session.status',
properties: {
status: { type: 'idle' },
},
} as EventWithProperties,
});

await jest.advanceTimersByTimeAsync(100);
await eventPromise;

expect(sendNotification).not.toHaveBeenCalled();
expect(playSound).not.toHaveBeenCalled();
});

it('should only debounce idle after error, not busy status', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

// Trigger error at time 0
mockNow = 0;
const errorPromise = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});
jest.runAllTimers();
await errorPromise;

jest.clearAllMocks();
mockNow = 500;

// Busy status should not be affected
await plugin.event({
event: {
type: 'session.status',
properties: {
status: { type: 'busy' },
},
} as EventWithProperties,
});

// No notifications should be sent (busy doesn't trigger anything anyway)
expect(sendNotification).not.toHaveBeenCalled();
expect(playSound).not.toHaveBeenCalled();
});

it('should skip error notification within 150ms after idle (cancellation scenario)', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

// Trigger idle at time 0 (user cancels, idle fires first)
mockNow = 0;
const eventPromise = plugin.event({
event: {
type: 'session.status',
properties: {
status: { type: 'idle' },
},
} as EventWithProperties,
});

// Advance slightly but within delay
jest.advanceTimersByTime(20);

// Trigger error event (abort error fires after idle)
mockNow = 100;
const errorPromise = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});

jest.runAllTimers();
await eventPromise;
await errorPromise;

// Should NOT trigger error notification/sound
expect(sendNotification).not.toHaveBeenCalled();
expect(playSound).not.toHaveBeenCalled();
});

it('should allow error notification after 150ms from idle', async () => {
const plugin = await createNotifierPlugin(mockConfig);

if (!plugin.event) throw new Error('event handler not defined');

// Trigger idle at time 0
mockNow = 0;
const eventPromise = plugin.event({
event: {
type: 'session.status',
properties: {
status: { type: 'idle' },
},
} as EventWithProperties,
});

await jest.advanceTimersByTimeAsync(200);
await eventPromise;

// Clear mocks
jest.clearAllMocks();

// Advance time by 200ms (outside debounce window)
mockNow = 400;

// Trigger error event
const errorPromise = plugin.event({
event: {
type: 'session.error',
properties: {},
} as EventWithProperties,
});

jest.runAllTimers();
await errorPromise;

// Should trigger error notification/sound (it's a real new error)
expect(sendNotification).toHaveBeenCalledWith('Error occurred', 5, null, 'OpenCode');
expect(playSound).toHaveBeenCalledWith('error', null, 0.5);
});
});
Loading