@chime-io/plugin-opencode 1.0.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.
@@ -0,0 +1,448 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ import type { Notifier } from '@telnotify/core';
4
+
5
+ import {
6
+ createOpenCodeEventFormatter,
7
+ extractErrorMessage,
8
+ type OpenCodeConversationMessage,
9
+ type OpenCodeErrorLike,
10
+ type OpenCodeSession,
11
+ } from './format.js';
12
+
13
+ const QUESTION_TOOLS = new Set(['question', 'ask_user_question', 'askuserquestion']);
14
+
15
+ interface SessionGetResult {
16
+ data?: OpenCodeSession;
17
+ error?: unknown;
18
+ }
19
+
20
+ interface SessionListMessagesArgsPrimary {
21
+ sessionID: string;
22
+ limit: number;
23
+ }
24
+
25
+ interface SessionListMessagesArgsSecondary {
26
+ path: { id: string };
27
+ query: { limit: number };
28
+ }
29
+
30
+ type SessionMessagesArgs =
31
+ | SessionListMessagesArgsPrimary
32
+ | SessionListMessagesArgsSecondary;
33
+
34
+ interface SessionMessagesResult {
35
+ data?: OpenCodeConversationMessage[];
36
+ }
37
+
38
+ interface OpenCodeSessionApi {
39
+ get(args: { sessionID: string }): Promise<SessionGetResult>;
40
+ messages?(args: SessionMessagesArgs): Promise<SessionMessagesResult | OpenCodeConversationMessage[]>;
41
+ }
42
+
43
+ interface OpenCodeAppApi {
44
+ log?(input: {
45
+ body: {
46
+ service: string;
47
+ level: 'warn';
48
+ message: string;
49
+ extra?: Record<string, unknown>;
50
+ };
51
+ }): Promise<unknown>;
52
+ }
53
+
54
+ export interface OpenCodeClient {
55
+ session?: OpenCodeSessionApi;
56
+ app?: OpenCodeAppApi;
57
+ }
58
+
59
+ export interface Logger {
60
+ warn(message: string, extra?: Record<string, unknown>): Promise<void>;
61
+ }
62
+
63
+ interface ToolExecuteInput {
64
+ tool?: string;
65
+ sessionID?: string;
66
+ callID?: string;
67
+ }
68
+
69
+ interface ToolExecuteOutput {
70
+ args?: {
71
+ questions?: Array<{
72
+ question?: string;
73
+ }>;
74
+ };
75
+ }
76
+
77
+ interface PermissionEventProperties {
78
+ id?: string;
79
+ permissionID?: string;
80
+ sessionID?: string;
81
+ title?: string;
82
+ }
83
+
84
+ interface SessionEventProperties {
85
+ sessionID?: string;
86
+ status?: { type?: string } | 'busy';
87
+ error?: OpenCodeErrorLike | string;
88
+ info?: OpenCodeSession;
89
+ }
90
+
91
+ export interface OpenCodeEventEnvelope {
92
+ event: {
93
+ type: string;
94
+ properties?: PermissionEventProperties & SessionEventProperties;
95
+ };
96
+ }
97
+
98
+ export interface OpenCodeNotifierPlugin {
99
+ 'tool.execute.before': (input: ToolExecuteInput, output: ToolExecuteOutput) => Promise<void>;
100
+ 'tool.execute.after': (input: ToolExecuteInput) => Promise<void>;
101
+ event: (payload: OpenCodeEventEnvelope) => Promise<void>;
102
+ }
103
+
104
+ export interface CreateOpenCodeNotifierPluginOptions {
105
+ client: OpenCodeClient;
106
+ notifier: Notifier;
107
+ logger: Logger;
108
+ }
109
+
110
+ function getLifecycleLogFile(): string {
111
+ return process.env.TELME_LOG_FILE || '/tmp/telme.log';
112
+ }
113
+
114
+ async function appendLifecycleLog(
115
+ stage: string,
116
+ payload: Record<string, unknown>,
117
+ ): Promise<void> {
118
+ try {
119
+ const line = JSON.stringify({
120
+ ts: new Date().toISOString(),
121
+ stage,
122
+ ...payload,
123
+ });
124
+ await fs.appendFile(getLifecycleLogFile(), `${line}\n`, 'utf8');
125
+ } catch {
126
+ // Never break plugin lifecycle when file logging fails.
127
+ }
128
+ }
129
+
130
+ function isBusyStatus(status: SessionEventProperties['status']): boolean {
131
+ return status === 'busy' || status?.type === 'busy';
132
+ }
133
+
134
+ function getQuestionText(args: ToolExecuteOutput['args']): string {
135
+ const questions = args?.questions;
136
+ if (!Array.isArray(questions) || questions.length === 0) return '';
137
+ const questionText = questions[0]?.question;
138
+ return typeof questionText === 'string' ? questionText : '';
139
+ }
140
+
141
+ export function createOpenCodeNotifierPlugin({
142
+ client,
143
+ notifier,
144
+ logger,
145
+ }: CreateOpenCodeNotifierPluginOptions): OpenCodeNotifierPlugin {
146
+ const sessionCache = new Map<string, OpenCodeSession>();
147
+ const rootActivity = new Map<string, boolean>();
148
+ const notifyingRoots = new Set<string>();
149
+ const rootErrors = new Map<string, string>();
150
+ const notifiedTaskErrors = new Set<string>();
151
+ const questionNotifications = new Map<string, string>();
152
+ const permissionNotifications = new Map<string, string>();
153
+
154
+ const formatter = createOpenCodeEventFormatter({
155
+ listMessages: async (sessionId) => {
156
+ if (!client.session?.messages) return [];
157
+
158
+ const attempts: Array<() => Promise<SessionMessagesResult | OpenCodeConversationMessage[]>> = [
159
+ () => client.session!.messages!({ sessionID: sessionId, limit: 10 }),
160
+ () =>
161
+ client.session!.messages!({
162
+ path: { id: sessionId },
163
+ query: { limit: 10 },
164
+ }),
165
+ ];
166
+
167
+ for (const attempt of attempts) {
168
+ try {
169
+ const result = await attempt();
170
+ if (Array.isArray(result)) return result;
171
+ if (Array.isArray(result.data)) return result.data;
172
+ } catch (error) {
173
+ await logger.warn('Failed to load session messages for notification', {
174
+ sessionId,
175
+ error: error instanceof Error ? error.message : String(error),
176
+ });
177
+ }
178
+ }
179
+
180
+ return [];
181
+ },
182
+ });
183
+
184
+ async function getSessionInfo(sessionId: string): Promise<OpenCodeSession> {
185
+ const cached = sessionCache.get(sessionId);
186
+ if (cached) return cached;
187
+
188
+ if (!client.session) {
189
+ throw new Error('OpenCode session client is required');
190
+ }
191
+
192
+ const result = await client.session.get({ sessionID: sessionId });
193
+ if (result.error || !result.data) {
194
+ throw new Error(
195
+ result.error ? `Failed to load session ${sessionId}` : `Session ${sessionId} not found`,
196
+ );
197
+ }
198
+
199
+ sessionCache.set(result.data.id, result.data);
200
+ return result.data;
201
+ }
202
+
203
+ async function getRootSessionInfo(sessionId: string): Promise<OpenCodeSession> {
204
+ const visited = new Set<string>();
205
+ let current = await getSessionInfo(sessionId);
206
+
207
+ while (current.parentID) {
208
+ if (visited.has(current.id)) {
209
+ throw new Error(`Detected session parent cycle at ${current.id}`);
210
+ }
211
+
212
+ visited.add(current.id);
213
+ current = await getSessionInfo(current.parentID);
214
+ }
215
+
216
+ return current;
217
+ }
218
+
219
+ async function notifyRootSession(sessionId: string): Promise<void> {
220
+ let notifiedRootId: string | null = null;
221
+
222
+ try {
223
+ const rootSession = await getRootSessionInfo(sessionId);
224
+ if (rootSession.id !== sessionId) return;
225
+ if (!rootActivity.get(rootSession.id)) return;
226
+ if (notifyingRoots.has(rootSession.id)) return;
227
+
228
+ notifyingRoots.add(rootSession.id);
229
+ notifiedRootId = rootSession.id;
230
+
231
+ if (rootErrors.has(rootSession.id)) {
232
+ await notifier.notify(
233
+ await formatter.formatSessionError(rootSession, rootErrors.get(rootSession.id)),
234
+ );
235
+ } else {
236
+ await notifier.notify(await formatter.formatSessionCompleted(rootSession));
237
+ }
238
+
239
+ rootActivity.set(rootSession.id, false);
240
+ rootErrors.delete(rootSession.id);
241
+ } catch (error) {
242
+ await logger.warn('Failed to process notification event', {
243
+ sessionId,
244
+ error: error instanceof Error ? error.message : String(error),
245
+ });
246
+ } finally {
247
+ if (notifiedRootId) {
248
+ notifyingRoots.delete(notifiedRootId);
249
+ }
250
+ }
251
+ }
252
+
253
+ async function notifyTaskSessionError(
254
+ sessionId: string,
255
+ errorMessage: string,
256
+ ): Promise<void> {
257
+ if (notifiedTaskErrors.has(sessionId)) return;
258
+
259
+ try {
260
+ const session = await getSessionInfo(sessionId);
261
+ if (!session.parentID) return;
262
+
263
+ notifiedTaskErrors.add(sessionId);
264
+ await notifier.notify(await formatter.formatSessionError(session, errorMessage));
265
+ } catch (error) {
266
+ await logger.warn('Failed to send task error notification', {
267
+ sessionId,
268
+ error: error instanceof Error ? error.message : String(error),
269
+ });
270
+ }
271
+ }
272
+
273
+ async function notifyQuestion(
274
+ sessionId: string,
275
+ callId: string,
276
+ questionText: string,
277
+ ): Promise<void> {
278
+ if (!callId || questionNotifications.has(callId)) return;
279
+
280
+ try {
281
+ const session = await getSessionInfo(sessionId);
282
+ questionNotifications.set(callId, sessionId);
283
+ await notifier.notify(formatter.formatQuestion(session, questionText));
284
+ } catch (error) {
285
+ await logger.warn('Failed to send question notification', {
286
+ sessionId,
287
+ callId,
288
+ error: error instanceof Error ? error.message : String(error),
289
+ });
290
+ }
291
+ }
292
+
293
+ async function notifyPermission(
294
+ sessionId: string,
295
+ permissionId: string,
296
+ title: string,
297
+ ): Promise<void> {
298
+ if (!permissionId || permissionNotifications.has(permissionId)) return;
299
+
300
+ try {
301
+ const session = await getSessionInfo(sessionId);
302
+ permissionNotifications.set(permissionId, sessionId);
303
+ await notifier.notify(formatter.formatPermission(session, title));
304
+ } catch (error) {
305
+ await logger.warn('Failed to send permission notification', {
306
+ sessionId,
307
+ permissionId,
308
+ error: error instanceof Error ? error.message : String(error),
309
+ });
310
+ }
311
+ }
312
+
313
+ function clearSessionNotificationState(sessionId: string): void {
314
+ notifiedTaskErrors.delete(sessionId);
315
+
316
+ for (const [callId, trackedSessionId] of questionNotifications.entries()) {
317
+ if (trackedSessionId === sessionId) {
318
+ questionNotifications.delete(callId);
319
+ }
320
+ }
321
+
322
+ for (const [permissionId, trackedSessionId] of permissionNotifications.entries()) {
323
+ if (trackedSessionId === sessionId) {
324
+ permissionNotifications.delete(permissionId);
325
+ }
326
+ }
327
+ }
328
+
329
+ return {
330
+ 'tool.execute.before': async (input, output) => {
331
+ await appendLifecycleLog('tool.execute.before', {
332
+ tool: input.tool,
333
+ sessionId: input.sessionID,
334
+ callId: input.callID,
335
+ });
336
+ if (!input.tool || !QUESTION_TOOLS.has(input.tool)) return;
337
+ if (!input.sessionID || !input.callID) return;
338
+
339
+ await notifyQuestion(
340
+ input.sessionID,
341
+ input.callID,
342
+ getQuestionText(output.args),
343
+ );
344
+ },
345
+ 'tool.execute.after': async (input) => {
346
+ await appendLifecycleLog('tool.execute.after', {
347
+ tool: input.tool,
348
+ sessionId: input.sessionID,
349
+ callId: input.callID,
350
+ });
351
+ if (!input.tool || !QUESTION_TOOLS.has(input.tool) || !input.callID) return;
352
+ questionNotifications.delete(input.callID);
353
+ },
354
+ event: async ({ event }) => {
355
+ await appendLifecycleLog('event.received', {
356
+ eventType: event.type,
357
+ sessionId: event.properties?.sessionID,
358
+ });
359
+
360
+ if (event.type === 'session.created' || event.type === 'session.updated') {
361
+ const info = event.properties?.info;
362
+ if (info?.id) {
363
+ sessionCache.set(info.id, info);
364
+ }
365
+ return;
366
+ }
367
+
368
+ if (event.type === 'session.deleted') {
369
+ const sessionId = event.properties?.info?.id;
370
+ if (sessionId) {
371
+ clearSessionNotificationState(sessionId);
372
+ }
373
+ return;
374
+ }
375
+
376
+ if (event.type === 'permission.updated' || event.type === 'permission.asked') {
377
+ const permissionId = event.properties?.id;
378
+ const sessionId = event.properties?.sessionID;
379
+ if (!permissionId || !sessionId) return;
380
+ await notifyPermission(sessionId, permissionId, event.properties?.title ?? '');
381
+ return;
382
+ }
383
+
384
+ if (event.type === 'permission.replied') {
385
+ const permissionId = event.properties?.permissionID;
386
+ if (permissionId) {
387
+ permissionNotifications.delete(permissionId);
388
+ }
389
+ return;
390
+ }
391
+
392
+ if (event.type === 'session.status') {
393
+ const sessionId = event.properties?.sessionID;
394
+ const status = event.properties?.status;
395
+ if (!sessionId) return;
396
+
397
+ if (typeof status === 'object' && status?.type === 'idle') {
398
+ await notifyRootSession(sessionId);
399
+ return;
400
+ }
401
+
402
+ if (!isBusyStatus(status)) return;
403
+
404
+ try {
405
+ const rootSession = await getRootSessionInfo(sessionId);
406
+ rootActivity.set(rootSession.id, true);
407
+ } catch (error) {
408
+ await logger.warn('Failed to resolve root session for status event', {
409
+ sessionId,
410
+ error: error instanceof Error ? error.message : String(error),
411
+ });
412
+ }
413
+ return;
414
+ }
415
+
416
+ if (event.type === 'session.error') {
417
+ const sessionId = event.properties?.sessionID;
418
+ if (!sessionId) return;
419
+
420
+ try {
421
+ const session = await getSessionInfo(sessionId);
422
+ const errorMessage = extractErrorMessage(event.properties?.error);
423
+
424
+ if (session.parentID) {
425
+ await notifyTaskSessionError(sessionId, errorMessage);
426
+ return;
427
+ }
428
+
429
+ const rootSession = await getRootSessionInfo(sessionId);
430
+ rootActivity.set(rootSession.id, true);
431
+ rootErrors.set(rootSession.id, errorMessage);
432
+ } catch (error) {
433
+ await logger.warn('Failed to resolve root session for error event', {
434
+ sessionId,
435
+ error: error instanceof Error ? error.message : String(error),
436
+ });
437
+ }
438
+ return;
439
+ }
440
+
441
+ if (event.type !== 'session.idle') return;
442
+
443
+ const sessionId = event.properties?.sessionID;
444
+ if (!sessionId) return;
445
+ await notifyRootSession(sessionId);
446
+ },
447
+ };
448
+ }
@@ -0,0 +1,116 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ createOpenCodeEventFormatter,
9
+ extractLastErrorFromMessages,
10
+ extractLastResultFromMessages,
11
+ formatChangeSummary,
12
+ } from "../dist/format.js";
13
+ import { createOpenCodeNotifierPlugin } from "../dist/notifier-plugin.js";
14
+
15
+ test("formatChangeSummary keeps additions deletions and file count", () => {
16
+ assert.equal(
17
+ formatChangeSummary({
18
+ summary: { additions: 4, deletions: 2, files: 3 },
19
+ }),
20
+ "+4 · -2 · 3 files",
21
+ );
22
+ });
23
+
24
+ test("extractLastResultFromMessages prefers latest assistant text", () => {
25
+ const result = extractLastResultFromMessages([
26
+ { info: { role: "user" }, parts: [{ type: "text", text: "ignored" }] },
27
+ {
28
+ info: { role: "assistant" },
29
+ parts: [{ type: "text", text: " 已经完成 最终结果 " }],
30
+ },
31
+ ]);
32
+
33
+ assert.equal(result, "已经完成 最终结果");
34
+ });
35
+
36
+ test("extractLastErrorFromMessages reads tool error message", () => {
37
+ const result = extractLastErrorFromMessages([
38
+ {
39
+ info: { role: "assistant" },
40
+ parts: [
41
+ {
42
+ type: "tool",
43
+ state: {
44
+ status: "error",
45
+ error: { message: "permission denied" },
46
+ },
47
+ },
48
+ ],
49
+ },
50
+ ]);
51
+
52
+ assert.equal(result, "permission denied");
53
+ });
54
+
55
+ test("createOpenCodeEventFormatter formats completed root session", async () => {
56
+ const formatter = createOpenCodeEventFormatter({
57
+ listMessages: async () => [
58
+ {
59
+ info: { role: "assistant" },
60
+ parts: [{ type: "text", text: "任务已完成" }],
61
+ },
62
+ ],
63
+ });
64
+
65
+ const notification = await formatter.formatSessionCompleted({
66
+ id: "1234567890abcdef",
67
+ title: "feature-flow",
68
+ summary: { additions: 7, deletions: 1, files: 2 },
69
+ });
70
+
71
+ assert.deepEqual(notification, {
72
+ agent: "opencode",
73
+ kind: "session.completed",
74
+ title: "OpenCode · feature-flow",
75
+ lines: ["+7 · -1 · 2 files", "任务已完成"],
76
+ metadata: { sessionId: "1234567890abcdef" },
77
+ });
78
+ });
79
+
80
+ test("createOpenCodeNotifierPlugin writes lifecycle log to file", async () => {
81
+ const logFile = path.join(
82
+ os.tmpdir(),
83
+ `telnotify-opencode-${Date.now()}.log`,
84
+ );
85
+ process.env.TELME_LOG_FILE = logFile;
86
+
87
+ const plugin = createOpenCodeNotifierPlugin({
88
+ client: {
89
+ session: {
90
+ get: async ({ sessionID }: { sessionID: string }) => ({
91
+ data: { id: sessionID, title: "demo-session", parentID: null },
92
+ }),
93
+ messages: async () => ({ data: [] }),
94
+ },
95
+ },
96
+ notifier: { notify: async () => undefined },
97
+ logger: { warn: async () => undefined },
98
+ });
99
+
100
+ await plugin.event({
101
+ event: {
102
+ type: "session.status",
103
+ properties: {
104
+ sessionID: "root-session",
105
+ status: { type: "busy" },
106
+ },
107
+ },
108
+ });
109
+
110
+ const logContent = await fs.readFile(logFile, "utf8");
111
+ assert.match(logContent, /event\.received/);
112
+ assert.match(logContent, /session\.status/);
113
+
114
+ delete process.env.TELME_LOG_FILE;
115
+ await fs.unlink(logFile);
116
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "references": [
8
+ { "path": "../core" },
9
+ { "path": "../telegram" }
10
+ ],
11
+ "include": ["src/**/*.ts"]
12
+ }