@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.
- package/dist/index-minimal.d.ts +16 -0
- package/dist/index-minimal.js +20 -0
- package/dist/index.d.ts +8 -35
- package/dist/index.js +7 -582
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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 (
|
|
412
|
-
|
|
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