dondon-notify 0.1.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.
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/ocx.jsonc +10 -0
- package/opencode.jsonc +4 -0
- package/package.json +30 -0
- package/registry.json +19 -0
- package/src/notify.ts +521 -0
- package/src/plugin/kdco-primitives/get-project-id.ts +172 -0
- package/src/plugin/kdco-primitives/index.ts +26 -0
- package/src/plugin/kdco-primitives/log-warn.ts +51 -0
- package/src/plugin/kdco-primitives/mutex.ts +122 -0
- package/src/plugin/kdco-primitives/shell.ts +138 -0
- package/src/plugin/kdco-primitives/temp.ts +36 -0
- package/src/plugin/kdco-primitives/terminal-detect.ts +34 -0
- package/src/plugin/kdco-primitives/types.ts +13 -0
- package/src/plugin/kdco-primitives/with-timeout.ts +84 -0
- package/test/test-kitty.js +73 -0
package/src/notify.ts
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify
|
|
3
|
+
* Native OS notifications for OpenCode
|
|
4
|
+
*
|
|
5
|
+
* Philosophy: "Notify the human when the AI needs them back, not for every micro-event."
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Auto-detects terminal emulator (Ghostty, Kitty, iTerm, WezTerm, etc.)
|
|
9
|
+
* - Suppresses notifications when terminal is focused (like Ghostty does)
|
|
10
|
+
* - Click notification to focus terminal
|
|
11
|
+
* - Parent session only by default (no spam from sub-tasks)
|
|
12
|
+
*
|
|
13
|
+
* Uses node-notifier which bundles native binaries:
|
|
14
|
+
* - macOS: terminal-notifier (native NSUserNotificationCenter)
|
|
15
|
+
* - Windows: SnoreToast (native toast notifications)
|
|
16
|
+
* - Linux: notify-send (native desktop notifications)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs/promises"
|
|
20
|
+
import * as os from "node:os"
|
|
21
|
+
import * as path from "node:path"
|
|
22
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
23
|
+
import type { Event } from "@opencode-ai/sdk"
|
|
24
|
+
import detectTerminal from "detect-terminal"
|
|
25
|
+
import notifier from "node-notifier"
|
|
26
|
+
import type { OpencodeClient } from "./plugin/kdco-primitives/types"
|
|
27
|
+
|
|
28
|
+
interface NotifyConfig {
|
|
29
|
+
/** Notify for child/sub-session events (default: false) */
|
|
30
|
+
notifyChildSessions: boolean
|
|
31
|
+
/** Sound configuration per event type */
|
|
32
|
+
sounds: {
|
|
33
|
+
idle: string
|
|
34
|
+
error: string
|
|
35
|
+
permission: string
|
|
36
|
+
}
|
|
37
|
+
/** Quiet hours configuration */
|
|
38
|
+
quietHours: {
|
|
39
|
+
enabled: boolean
|
|
40
|
+
start: string // "HH:MM" format
|
|
41
|
+
end: string // "HH:MM" format
|
|
42
|
+
}
|
|
43
|
+
/** Override terminal detection (optional) */
|
|
44
|
+
terminal?: string
|
|
45
|
+
/** Kitty-specific configuration */
|
|
46
|
+
kitty?: {
|
|
47
|
+
/** Use native Kitty notifications (default: true when Kitty detected) */
|
|
48
|
+
enabled: boolean
|
|
49
|
+
/** Enable notification sounds (default: false, Kitty doesn't support sounds) */
|
|
50
|
+
sounds: boolean
|
|
51
|
+
/** Auto-focus terminal when notification is clicked (default: true) */
|
|
52
|
+
focusOnClick: boolean
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TerminalInfo {
|
|
57
|
+
name: string | null
|
|
58
|
+
bundleId: string | null
|
|
59
|
+
processName: string | null
|
|
60
|
+
windowId?: string | null // Kitty window ID for Linux focus detection
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_CONFIG: NotifyConfig = {
|
|
64
|
+
notifyChildSessions: false,
|
|
65
|
+
sounds: {
|
|
66
|
+
idle: "Glass",
|
|
67
|
+
error: "Basso",
|
|
68
|
+
permission: "Submarine",
|
|
69
|
+
},
|
|
70
|
+
quietHours: {
|
|
71
|
+
enabled: false,
|
|
72
|
+
start: "22:00",
|
|
73
|
+
end: "08:00",
|
|
74
|
+
},
|
|
75
|
+
kitty: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
sounds: false,
|
|
78
|
+
focusOnClick: true,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Terminal name to process name mapping (for focus detection)
|
|
83
|
+
const TERMINAL_PROCESS_NAMES: Record<string, string> = {
|
|
84
|
+
ghostty: "Ghostty",
|
|
85
|
+
kitty: "kitty",
|
|
86
|
+
iterm: "iTerm2",
|
|
87
|
+
iterm2: "iTerm2",
|
|
88
|
+
wezterm: "WezTerm",
|
|
89
|
+
alacritty: "Alacritty",
|
|
90
|
+
terminal: "Terminal",
|
|
91
|
+
apple_terminal: "Terminal",
|
|
92
|
+
hyper: "Hyper",
|
|
93
|
+
warp: "Warp",
|
|
94
|
+
vscode: "Code",
|
|
95
|
+
"vscode-insiders": "Code - Insiders",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ==========================================
|
|
99
|
+
// CONFIGURATION
|
|
100
|
+
// ==========================================
|
|
101
|
+
|
|
102
|
+
let cachedConfig: NotifyConfig | null = null
|
|
103
|
+
|
|
104
|
+
async function loadConfig(): Promise<NotifyConfig> {
|
|
105
|
+
// Return cached config if available
|
|
106
|
+
if (cachedConfig) {
|
|
107
|
+
return cachedConfig
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const configPath = path.join(os.homedir(), ".config", "opencode", "dondon-notify.json")
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const content = await fs.readFile(configPath, "utf8")
|
|
114
|
+
const userConfig = JSON.parse(content) as Partial<NotifyConfig>
|
|
115
|
+
|
|
116
|
+
// Merge with defaults
|
|
117
|
+
const config = {
|
|
118
|
+
...DEFAULT_CONFIG,
|
|
119
|
+
...userConfig,
|
|
120
|
+
sounds: {
|
|
121
|
+
...DEFAULT_CONFIG.sounds,
|
|
122
|
+
...userConfig.sounds,
|
|
123
|
+
},
|
|
124
|
+
quietHours: {
|
|
125
|
+
...DEFAULT_CONFIG.quietHours,
|
|
126
|
+
...userConfig.quietHours,
|
|
127
|
+
},
|
|
128
|
+
kitty: {
|
|
129
|
+
...DEFAULT_CONFIG.kitty,
|
|
130
|
+
...userConfig.kitty,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Cache the config
|
|
135
|
+
cachedConfig = config
|
|
136
|
+
return config
|
|
137
|
+
} catch {
|
|
138
|
+
// Config doesn't exist or is invalid, use defaults
|
|
139
|
+
cachedConfig = DEFAULT_CONFIG
|
|
140
|
+
return DEFAULT_CONFIG
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function loadConfigSync(): NotifyConfig | null {
|
|
145
|
+
return cachedConfig
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ==========================================
|
|
149
|
+
// TERMINAL DETECTION (macOS)
|
|
150
|
+
// ==========================================
|
|
151
|
+
|
|
152
|
+
async function runOsascript(script: string): Promise<string | null> {
|
|
153
|
+
if (process.platform !== "darwin") return null
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const proc = Bun.spawn(["osascript", "-e", script], {
|
|
157
|
+
stdout: "pipe",
|
|
158
|
+
stderr: "pipe",
|
|
159
|
+
})
|
|
160
|
+
const output = await new Response(proc.stdout).text()
|
|
161
|
+
return output.trim()
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function getBundleId(appName: string): Promise<string | null> {
|
|
168
|
+
return runOsascript(`id of application "${appName}"`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ==========================================
|
|
172
|
+
// KITTY-SPECIFIC FUNCTIONS (Linux)
|
|
173
|
+
// ==========================================
|
|
174
|
+
|
|
175
|
+
function isKittyTerminal(): boolean {
|
|
176
|
+
// Check if we're running in Kitty
|
|
177
|
+
return process.env.TERM?.toLowerCase().includes("kitty") ||
|
|
178
|
+
process.env.TERMINAL?.toLowerCase().includes("kitty") ||
|
|
179
|
+
detectTerminal()?.toLowerCase() === "kitty"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function getKittyWindowId(): Promise<string | null> {
|
|
183
|
+
try {
|
|
184
|
+
// Kitty sets WINDOWID environment variable
|
|
185
|
+
return process.env.WINDOWID || null
|
|
186
|
+
} catch {
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function isKittyWindowFocused(windowId: string): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
// Use xprop to check if the window is focused
|
|
194
|
+
const proc = Bun.spawn(["xprop", "-root", "_NET_ACTIVE_WINDOW"], {
|
|
195
|
+
stdout: "pipe",
|
|
196
|
+
stderr: "pipe",
|
|
197
|
+
})
|
|
198
|
+
const output = await new Response(proc.stdout).text()
|
|
199
|
+
|
|
200
|
+
// Extract the active window ID from xprop output
|
|
201
|
+
const activeWindowMatch = output.match(/_NET_ACTIVE_WINDOW.*?# (0x[0-9a-f]+)/i)
|
|
202
|
+
if (!activeWindowMatch) return false
|
|
203
|
+
|
|
204
|
+
const activeWindowId = activeWindowMatch[1]
|
|
205
|
+
return activeWindowId.toLowerCase() === windowId.toLowerCase()
|
|
206
|
+
} catch {
|
|
207
|
+
// If xprop fails or isn't available, assume not focused to be safe
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface KittyNotificationOptions {
|
|
213
|
+
title: string
|
|
214
|
+
message: string
|
|
215
|
+
sound?: string
|
|
216
|
+
focusOnClick?: boolean
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sendKittyNotification(options: KittyNotificationOptions): void {
|
|
220
|
+
const { title, message, sound, focusOnClick = true } = options
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Build Kitty OSC 99 notification metadata
|
|
224
|
+
const metadataParts = ["i=1", "d=1"]
|
|
225
|
+
|
|
226
|
+
// Add focus action if requested
|
|
227
|
+
if (focusOnClick) {
|
|
228
|
+
metadataParts.push("a=focus")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Add sound if configured ( Kitty supports some sound names)
|
|
232
|
+
if (sound && sound !== "silent") {
|
|
233
|
+
metadataParts.push(`s=${sound}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const metadata = metadataParts.join(":")
|
|
237
|
+
const titlePayload = `p=title;${title}`
|
|
238
|
+
const bodyPayload = `p=body;${message}`
|
|
239
|
+
|
|
240
|
+
// Create the escape sequence
|
|
241
|
+
const escapeSequence = `\x1b]99;${metadata};${titlePayload}\x1b\\\x1b]99;${metadata};${bodyPayload}\x1b\\`
|
|
242
|
+
|
|
243
|
+
// Send to stdout
|
|
244
|
+
process.stdout.write(escapeSequence)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Fall back to system notification if Kitty notification fails
|
|
247
|
+
console.warn("Kitty notification failed, falling back to system notification:", error)
|
|
248
|
+
notifier.notify({ title, message, sound })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function getFrontmostApp(): Promise<string | null> {
|
|
253
|
+
return runOsascript(
|
|
254
|
+
'tell application "System Events" to get name of first application process whose frontmost is true',
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function detectTerminalInfo(config: NotifyConfig): Promise<TerminalInfo> {
|
|
259
|
+
// Use config override if provided
|
|
260
|
+
const terminalName = config.terminal || detectTerminal() || null
|
|
261
|
+
|
|
262
|
+
if (!terminalName) {
|
|
263
|
+
return { name: null, bundleId: null, processName: null, windowId: null }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Get process name for focus detection
|
|
267
|
+
const processName = TERMINAL_PROCESS_NAMES[terminalName.toLowerCase()] || terminalName
|
|
268
|
+
|
|
269
|
+
// Dynamically get bundle ID from macOS (no hardcoding!)
|
|
270
|
+
const bundleId = await getBundleId(processName)
|
|
271
|
+
|
|
272
|
+
// Get Kitty window ID for Linux focus detection
|
|
273
|
+
let windowId: string | null = null
|
|
274
|
+
if (terminalName.toLowerCase() === "kitty" && process.platform === "linux") {
|
|
275
|
+
windowId = await getKittyWindowId()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
name: terminalName,
|
|
280
|
+
bundleId,
|
|
281
|
+
processName,
|
|
282
|
+
windowId,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function isTerminalFocused(terminalInfo: TerminalInfo): Promise<boolean> {
|
|
287
|
+
if (!terminalInfo.processName) return false
|
|
288
|
+
|
|
289
|
+
if (process.platform === "darwin") {
|
|
290
|
+
// macOS focus detection
|
|
291
|
+
const frontmost = await getFrontmostApp()
|
|
292
|
+
if (!frontmost) return false
|
|
293
|
+
return frontmost.toLowerCase() === terminalInfo.processName.toLowerCase()
|
|
294
|
+
} else if (process.platform === "linux" && terminalInfo.name?.toLowerCase() === "kitty" && terminalInfo.windowId) {
|
|
295
|
+
// Kitty focus detection on Linux
|
|
296
|
+
return await isKittyWindowFocused(terminalInfo.windowId)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ==========================================
|
|
303
|
+
// QUIET HOURS CHECK
|
|
304
|
+
// ==========================================
|
|
305
|
+
|
|
306
|
+
function isQuietHours(config: NotifyConfig): boolean {
|
|
307
|
+
if (!config.quietHours.enabled) return false
|
|
308
|
+
|
|
309
|
+
const now = new Date()
|
|
310
|
+
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
|
311
|
+
|
|
312
|
+
const [startHour, startMin] = config.quietHours.start.split(":").map(Number)
|
|
313
|
+
const [endHour, endMin] = config.quietHours.end.split(":").map(Number)
|
|
314
|
+
|
|
315
|
+
const startMinutes = startHour * 60 + startMin
|
|
316
|
+
const endMinutes = endHour * 60 + endMin
|
|
317
|
+
|
|
318
|
+
// Handle overnight quiet hours (e.g., 22:00 - 08:00)
|
|
319
|
+
if (startMinutes > endMinutes) {
|
|
320
|
+
return currentMinutes >= startMinutes || currentMinutes < endMinutes
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return currentMinutes >= startMinutes && currentMinutes < endMinutes
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ==========================================
|
|
327
|
+
// PARENT SESSION DETECTION
|
|
328
|
+
// ==========================================
|
|
329
|
+
|
|
330
|
+
async function isParentSession(client: OpencodeClient, sessionID: string): Promise<boolean> {
|
|
331
|
+
try {
|
|
332
|
+
const session = await client.session.get({ path: { id: sessionID } })
|
|
333
|
+
// No parentID means this IS the parent/root session
|
|
334
|
+
return !session.data?.parentID
|
|
335
|
+
} catch {
|
|
336
|
+
// If we can't fetch, assume it's a parent to be safe (notify rather than miss)
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ==========================================
|
|
342
|
+
// NOTIFICATION SENDER
|
|
343
|
+
// ==========================================
|
|
344
|
+
|
|
345
|
+
interface NotificationOptions {
|
|
346
|
+
title: string
|
|
347
|
+
message: string
|
|
348
|
+
sound: string
|
|
349
|
+
terminalInfo: TerminalInfo
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sendNotification(options: NotificationOptions): void {
|
|
353
|
+
const { title, message, sound, terminalInfo } = options
|
|
354
|
+
|
|
355
|
+
// Use Kitty notifications if available and on supported platform
|
|
356
|
+
if (terminalInfo.name?.toLowerCase() === "kitty" && process.platform === "linux") {
|
|
357
|
+
// Use default Kitty settings (simpler approach)
|
|
358
|
+
sendKittyNotification({
|
|
359
|
+
title,
|
|
360
|
+
message,
|
|
361
|
+
sound, // Kitty doesn't support sounds, but we pass it for fallback
|
|
362
|
+
focusOnClick: true,
|
|
363
|
+
})
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Fall back to system notifications
|
|
368
|
+
const notifyOptions: Record<string, unknown> = {
|
|
369
|
+
title,
|
|
370
|
+
message,
|
|
371
|
+
sound,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// macOS-specific: click notification to focus terminal
|
|
375
|
+
if (process.platform === "darwin" && terminalInfo.bundleId) {
|
|
376
|
+
notifyOptions.activate = terminalInfo.bundleId
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
notifier.notify(notifyOptions)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ==========================================
|
|
383
|
+
// EVENT HANDLERS
|
|
384
|
+
// ==========================================
|
|
385
|
+
|
|
386
|
+
async function handleSessionIdle(
|
|
387
|
+
client: OpencodeClient,
|
|
388
|
+
sessionID: string,
|
|
389
|
+
config: NotifyConfig,
|
|
390
|
+
terminalInfo: TerminalInfo,
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
// Check if we should notify for this session
|
|
393
|
+
if (!config.notifyChildSessions) {
|
|
394
|
+
const isParent = await isParentSession(client, sessionID)
|
|
395
|
+
if (!isParent) return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check quiet hours
|
|
399
|
+
if (isQuietHours(config)) return
|
|
400
|
+
|
|
401
|
+
// Check if terminal is focused (suppress notification if user is already looking)
|
|
402
|
+
if (await isTerminalFocused(terminalInfo)) return
|
|
403
|
+
|
|
404
|
+
// Get session info for context
|
|
405
|
+
let sessionTitle = "Task"
|
|
406
|
+
try {
|
|
407
|
+
const session = await client.session.get({ path: { id: sessionID } })
|
|
408
|
+
if (session.data?.title) {
|
|
409
|
+
sessionTitle = session.data.title.slice(0, 50)
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// Use default title
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
sendNotification({
|
|
416
|
+
title: "Ready for review",
|
|
417
|
+
message: sessionTitle,
|
|
418
|
+
sound: config.sounds.idle,
|
|
419
|
+
terminalInfo,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function handleSessionError(
|
|
424
|
+
client: OpencodeClient,
|
|
425
|
+
sessionID: string,
|
|
426
|
+
error: string | undefined,
|
|
427
|
+
config: NotifyConfig,
|
|
428
|
+
terminalInfo: TerminalInfo,
|
|
429
|
+
): Promise<void> {
|
|
430
|
+
// Check if we should notify for this session
|
|
431
|
+
if (!config.notifyChildSessions) {
|
|
432
|
+
const isParent = await isParentSession(client, sessionID)
|
|
433
|
+
if (!isParent) return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Check quiet hours
|
|
437
|
+
if (isQuietHours(config)) return
|
|
438
|
+
|
|
439
|
+
// Check if terminal is focused (suppress notification if user is already looking)
|
|
440
|
+
if (await isTerminalFocused(terminalInfo)) return
|
|
441
|
+
|
|
442
|
+
const errorMessage = error?.slice(0, 100) || "Something went wrong"
|
|
443
|
+
|
|
444
|
+
sendNotification({
|
|
445
|
+
title: "Something went wrong",
|
|
446
|
+
message: errorMessage,
|
|
447
|
+
sound: config.sounds.error,
|
|
448
|
+
terminalInfo,
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function handlePermissionUpdated(
|
|
453
|
+
config: NotifyConfig,
|
|
454
|
+
terminalInfo: TerminalInfo,
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
// Always notify for permission events - AI is blocked waiting for human
|
|
457
|
+
// No parent check needed: permissions always need human attention
|
|
458
|
+
|
|
459
|
+
// Check quiet hours
|
|
460
|
+
if (isQuietHours(config)) return
|
|
461
|
+
|
|
462
|
+
// Check if terminal is focused (suppress notification if user is already looking)
|
|
463
|
+
if (await isTerminalFocused(terminalInfo)) return
|
|
464
|
+
|
|
465
|
+
sendNotification({
|
|
466
|
+
title: "Waiting for you",
|
|
467
|
+
message: "OpenCode needs your input",
|
|
468
|
+
sound: config.sounds.permission,
|
|
469
|
+
terminalInfo,
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ==========================================
|
|
474
|
+
// PLUGIN EXPORT
|
|
475
|
+
// ==========================================
|
|
476
|
+
|
|
477
|
+
export const NotifyPlugin: Plugin = async (ctx) => {
|
|
478
|
+
const { client } = ctx
|
|
479
|
+
|
|
480
|
+
// Load config once at startup
|
|
481
|
+
const config = await loadConfig()
|
|
482
|
+
|
|
483
|
+
// Detect terminal once at startup (cached for performance)
|
|
484
|
+
const terminalInfo = await detectTerminalInfo(config)
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
event: async ({ event }: { event: Event }): Promise<void> => {
|
|
488
|
+
switch (event.type) {
|
|
489
|
+
case "session.idle": {
|
|
490
|
+
const sessionID = event.properties.sessionID
|
|
491
|
+
if (sessionID) {
|
|
492
|
+
await handleSessionIdle(client as OpencodeClient, sessionID, config, terminalInfo)
|
|
493
|
+
}
|
|
494
|
+
break
|
|
495
|
+
}
|
|
496
|
+
case "session.error": {
|
|
497
|
+
const sessionID = event.properties.sessionID
|
|
498
|
+
const error = event.properties.error
|
|
499
|
+
const errorMessage = typeof error === "string" ? error : error ? String(error) : undefined
|
|
500
|
+
if (sessionID) {
|
|
501
|
+
await handleSessionError(
|
|
502
|
+
client as OpencodeClient,
|
|
503
|
+
sessionID,
|
|
504
|
+
errorMessage,
|
|
505
|
+
config,
|
|
506
|
+
terminalInfo,
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
break
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
case "permission.updated": {
|
|
513
|
+
await handlePermissionUpdated(config, terminalInfo)
|
|
514
|
+
break
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export default NotifyPlugin
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project ID generation for kdco registry plugins.
|
|
3
|
+
*
|
|
4
|
+
* Generates a stable, unique identifier for a project based on its git history.
|
|
5
|
+
* Used for cross-worktree consistency in delegation storage, state databases,
|
|
6
|
+
* and other plugin data that should be shared across worktrees.
|
|
7
|
+
*
|
|
8
|
+
* @module kdco-primitives/get-project-id
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as crypto from "node:crypto"
|
|
12
|
+
import { stat } from "node:fs/promises"
|
|
13
|
+
import * as path from "node:path"
|
|
14
|
+
import { logWarn } from "./log-warn"
|
|
15
|
+
import type { OpencodeClient } from "./types"
|
|
16
|
+
import { TimeoutError, withTimeout } from "./with-timeout"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate a short hash from a path for project ID fallback.
|
|
20
|
+
*
|
|
21
|
+
* Used when git root commit is unavailable (non-git repos, empty repos).
|
|
22
|
+
* Produces a 16-character hex string for reasonable uniqueness.
|
|
23
|
+
*
|
|
24
|
+
* @param projectRoot - Absolute path to hash
|
|
25
|
+
* @returns 16-char hex hash
|
|
26
|
+
*/
|
|
27
|
+
function hashPath(projectRoot: string): string {
|
|
28
|
+
const hash = crypto.createHash("sha256").update(projectRoot).digest("hex")
|
|
29
|
+
return hash.slice(0, 16)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a unique project ID from the project root path.
|
|
34
|
+
*
|
|
35
|
+
* **Strategy:**
|
|
36
|
+
* 1. Uses the first root commit SHA for stability across renames/moves
|
|
37
|
+
* 2. Falls back to path hash for non-git repos or empty repos
|
|
38
|
+
* 3. Caches result in .git/opencode for performance
|
|
39
|
+
*
|
|
40
|
+
* **Git Worktree Support:**
|
|
41
|
+
* When .git is a file (worktree), resolves the actual .git directory
|
|
42
|
+
* and uses the shared cache. This ensures all worktrees share the same
|
|
43
|
+
* project ID and associated data.
|
|
44
|
+
*
|
|
45
|
+
* @param projectRoot - Absolute path to the project root
|
|
46
|
+
* @param client - Optional OpenCode client for logging warnings
|
|
47
|
+
* @returns 40-char hex SHA (git root) or 16-char hash (fallback)
|
|
48
|
+
* @throws {Error} When projectRoot is invalid or .git file has invalid format
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const projectId = await getProjectId("/home/user/my-repo")
|
|
53
|
+
* // Returns: "abc123..." (40-char git hash)
|
|
54
|
+
*
|
|
55
|
+
* const projectId = await getProjectId("/home/user/non-git-folder")
|
|
56
|
+
* // Returns: "def456..." (16-char path hash)
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export async function getProjectId(projectRoot: string, client?: OpencodeClient): Promise<string> {
|
|
60
|
+
// Guard: Validate projectRoot (Law 1: Early Exit, Law 4: Fail Fast)
|
|
61
|
+
if (!projectRoot || typeof projectRoot !== "string") {
|
|
62
|
+
throw new Error("getProjectId: projectRoot is required and must be a string")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const gitPath = path.join(projectRoot, ".git")
|
|
66
|
+
|
|
67
|
+
// Check if .git exists and what type it is
|
|
68
|
+
const gitStat = await stat(gitPath).catch(() => null)
|
|
69
|
+
|
|
70
|
+
// Guard: No .git directory - not a git repo (Law 1: Early Exit)
|
|
71
|
+
if (!gitStat) {
|
|
72
|
+
logWarn(client, "project-id", `No .git found at ${projectRoot}, using path hash`)
|
|
73
|
+
return hashPath(projectRoot)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let gitDir = gitPath
|
|
77
|
+
|
|
78
|
+
// Handle worktree case: .git is a file containing gitdir reference
|
|
79
|
+
if (gitStat.isFile()) {
|
|
80
|
+
const content = await Bun.file(gitPath).text()
|
|
81
|
+
const match = content.match(/^gitdir:\s*(.+)$/m)
|
|
82
|
+
|
|
83
|
+
// Guard: Invalid .git file format (Law 4: Fail Fast)
|
|
84
|
+
if (!match) {
|
|
85
|
+
throw new Error(`getProjectId: .git file exists but has invalid format at ${gitPath}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve path (handles both relative and absolute)
|
|
89
|
+
const gitdirPath = match[1].trim()
|
|
90
|
+
const resolvedGitdir = path.resolve(projectRoot, gitdirPath)
|
|
91
|
+
|
|
92
|
+
// The gitdir contains a 'commondir' file pointing to shared .git
|
|
93
|
+
const commondirPath = path.join(resolvedGitdir, "commondir")
|
|
94
|
+
const commondirFile = Bun.file(commondirPath)
|
|
95
|
+
|
|
96
|
+
if (await commondirFile.exists()) {
|
|
97
|
+
const commondirContent = (await commondirFile.text()).trim()
|
|
98
|
+
gitDir = path.resolve(resolvedGitdir, commondirContent)
|
|
99
|
+
} else {
|
|
100
|
+
// Fallback to ../.. assumption for older git or unusual setups
|
|
101
|
+
gitDir = path.resolve(resolvedGitdir, "../..")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Guard: Resolved gitdir must be a directory (Law 4: Fail Fast)
|
|
105
|
+
const gitDirStat = await stat(gitDir).catch(() => null)
|
|
106
|
+
if (!gitDirStat?.isDirectory()) {
|
|
107
|
+
throw new Error(`getProjectId: Resolved gitdir ${gitDir} is not a directory`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check cache in .git/opencode
|
|
112
|
+
const cacheFile = path.join(gitDir, "opencode")
|
|
113
|
+
const cache = Bun.file(cacheFile)
|
|
114
|
+
|
|
115
|
+
if (await cache.exists()) {
|
|
116
|
+
const cached = (await cache.text()).trim()
|
|
117
|
+
// Validate cache content (40-char hex for git hash, or 16-char for path hash)
|
|
118
|
+
if (/^[a-f0-9]{40}$/i.test(cached) || /^[a-f0-9]{16}$/i.test(cached)) {
|
|
119
|
+
return cached
|
|
120
|
+
}
|
|
121
|
+
logWarn(client, "project-id", `Invalid cache content at ${cacheFile}, regenerating`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Generate project ID from git root commit
|
|
125
|
+
try {
|
|
126
|
+
const proc = Bun.spawn(["git", "rev-list", "--max-parents=0", "--all"], {
|
|
127
|
+
cwd: projectRoot,
|
|
128
|
+
stdout: "pipe",
|
|
129
|
+
stderr: "pipe",
|
|
130
|
+
env: { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined },
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// 5 second timeout to prevent hangs on network filesystems
|
|
134
|
+
const timeoutMs = 5000
|
|
135
|
+
const exitCode = await withTimeout(proc.exited, timeoutMs, `git rev-list timed out`).catch(
|
|
136
|
+
(e) => {
|
|
137
|
+
if (e instanceof TimeoutError) {
|
|
138
|
+
proc.kill()
|
|
139
|
+
}
|
|
140
|
+
return 1 // Treat timeout/errors as failure, fall back to path hash
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if (exitCode === 0) {
|
|
145
|
+
const output = await new Response(proc.stdout).text()
|
|
146
|
+
const roots = output
|
|
147
|
+
.split("\n")
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.map((x) => x.trim())
|
|
150
|
+
.sort()
|
|
151
|
+
|
|
152
|
+
if (roots.length > 0 && /^[a-f0-9]{40}$/i.test(roots[0])) {
|
|
153
|
+
const projectId = roots[0]
|
|
154
|
+
// Cache the result
|
|
155
|
+
try {
|
|
156
|
+
await Bun.write(cacheFile, projectId)
|
|
157
|
+
} catch (e) {
|
|
158
|
+
logWarn(client, "project-id", `Failed to cache project ID: ${e}`)
|
|
159
|
+
}
|
|
160
|
+
return projectId
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const stderr = await new Response(proc.stderr).text()
|
|
164
|
+
logWarn(client, "project-id", `git rev-list failed (${exitCode}): ${stderr.trim()}`)
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logWarn(client, "project-id", `git command failed: ${error}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fallback to path hash
|
|
171
|
+
return hashPath(projectRoot)
|
|
172
|
+
}
|