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/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
+ }