Skip to content

Commit 2e6a2cc

Browse files
committed
feat: add SDK v2 support, voice caching, VS Code focus detection, and MCI playback
Introduce four major capabilities in a single coordinated release: - SDK v2 client shape compatibility: auto-detect v1/v2 session.get and tui.showToast call shapes at runtime with transparent fallback, add v2 event types (permission.v2.asked, question.v2.asked, etc.) with immediate flush for v2 asked events, and expand PluginInput/Session types for upstream SDK 1.17.3 surface area. - Voice caching: new enableVoiceCache, voiceCacheDir, and voiceCacheMaxSizeMB config options; LRU-pruned SHA-256-keyed disk cache for OpenAI, ElevenLabs, Edge TTS, and SAPI engines. - VS Code focus detection: recognise VS Code, VS Code Insiders, and VSCodium across Windows, macOS, and Linux via TERM_PROGRAM, xdotool window class, and expanded known-terminal lists. - Windows MCI playback: replace System.Windows.Media.MediaPlayer with winmm.dll mciSendString for loop-count support and direct PowerShell fallback when the OpenCode shell runner is unavailable. Additional improvements: config hot-reload via file-stat signature checking, plugin dispose lifecycle cleanup, and updated README documentation for SDK v2 event surfaces. Add 5 new test suites (performance-regression, upstream-v2-event-types, v2-client-shape-compat, voice-caching, vscode-focus) and update existing tests for MCI playback assertions.
1 parent b28f6f9 commit 2e6a2cc

17 files changed

Lines changed: 2725 additions & 159 deletions

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ A smart voice notification plugin for [OpenCode](https://opencode.ai) with **mul
1818

1919
<img width="1456" height="720" alt="image" src="https://github.com/user-attachments/assets/52ccf357-2548-400b-a346-6362f2fc3180" />
2020

21+
### OpenCode's Built-in Notifications
22+
23+
OpenCode provides basic built-in notifications via its `internal:notifications` interface. These cover simple toast alerts for common events (task completion, errors, permission requests).
24+
25+
**This plugin is for users who want more:** multi-engine TTS voice narration, intelligent delayed reminders with exponential backoff, AI-generated contextual messages, Discord/webhook integration, custom sound themes, focus-aware suppression, and per-project sound customization. If you only need a simple desktop toast, OpenCode's built-in notifications may be sufficient. If you want a fully-featured voice notification experience, this plugin delivers it.
2126

2227
## Features
2328

@@ -45,7 +50,7 @@ The plugin automatically tries multiple TTS engines in order, falling back if on
4550
- Per-notification type delays (permission requests are more urgent)
4651
- **Smart Quota Handling**: Automatically falls back to free Edge TTS if ElevenLabs quota is exceeded
4752
- **Permission Batching**: Multiple simultaneous permission requests are batched into a single notification (e.g., "5 permission requests require your attention")
48-
- **Question Tool Support** (SDK v1.1.7+): Notifies when the agent asks questions and needs user input
53+
- **Question Tool Support** (SDK v2 event): Notifies when the agent asks questions and needs user input
4954

5055
### AI-Generated Messages
5156
- **Dynamic notifications**: Use a local AI to generate unique, contextual messages instead of preset static ones
@@ -395,16 +400,16 @@ Focus detection suppresses sound and desktop notifications when the terminal is
395400
|-------|--------|
396401
| `session.idle` | Agent finished working - notify user |
397402
| `session.error` | Agent encountered an error - alert user |
398-
| `permission.asked` | Permission request (SDK v1.1.1+) - alert user |
399-
| `permission.updated` | Permission request (SDK v1.0.x) - alert user |
403+
| `permission.asked` | Permission request (SDK v2 event) - alert user |
404+
| `permission.updated` | Permission request (SDK v1 event) - alert user |
400405
| `permission.replied` | User responded - cancel pending reminders |
401-
| `question.asked` | Agent asks question (SDK v1.1.7+) - notify user |
406+
| `question.asked` | Agent asks question (SDK v2 event) - notify user |
402407
| `question.replied` | User answered question - cancel pending reminders |
403408
| `question.rejected` | User dismissed question - cancel pending reminders |
404409
| `message.updated` | New user message - cancel pending reminders |
405410
| `session.created` | New session - reset state |
406411

407-
> **Note**: The plugin supports OpenCode SDK v1.0.x, v1.1.x, and v1.1.7+ for backward compatibility.
412+
> **Note**: OpenCode exposes both v1 and v2 SDK event surfaces. This plugin handles both to maintain backward compatibility. `permission.asked` and `question.asked` are v2 SDK events; `permission.updated` is a v1 SDK event. Both are supported regardless of which version `@opencode-ai/plugin` you use (upstream is currently 1.17.3).
408413
409414
## Development
410415

src/index.ts

Lines changed: 189 additions & 49 deletions
Large diffs are not rendered by default.

src/types/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export interface PluginConfig {
8888
openaiTtsFormat: OpenAITtsFormat;
8989
openaiTtsSpeed: number;
9090

91+
// Voice cache
92+
enableVoiceCache: boolean;
93+
voiceCacheDir: string;
94+
voiceCacheMaxSizeMB: number;
95+
9196
// Message pools
9297
idleTTSMessages: string[];
9398
permissionTTSMessages: string[];

src/types/opencode-sdk.ts

Lines changed: 185 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,42 +31,108 @@ export interface Project {
3131
[key: string]: unknown;
3232
}
3333

34+
export type ToastVariant = 'info' | 'success' | 'warning' | 'error';
35+
36+
export interface TUIToastPayload {
37+
message: string;
38+
variant?: ToastVariant;
39+
duration?: number;
40+
title?: string;
41+
directory?: string;
42+
workspace?: string;
43+
}
44+
45+
export interface TUIToastBodyPayload {
46+
body: TUIToastPayload;
47+
}
48+
49+
export type TUIShowToastInput = TUIToastPayload | TUIToastBodyPayload;
50+
3451
export interface TUIClient {
35-
showToast(input: {
36-
body: {
37-
message: string;
38-
variant?: 'info' | 'success' | 'warning' | 'error';
39-
duration?: number;
40-
title?: string;
41-
};
42-
}): Promise<unknown>;
52+
showToast(input?: TUIShowToastInput, ...args: Array<unknown>): Promise<unknown> | unknown;
4353
[key: string]: unknown;
4454
}
4555

4656
export interface Session {
4757
id: string;
58+
slug?: string;
4859
projectID?: string;
60+
workspaceID?: string;
4961
directory?: string;
62+
path?: string;
5063
parentID?: string | null;
5164
title?: string;
65+
agent?: string;
66+
model?: {
67+
id?: string;
68+
providerID?: string;
69+
variant?: string;
70+
[key: string]: unknown;
71+
};
5272
version?: string;
5373
status?: string;
74+
metadata?: Record<string, unknown>;
5475
summary?: {
5576
additions?: number;
5677
deletions?: number;
5778
files?: number;
79+
diffs?: Array<unknown>;
80+
[key: string]: unknown;
81+
};
82+
cost?: number;
83+
tokens?: {
84+
input?: number;
85+
output?: number;
86+
reasoning?: number;
87+
cache?: {
88+
read?: number;
89+
write?: number;
90+
};
91+
[key: string]: unknown;
92+
};
93+
share?: {
94+
url?: string;
95+
[key: string]: unknown;
96+
};
97+
permission?: Array<unknown>;
98+
revert?: {
99+
messageID?: string;
100+
partID?: string;
101+
snapshot?: string;
102+
diff?: string;
58103
[key: string]: unknown;
59104
};
60105
time?: {
61106
created?: number;
62107
updated?: number;
63108
compacting?: number;
109+
archived?: number;
64110
};
65111
[key: string]: unknown;
66112
}
67113

114+
export interface SessionGetV1Input {
115+
path: {
116+
id: string;
117+
};
118+
query?: {
119+
directory?: string;
120+
workspace?: string;
121+
[key: string]: unknown;
122+
};
123+
}
124+
125+
export interface SessionGetV2Input {
126+
sessionID: string;
127+
directory?: string;
128+
workspace?: string;
129+
}
130+
131+
export type SessionGetInput = SessionGetV1Input | SessionGetV2Input;
132+
export type SessionGetResult = { data?: Session | null } | Session | null | undefined;
133+
68134
export interface SessionClient {
69-
get(input: { path: { id: string } }): Promise<{ data?: Session }>;
135+
get(input: SessionGetInput, ...args: Array<unknown>): Promise<SessionGetResult> | SessionGetResult;
70136
[key: string]: unknown;
71137
}
72138

@@ -138,19 +204,63 @@ export interface OpenCodeClient {
138204
[key: string]: unknown;
139205
}
140206

141-
export interface PluginInitParams {
207+
export interface WorkspaceInfo {
208+
id: string;
209+
type: string;
210+
name: string;
211+
branch: string | null;
212+
directory: string | null;
213+
extra: unknown | null;
214+
projectID: string;
215+
}
216+
217+
export type WorkspaceTarget =
218+
| {
219+
type: 'local';
220+
directory: string;
221+
}
222+
| {
223+
type: 'remote';
224+
url: string | URL;
225+
headers?: Record<string, string>;
226+
};
227+
228+
export interface WorkspaceAdapter {
229+
name: string;
230+
description: string;
231+
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>;
232+
create(config: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>;
233+
remove(config: WorkspaceInfo): Promise<void>;
234+
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>;
235+
}
236+
237+
export interface ExperimentalWorkspaceRegistry {
238+
register(type: string, adapter: WorkspaceAdapter): void;
239+
}
240+
241+
export interface PluginInput {
142242
project: Project;
143243
client: OpenCodeClient;
144244
$: ShellRunner;
145245
directory: string;
146246
worktree: string;
147247
serverUrl?: URL;
248+
experimental_workspace?: ExperimentalWorkspaceRegistry;
148249
}
149250

251+
export type PluginInitParams = PluginInput;
252+
150253
export type EventType =
151254
| 'server.instance.disposed'
255+
| 'global.disposed'
256+
| 'models-dev.refreshed'
257+
| 'plugin.added'
258+
| 'catalog.model.updated'
152259
| 'installation.updated'
153260
| 'installation.update-available'
261+
| 'account.added'
262+
| 'account.removed'
263+
| 'account.switched'
154264
| 'lsp.client.diagnostics'
155265
| 'lsp.updated'
156266
| 'message.updated'
@@ -160,10 +270,15 @@ export type EventType =
160270
| 'message.part.removed'
161271
| 'permission.updated'
162272
| 'permission.asked'
273+
| 'permission.v2.asked'
163274
| 'permission.replied'
275+
| 'permission.v2.replied'
164276
| 'question.asked'
277+
| 'question.v2.asked'
165278
| 'question.replied'
279+
| 'question.v2.replied'
166280
| 'question.rejected'
281+
| 'question.v2.rejected'
167282
| 'session.status'
168283
| 'session.idle'
169284
| 'session.compacted'
@@ -172,14 +287,52 @@ export type EventType =
172287
| 'session.deleted'
173288
| 'session.diff'
174289
| 'session.error'
290+
| 'session.next.agent.switched'
291+
| 'session.next.model.switched'
292+
| 'session.next.moved'
293+
| 'session.next.prompted'
294+
| 'session.next.prompt.admitted'
295+
| 'session.next.prompt.promoted'
296+
| 'session.next.interrupt.requested'
297+
| 'session.next.context.updated'
298+
| 'session.next.synthetic'
299+
| 'session.next.shell.started'
300+
| 'session.next.shell.ended'
301+
| 'session.next.step.started'
302+
| 'session.next.step.ended'
303+
| 'session.next.step.failed'
304+
| 'session.next.text.started'
305+
| 'session.next.text.ended'
306+
| 'session.next.reasoning.started'
307+
| 'session.next.reasoning.ended'
308+
| 'session.next.tool.input.started'
309+
| 'session.next.tool.input.ended'
310+
| 'session.next.tool.called'
311+
| 'session.next.tool.progress'
312+
| 'session.next.tool.success'
313+
| 'session.next.tool.failed'
314+
| 'session.next.retried'
315+
| 'session.next.compaction.started'
316+
| 'session.next.compaction.ended'
175317
| 'todo.updated'
176318
| 'command.executed'
177319
| 'file.edited'
178320
| 'file.watcher.updated'
321+
| 'reference.updated'
322+
| 'project.directories.updated'
323+
| 'project.updated'
179324
| 'vcs.branch.updated'
325+
| 'worktree.ready'
326+
| 'worktree.failed'
327+
| 'workspace.ready'
328+
| 'workspace.failed'
329+
| 'workspace.status'
180330
| 'tui.prompt.append'
181331
| 'tui.command.execute'
182332
| 'tui.toast.show'
333+
| 'tui.session.select'
334+
| 'mcp.tools.changed'
335+
| 'mcp.browser.open.failed'
183336
| 'pty.created'
184337
| 'pty.updated'
185338
| 'pty.exited'
@@ -194,14 +347,34 @@ export interface EventProperties {
194347
partID?: string;
195348
permissionID?: string;
196349
requestID?: string;
350+
callID?: string;
197351
id?: string;
198352
info?: unknown;
199353
error?: unknown;
200354
response?: string;
201355
reply?: string;
202356
status?: unknown;
357+
action?: string;
358+
resources?: Array<string>;
359+
save?: Array<string> | boolean;
360+
metadata?: Record<string, unknown>;
361+
source?: string;
362+
permission?: string;
363+
patterns?: Array<string>;
364+
always?: Array<string>;
203365
questions?: Array<unknown>;
204-
answers?: Array<Array<string>>;
366+
answers?: Array<Array<string>> | Array<unknown>;
367+
tool?: unknown;
368+
file?: string;
369+
event?: string;
370+
command?: string;
371+
arguments?: string;
372+
version?: string;
373+
branch?: string;
374+
title?: string;
375+
message?: string;
376+
variant?: ToastVariant;
377+
duration?: number;
205378
[key: string]: unknown;
206379
}
207380

@@ -212,5 +385,6 @@ export interface PluginEvent {
212385

213386
export interface PluginHandlers {
214387
event?: (input: { event: PluginEvent }) => Promise<void>;
388+
dispose?: () => void | Promise<void>;
215389
[key: string]: unknown;
216390
}

src/util/config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export const getDefaultConfigObject = (): PluginConfig => ({
164164
openaiTtsVoice: 'alloy',
165165
openaiTtsFormat: 'mp3',
166166
openaiTtsSpeed: 1.0,
167+
enableVoiceCache: false,
168+
voiceCacheDir: 'voice-cache',
169+
voiceCacheMaxSizeMB: 100,
167170
idleTTSMessages: [
168171
'All done! Your task has been completed successfully.',
169172
'Hey there! I finished working on your request.',
@@ -559,6 +562,21 @@ const generateDefaultConfig = (overrides: Partial<PluginConfig> = {}, version =
559562
// Speech speed: 0.25 to 4.0 (1.0 = normal)
560563
"openaiTtsSpeed": ${overrides.openaiTtsSpeed !== undefined ? overrides.openaiTtsSpeed : 1.0},
561564
565+
// ============================================================
566+
// VOICE CACHE SETTINGS (Optional local TTS audio cache)
567+
// ============================================================
568+
// Cache generated TTS audio locally so repeated messages can replay
569+
// without calling cloud/self-hosted TTS services again.
570+
// Disabled by default to preserve existing behavior and disk usage.
571+
"enableVoiceCache": ${overrides.enableVoiceCache !== undefined ? overrides.enableVoiceCache : false},
572+
573+
// Directory for cached voice audio. Relative paths are resolved from
574+
// ~/.config/opencode/; absolute paths are used as-is.
575+
"voiceCacheDir": ${JSON.stringify(overrides.voiceCacheDir !== undefined ? overrides.voiceCacheDir : 'voice-cache')},
576+
577+
// Maximum cache size in megabytes. Oldest cached audio is removed first.
578+
"voiceCacheMaxSizeMB": ${overrides.voiceCacheMaxSizeMB !== undefined ? overrides.voiceCacheMaxSizeMB : 100},
579+
562580
// ============================================================
563581
// INITIAL TTS MESSAGES (Used immediately or after sound)
564582
// These are randomly selected each time for variety

0 commit comments

Comments
 (0)