@ulthon/ul-opencode-event 0.1.34 → 0.1.35

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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * MINIMAL test entry point — zero side effects, zero static imports.
3
+ * Used to isolate whether the crash is caused by our plugin's code
4
+ * or by something else (npm install, module loading, etc.).
5
+ *
6
+ * If this minimal version STILL crashes OpenCode, the problem is NOT
7
+ * our plugin code — it's npm install side effects or an OpenCode bug.
8
+ *
9
+ * If this minimal version works, the problem is in our static import
10
+ * chain (logger.js, config.js, handler.js, etc.) and we can add them
11
+ * back one by one to find the culprit.
12
+ */
13
+ declare const MinimalPlugin: () => Promise<{
14
+ event: () => Promise<void>;
15
+ }>;
16
+ export default MinimalPlugin;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * MINIMAL test entry point — zero side effects, zero static imports.
3
+ * Used to isolate whether the crash is caused by our plugin's code
4
+ * or by something else (npm install, module loading, etc.).
5
+ *
6
+ * If this minimal version STILL crashes OpenCode, the problem is NOT
7
+ * our plugin code — it's npm install side effects or an OpenCode bug.
8
+ *
9
+ * If this minimal version works, the problem is in our static import
10
+ * chain (logger.js, config.js, handler.js, etc.) and we can add them
11
+ * back one by one to find the culprit.
12
+ */
13
+ const MinimalPlugin = async () => {
14
+ return {
15
+ event: async () => {
16
+ // no-op
17
+ },
18
+ };
19
+ };
20
+ export default MinimalPlugin;
package/dist/index.d.ts CHANGED
@@ -1,36 +1,9 @@
1
1
  /**
2
- * Notification Plugin Entry Point
3
- * Sends email notifications when session events occur
4
- */
5
- import type { Plugin } from '@opencode-ai/plugin';
6
- import type { EventPayload } from './types.js';
7
- /**
8
- * Notification Plugin for OpenCode
9
- * Listens for session events and sends notifications via configured channels
10
- */
11
- export declare const NotificationPlugin: Plugin;
12
- /**
13
- * Default export: the plugin function itself.
14
- * This matches the pattern used by all working OpenCode plugins
15
- * (opencode-claude-auth, opencode-dcp, opencode-copilot-plugin).
16
- * OpenCode's readV1Plugin() in detect mode returns undefined for function defaults,
17
- * falling through to getLegacyPlugins() which iterates all module exports.
18
- */
19
- export default NotificationPlugin;
20
- /**
21
- * Test-only: reset module-level flags so toast tests can run independently.
22
- */
23
- declare function __test_resetFlags(): void;
24
- /**
25
- * Test-only helpers namespace.
26
- * Includes a `server` property pointing to NotificationPlugin so that
27
- * OpenCode's getServerPlugin() correctly identifies it (returns the server fn)
28
- * instead of returning undefined and causing getLegacyPlugins to throw.
29
- * The `seen` Set in getLegacyPlugins deduplicates, so NotificationPlugin
30
- * is only called once despite appearing in both `default` and `__test.server`.
31
- */
32
- export declare const __test: {
33
- payloads: EventPayload[];
34
- resetFlags: typeof __test_resetFlags;
35
- server: Plugin;
36
- };
2
+ * MINIMAL test entry point — zero side effects, zero static imports.
3
+ * Testing whether the OpenCode crash is caused by our plugin's code
4
+ * or by npm install side effects / an OpenCode bug.
5
+ */
6
+ declare const MinimalPlugin: () => Promise<{
7
+ event: () => Promise<void>;
8
+ }>;
9
+ export default MinimalPlugin;
package/dist/index.js CHANGED
@@ -1,588 +1,13 @@
1
- import * as path from 'path';
2
- import { loadConfig } from './config.js';
3
- import { createEventHandler } from './handler.js';
4
- import { getDefaultTimezone } from './locales.js';
5
- import { DEFAULT_SESSION_TYPES } from './types.js';
6
- import { logger, DEBUG_ENABLED } from './logger.js';
7
- import { runAutoUpdate, getCurrentVersion } from './updater.js';
8
- // cost.js imported lazily inside NotificationPlugin to follow lazy-import convention
9
1
  /**
10
- * Resolve session type from SDK Session info.
11
- * - undefined/missing sessionInfo 'unknown' (race condition or file.edited)
12
- * - has parentID 'subagent' (child of main session)
13
- * - no parentID → 'main' (primary user session)
2
+ * MINIMAL test entry point zero side effects, zero static imports.
3
+ * Testing whether the OpenCode crash is caused by our plugin's code
4
+ * or by npm install side effects / an OpenCode bug.
14
5
  */
15
- function resolveSessionType(sessionInfo) {
16
- if (!sessionInfo)
17
- return 'unknown';
18
- if (sessionInfo.parentID !== undefined && sessionInfo.parentID !== '')
19
- return 'subagent';
20
- return 'main';
21
- }
22
- /**
23
- * Aggregate token data for a session from its SessionTokenData.
24
- * Sums cost and all token fields across messages (each message.id counted once).
25
- */
26
- function aggregateSessionTokens(sessionData) {
27
- let cost = 0;
28
- let input = 0;
29
- let output = 0;
30
- let reasoning = 0;
31
- let cacheRead = 0;
32
- let cacheWrite = 0;
33
- for (const td of sessionData.messages.values()) {
34
- cost += td.cost;
35
- input += td.tokens.input;
36
- output += td.tokens.output;
37
- reasoning += td.tokens.reasoning;
38
- cacheRead += td.tokens.cacheRead;
39
- cacheWrite += td.tokens.cacheWrite;
40
- }
41
- return {
42
- cost,
43
- tokens: {
44
- input,
45
- output,
46
- reasoning,
47
- cacheRead,
48
- cacheWrite,
49
- total: input + output + reasoning + cacheRead + cacheWrite,
50
- },
51
- };
52
- }
53
- /**
54
- * Convert SDK Event to internal EventPayload
55
- */
56
- function eventToPayload(event, projectName, sessionInfo, timezone, projectPath, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn) {
57
- const basePayload = {
58
- timestamp: formatTimestamp(new Date().toISOString(), timezone),
59
- projectName,
60
- projectPath,
61
- sessionId: '',
62
- sessionTitle: undefined,
63
- };
64
- switch (event.type) {
65
- case 'session.created':
66
- return {
67
- ...basePayload,
68
- eventType: 'created',
69
- sessionId: event.properties.info.id,
70
- sessionTitle: event.properties.info.title,
71
- sessionType: resolveSessionType(event.properties.info),
72
- parentSessionId: event.properties.info.parentID ?? undefined,
73
- };
74
- case 'session.idle': {
75
- const idlePayload = {
76
- ...basePayload,
77
- eventType: 'idle',
78
- sessionId: event.properties.sessionID,
79
- message: 'Task completed successfully',
80
- duration: sessionInfo ? formatDuration(Date.now() - sessionInfo.time.created) : undefined,
81
- sessionTitle: sessionInfo?.title,
82
- sessionType: resolveSessionType(sessionInfo),
83
- parentSessionId: sessionInfo?.parentID ?? undefined,
84
- };
85
- fillTokenData(idlePayload, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
86
- return idlePayload;
87
- }
88
- case 'session.error': {
89
- const errorObj = event.properties.error;
90
- const sessionId = event.properties.sessionID;
91
- const errorPayload = {
92
- ...basePayload,
93
- eventType: 'error',
94
- sessionId: sessionId ?? '',
95
- error: errorObj ? formatError(errorObj) : 'Unknown error',
96
- sessionTitle: sessionInfo?.title,
97
- sessionType: resolveSessionType(sessionInfo),
98
- parentSessionId: sessionInfo?.parentID ?? undefined,
99
- };
100
- fillTokenData(errorPayload, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
101
- return errorPayload;
102
- }
103
- default: {
104
- // 处理 SDK Event 联合类型中未声明、但运行时可能收到的事件
105
- const rawType = event.type;
106
- const rawProps = event.properties;
107
- // question.asked -- AI 向用户提问(主要目标,默认开启)
108
- if (rawType === 'question.asked') {
109
- const questions = rawProps.questions;
110
- const firstQ = questions?.[0];
111
- const p = {
112
- ...basePayload,
113
- eventType: 'question',
114
- sessionId: rawProps.sessionID || '',
115
- title: firstQ?.header ? String(firstQ.header) : undefined,
116
- questionText: firstQ?.question ? String(firstQ.question) : undefined,
117
- options: firstQ?.options ? JSON.stringify(firstQ.options) : undefined,
118
- sessionTitle: sessionInfo?.title,
119
- sessionType: resolveSessionType(sessionInfo),
120
- parentSessionId: sessionInfo?.parentID ?? undefined,
121
- };
122
- fillTokenData(p, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
123
- return p;
124
- }
125
- // permission.updated -- 权限确认请求(默认开启)
126
- if (rawType === 'permission.updated') {
127
- const perm = rawProps;
128
- const p = {
129
- ...basePayload,
130
- eventType: 'permission',
131
- sessionId: perm.sessionID || '',
132
- title: perm.title,
133
- permissionType: perm.type,
134
- sessionTitle: sessionInfo?.title,
135
- sessionType: resolveSessionType(sessionInfo),
136
- parentSessionId: sessionInfo?.parentID ?? undefined,
137
- };
138
- fillTokenData(p, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
139
- return p;
140
- }
141
- // session.status -- 会话状态变化(默认关闭)
142
- if (rawType === 'session.status') {
143
- const statusEvent = rawProps;
144
- const p = {
145
- ...basePayload,
146
- eventType: 'status',
147
- sessionId: statusEvent.sessionID || '',
148
- statusType: statusEvent.status?.type,
149
- sessionTitle: sessionInfo?.title,
150
- sessionType: resolveSessionType(sessionInfo),
151
- parentSessionId: sessionInfo?.parentID ?? undefined,
152
- };
153
- fillTokenData(p, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
154
- return p;
155
- }
156
- // command.executed -- shell 命令执行完毕(默认关闭)
157
- if (rawType === 'command.executed') {
158
- const cmd = rawProps;
159
- const p = {
160
- ...basePayload,
161
- eventType: 'command',
162
- sessionId: cmd.sessionID || '',
163
- commandName: cmd.name,
164
- arguments: cmd.arguments,
165
- sessionTitle: sessionInfo?.title,
166
- sessionType: resolveSessionType(sessionInfo),
167
- parentSessionId: sessionInfo?.parentID ?? undefined,
168
- };
169
- fillTokenData(p, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
170
- return p;
171
- }
172
- // file.edited -- 文件被编辑(默认关闭)
173
- if (rawType === 'file.edited') {
174
- const fileEvt = rawProps;
175
- const p = {
176
- ...basePayload,
177
- eventType: 'fileEdited',
178
- filePath: fileEvt.file,
179
- sessionType: resolveSessionType(sessionInfo),
180
- parentSessionId: sessionInfo?.parentID ?? undefined,
181
- };
182
- fillTokenData(p, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
183
- return p;
184
- }
185
- // todo.updated -- Todo 列表更新(默认关闭)
186
- if (rawType === 'todo.updated') {
187
- const todoEvt = rawProps;
188
- const summary = todoEvt.todos
189
- ?.map(t => `[${t.status}] ${t.content}`).join('\n');
190
- const p = {
191
- ...basePayload,
192
- eventType: 'todo',
193
- sessionId: todoEvt.sessionID || '',
194
- todoSummary: summary,
195
- sessionTitle: sessionInfo?.title,
196
- sessionType: resolveSessionType(sessionInfo),
197
- parentSessionId: sessionInfo?.parentID ?? undefined,
198
- };
199
- fillTokenData(p, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn);
200
- return p;
201
- }
202
- return null;
203
- }
204
- }
205
- }
206
- /**
207
- * Fill token/cost data into an EventPayload from tokenDataMap.
208
- * Computes sessionCost and formatted strings.
209
- * Skips session.created events (no token data at creation time).
210
- */
211
- function fillTokenData(payload, tokenDataMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn) {
212
- if (!tokenDataMap)
213
- return;
214
- if (payload.eventType === 'created')
215
- return;
216
- const sessionId = payload.sessionId;
217
- const sessionData = tokenDataMap.get(sessionId);
218
- if (!sessionData || sessionData.messages.size === 0)
219
- return;
220
- // Session-level aggregation
221
- const sessionAgg = aggregateSessionTokens(sessionData);
222
- payload.sessionCost = sessionAgg.cost;
223
- payload.sessionTokens = sessionAgg.tokens;
224
- payload.modelID = sessionData.lastModelID;
225
- payload.providerID = sessionData.lastProviderID;
226
- payload.mode = sessionData.lastMode;
227
- // Initially total = session only (sub-sessions added later by aggregateSubSessions)
228
- payload.totalCost = sessionAgg.cost;
229
- payload.totalTokens = { ...sessionAgg.tokens };
230
- // Cost formatting
231
- if (resolveCostConfigFn && formatCostFn) {
232
- const resolved = resolveCostConfigFn(configLanguage, configCost);
233
- payload.costFormatted = formatCostFn(sessionAgg.cost, resolved.currency, resolved.decimalPlaces, resolved.exchangeRate);
234
- payload.totalCostFormatted = payload.costFormatted;
235
- payload.currency = resolved.currency;
236
- }
237
- }
238
- /**
239
- * Aggregate sub-session token data into a payload's total fields.
240
- * Scans tokenDataMap for all sessions whose parentID matches the current session.
241
- */
242
- function aggregateSubSessions(payload, tokenDataMap, sessionMap, configLanguage, configCost, resolveCostConfigFn, formatCostFn) {
243
- const sessionId = payload.sessionId;
244
- if (payload.sessionCost === undefined)
245
- return;
246
- let totalCost = payload.sessionCost;
247
- const st = payload.sessionTokens;
248
- let totalInput = st.input;
249
- let totalOutput = st.output;
250
- let totalReasoning = st.reasoning;
251
- let totalCacheRead = st.cacheRead;
252
- let totalCacheWrite = st.cacheWrite;
253
- for (const [sid, sData] of tokenDataMap) {
254
- if (sid === sessionId)
255
- continue;
256
- const sInfo = sessionMap.get(sid);
257
- if (sInfo?.parentID === sessionId) {
258
- const agg = aggregateSessionTokens(sData);
259
- totalCost += agg.cost;
260
- totalInput += agg.tokens.input;
261
- totalOutput += agg.tokens.output;
262
- totalReasoning += agg.tokens.reasoning;
263
- totalCacheRead += agg.tokens.cacheRead;
264
- totalCacheWrite += agg.tokens.cacheWrite;
265
- }
266
- }
267
- payload.totalCost = totalCost;
268
- payload.totalTokens = {
269
- input: totalInput,
270
- output: totalOutput,
271
- reasoning: totalReasoning,
272
- cacheRead: totalCacheRead,
273
- cacheWrite: totalCacheWrite,
274
- total: totalInput + totalOutput + totalReasoning + totalCacheRead + totalCacheWrite,
275
- };
276
- // Re-format totalCostFormatted with the aggregated total
277
- if (resolveCostConfigFn && formatCostFn) {
278
- const resolved = resolveCostConfigFn(configLanguage, configCost);
279
- payload.totalCostFormatted = formatCostFn(totalCost, resolved.currency, resolved.decimalPlaces, resolved.exchangeRate);
280
- }
281
- }
282
- /**
283
- * Format duration in human-readable format
284
- */
285
- function formatDuration(ms) {
286
- if (ms < 1000)
287
- return `${ms}ms`;
288
- if (ms < 60000)
289
- return `${Math.round(ms / 1000)}s`;
290
- const minutes = Math.floor(ms / 60000);
291
- const seconds = Math.round((ms % 60000) / 1000);
292
- return `${minutes}m ${seconds}s`;
293
- }
294
- /**
295
- * Format timestamp with optional timezone conversion.
296
- * When timezone is not configured, returns original ISO string (backward compatible).
297
- * When timezone is configured, returns localized time string using Intl.DateTimeFormat.
298
- * Invalid timezone strings gracefully fall back to UTC.
299
- */
300
- function formatTimestamp(isoString, timezone) {
301
- if (!timezone) {
302
- return isoString;
303
- }
304
- try {
305
- const date = new Date(isoString);
306
- return new Intl.DateTimeFormat('en-CA', {
307
- timeZone: timezone,
308
- year: 'numeric',
309
- month: '2-digit',
310
- day: '2-digit',
311
- hour: '2-digit',
312
- minute: '2-digit',
313
- second: '2-digit',
314
- hour12: false,
315
- }).format(date);
316
- }
317
- catch {
318
- return isoString;
319
- }
320
- }
321
- /**
322
- * Format error object to string
323
- */
324
- function formatError(error) {
325
- if (typeof error === 'string')
326
- return error;
327
- if (error && typeof error === 'object') {
328
- if ('message' in error)
329
- return String(error.message);
330
- return JSON.stringify(error);
331
- }
332
- return String(error);
333
- }
334
- /**
335
- * Guard flag to ensure auto-update only runs once per plugin load
336
- */
337
- let hasCheckedUpdate = false;
338
- let hasShownStartupToast = false;
339
- /**
340
- * Test-only: captured payloads for verification in tests.
341
- * Cleared in __test_resetFlags().
342
- */
343
- const __test_payloads = [];
344
- /**
345
- * Notification Plugin for OpenCode
346
- * Listens for session events and sends notifications via configured channels
347
- */
348
- export const NotificationPlugin = async (ctx) => {
349
- try {
350
- return await NotificationPluginImpl(ctx);
351
- }
352
- catch (error) {
353
- logger.error(`NotificationPlugin fatal error: ${error}`);
354
- return {};
355
- }
356
- };
357
- /**
358
- * Actual plugin implementation, wrapped by NotificationPlugin for top-level error safety.
359
- */
360
- async function NotificationPluginImpl(ctx) {
361
- let config;
362
- try {
363
- config = loadConfig();
364
- }
365
- catch (error) {
366
- logger.error(`Failed to initialize plugin config: ${error}`);
367
- setTimeout(() => {
368
- try {
369
- ctx.client.tui.showToast({
370
- body: {
371
- title: 'ul-opencode-event Config Error',
372
- message: `配置加载失败: ${String(error).substring(0, 100)}\n详见日志: ~/.local/share/opencode/ul-opencode-event.log`,
373
- variant: 'error',
374
- duration: 8000,
375
- },
376
- }).catch(() => { });
377
- }
378
- catch { }
379
- runAutoUpdate(ctx, true).catch((err) => {
380
- logger.error(`Auto-update check failed: ${err}`);
381
- });
382
- }, 0);
383
- return {};
384
- }
385
- if (config.channels.length === 0) {
386
- logger.warn('Plugin loaded but no enabled channels found');
387
- const version = getCurrentVersion();
388
- setTimeout(() => {
389
- try {
390
- ctx.client.tui.showToast({
391
- body: {
392
- title: `ul-opencode-event ${version ? `v${version}` : ''}`,
393
- message: '插件已加载但没有启用的通知通道\n请检查配置文件',
394
- variant: 'warning',
395
- duration: 5000,
396
- },
397
- }).catch(() => { });
398
- }
399
- catch { }
400
- }, 0);
401
- return {};
402
- }
403
- const { resolveCostConfig, formatCost } = await import('./cost.js');
404
- const eventHandler = createEventHandler(config);
405
- const sessionMap = new Map();
406
- const tokenDataMap = new Map();
407
- if (DEBUG_ENABLED) {
408
- logger.debug(`Plugin initialized for ${ctx.directory} with ${config.channels.length} channels`);
409
- }
6
+ const MinimalPlugin = async () => {
410
7
  return {
411
- event: async ({ event }) => {
412
- if (DEBUG_ENABLED) {
413
- const props = event.properties;
414
- const info = props.info;
415
- logger.debug(`[EVENT] type=${event.type}` +
416
- (info?.id ? ` session.id=${info.id}` : '') +
417
- (props.sessionID ? ` sessionID=${String(props.sessionID)}` : '') +
418
- (info?.title ? ` title="${info.title}"` : ' title=(empty/missing)'));
419
- }
420
- if (event.type === 'session.created') {
421
- sessionMap.set(event.properties.info.id, event.properties.info);
422
- // Auto-update check (runs once, first main session only)
423
- if (!hasCheckedUpdate) {
424
- hasCheckedUpdate = true;
425
- const info = event.properties.info;
426
- const isCliMode = process.env.OPENCODE_CLI_RUN_MODE === 'true';
427
- const isSubSession = info.parentID !== undefined && info.parentID !== '';
428
- if (!isCliMode && !isSubSession) {
429
- setTimeout(() => {
430
- runAutoUpdate(ctx, config.auto_update ?? true).catch((err) => {
431
- logger.error(`Auto-update check failed: ${err}`);
432
- });
433
- }, 0);
434
- }
435
- }
436
- // Startup toast (version + channel count, once per process)
437
- if (!hasShownStartupToast) {
438
- hasShownStartupToast = true;
439
- const info = event.properties.info;
440
- const isCliMode = process.env.OPENCODE_CLI_RUN_MODE === 'true';
441
- const isSubSession = info.parentID !== undefined && info.parentID !== '';
442
- if (!isCliMode && !isSubSession) {
443
- setTimeout(() => {
444
- const version = getCurrentVersion();
445
- const channelCount = config.channels.length;
446
- const channelTypes = [...new Set(config.channels.map(c => c.type))].join(', ');
447
- const title = `ul-opencode-event ${version ? `v${version}` : ''}`;
448
- const message = `${channelCount} channel${channelCount !== 1 ? 's' : ''} loaded (${channelTypes})`;
449
- try {
450
- ctx.client.tui.showToast({
451
- body: { title, message, variant: 'success', duration: 3000 },
452
- }).catch(() => { });
453
- }
454
- catch { }
455
- }, 0);
456
- }
457
- }
458
- }
459
- else if (event.type === 'session.updated') {
460
- sessionMap.set(event.properties.info.id, event.properties.info);
461
- }
462
- else if (event.type === 'message.updated') {
463
- // Handle assistant message token data accumulation
464
- const msgInfo = event.properties.info;
465
- if (msgInfo?.role === 'assistant') {
466
- const sessionId = msgInfo.sessionID;
467
- const messageId = msgInfo.id;
468
- if (sessionId && messageId) {
469
- let sessionData = tokenDataMap.get(sessionId);
470
- if (!sessionData) {
471
- sessionData = {
472
- messages: new Map(),
473
- lastModelID: '',
474
- lastProviderID: '',
475
- lastMode: '',
476
- };
477
- tokenDataMap.set(sessionId, sessionData);
478
- }
479
- const sdkTokens = msgInfo.tokens;
480
- const tokenData = {
481
- cost: msgInfo.cost ?? 0,
482
- tokens: {
483
- input: sdkTokens?.input ?? 0,
484
- output: sdkTokens?.output ?? 0,
485
- reasoning: sdkTokens?.reasoning ?? 0,
486
- cacheRead: sdkTokens?.cache?.read ?? 0,
487
- cacheWrite: sdkTokens?.cache?.write ?? 0,
488
- total: (sdkTokens?.input ?? 0) + (sdkTokens?.output ?? 0) + (sdkTokens?.reasoning ?? 0) + (sdkTokens?.cache?.read ?? 0) + (sdkTokens?.cache?.write ?? 0),
489
- },
490
- modelID: msgInfo.modelID ?? '',
491
- providerID: msgInfo.providerID ?? '',
492
- mode: msgInfo.mode ?? '',
493
- };
494
- // Overwrite (not accumulate) for same message ID
495
- sessionData.messages.set(messageId, tokenData);
496
- sessionData.lastModelID = tokenData.modelID;
497
- sessionData.lastProviderID = tokenData.providerID;
498
- sessionData.lastMode = tokenData.mode;
499
- }
500
- }
501
- // message.updated does not produce a notification
502
- return;
503
- }
504
- let sessionInfo;
505
- const props = event.properties;
506
- if (typeof props.sessionID === 'string') {
507
- sessionInfo = sessionMap.get(props.sessionID);
508
- }
509
- else if (event.type === 'session.created') {
510
- sessionInfo = event.properties.info;
511
- }
512
- const resolvedTimezone = config.timezone ?? getDefaultTimezone(config.language);
513
- if (DEBUG_ENABLED && sessionInfo) {
514
- logger.debug(`[RESOLVED] sessionInfo.id=${sessionInfo.id} title="${sessionInfo.title}" parentID=${sessionInfo.parentID ?? '(none)'}`);
515
- }
516
- if (DEBUG_ENABLED && !sessionInfo && typeof event.properties.sessionID === 'string') {
517
- logger.debug(`[RESOLVED] MISS sessionID=${event.properties.sessionID} - sessionMap has no entry`);
518
- }
519
- const resolvedProjectName = config.projectName || path.basename(ctx.directory) || 'unknown-project';
520
- const payload = eventToPayload(event, resolvedProjectName, sessionInfo, resolvedTimezone, ctx.directory, tokenDataMap, config.language, config.cost, resolveCostConfig, formatCost);
521
- if (!payload) {
522
- return;
523
- }
524
- // Aggregate sub-session token data into total fields
525
- aggregateSubSessions(payload, tokenDataMap, sessionMap, config.language, config.cost, resolveCostConfig, formatCost);
526
- // Filter by session type whitelist (default: only 'main' sessions)
527
- const allowedTypes = config.includeSessionTypes ?? DEFAULT_SESSION_TYPES;
528
- if (!allowedTypes.includes(payload.sessionType)) {
529
- if (DEBUG_ENABLED) {
530
- logger.debug(`Skipping ${payload.sessionType} event ${payload.eventType} for session ${payload.sessionId}`);
531
- }
532
- return;
533
- }
534
- // Debug: warn when sessionMap miss produces 'unknown' (possible race condition)
535
- if (DEBUG_ENABLED && payload.sessionType === 'unknown' && typeof event.properties.sessionID === 'string') {
536
- logger.warn(`sessionMap miss for sessionID=${event.properties.sessionID}, treating as 'unknown'. Race condition?`);
537
- }
538
- if (DEBUG_ENABLED) {
539
- logger.debug(`Handling event ${payload.eventType} for session ${payload.sessionId}`);
540
- }
541
- // Test-only: capture payload for verification
542
- __test_payloads.push(payload);
543
- try {
544
- await eventHandler.handle(payload.eventType, payload);
545
- }
546
- catch (error) {
547
- logger.error(`Failed to handle event ${payload.eventType}: ${error}`);
548
- }
549
- // Clean up tokenDataMap after session.idle or session.error
550
- if (event.type === 'session.idle' || event.type === 'session.error') {
551
- const sid = event.properties.sessionID;
552
- if (sid) {
553
- tokenDataMap.delete(sid);
554
- }
555
- }
8
+ event: async () => {
9
+ // no-op
556
10
  },
557
11
  };
558
- }
559
- ;
560
- /**
561
- * Default export: the plugin function itself.
562
- * This matches the pattern used by all working OpenCode plugins
563
- * (opencode-claude-auth, opencode-dcp, opencode-copilot-plugin).
564
- * OpenCode's readV1Plugin() in detect mode returns undefined for function defaults,
565
- * falling through to getLegacyPlugins() which iterates all module exports.
566
- */
567
- export default NotificationPlugin;
568
- /**
569
- * Test-only: reset module-level flags so toast tests can run independently.
570
- */
571
- function __test_resetFlags() {
572
- hasCheckedUpdate = false;
573
- hasShownStartupToast = false;
574
- __test_payloads.length = 0;
575
- }
576
- /**
577
- * Test-only helpers namespace.
578
- * Includes a `server` property pointing to NotificationPlugin so that
579
- * OpenCode's getServerPlugin() correctly identifies it (returns the server fn)
580
- * instead of returning undefined and causing getLegacyPlugins to throw.
581
- * The `seen` Set in getLegacyPlugins deduplicates, so NotificationPlugin
582
- * is only called once despite appearing in both `default` and `__test.server`.
583
- */
584
- export const __test = {
585
- payloads: __test_payloads,
586
- resetFlags: __test_resetFlags,
587
- server: NotificationPlugin,
588
12
  };
13
+ export default MinimalPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulthon/ul-opencode-event",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "OpenCode notification plugin - sends notifications via email, DingTalk, or Feishu when session events occur",
5
5
  "author": "augushong",
6
6
  "license": "MIT",