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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared primitives for kdco registry plugins.
|
|
3
|
+
*
|
|
4
|
+
* This module provides common utilities extracted from multiple plugin files
|
|
5
|
+
* to eliminate duplication and ensure consistent behavior across plugins.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Project identification
|
|
11
|
+
export { getProjectId } from "./get-project-id"
|
|
12
|
+
|
|
13
|
+
// Logging
|
|
14
|
+
export { logWarn } from "./log-warn"
|
|
15
|
+
// Concurrency
|
|
16
|
+
export { Mutex } from "./mutex"
|
|
17
|
+
// Shell escaping
|
|
18
|
+
export { assertShellSafe, escapeAppleScript, escapeBash, escapeBatch } from "./shell"
|
|
19
|
+
// Temp directory
|
|
20
|
+
export { getTempDir } from "./temp"
|
|
21
|
+
// Terminal detection
|
|
22
|
+
export { isInsideTmux } from "./terminal-detect"
|
|
23
|
+
// Types
|
|
24
|
+
export type { OpencodeClient } from "./types"
|
|
25
|
+
// Timeout handling
|
|
26
|
+
export { TimeoutError, withTimeout } from "./with-timeout"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warning logger for kdco registry plugins.
|
|
3
|
+
*
|
|
4
|
+
* Provides a unified interface for logging warnings that works with
|
|
5
|
+
* both the OpenCode client (when available) and console fallback.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives/log-warn
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { OpencodeClient } from "./types"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log a warning message via OpenCode client or console fallback.
|
|
14
|
+
*
|
|
15
|
+
* Uses the OpenCode logging API when a client is available, which integrates
|
|
16
|
+
* with the OpenCode UI log panel. Falls back to console.warn for CLI contexts
|
|
17
|
+
* or when no client is provided.
|
|
18
|
+
*
|
|
19
|
+
* @param client - Optional OpenCode client for proper logging integration
|
|
20
|
+
* @param service - Service name for log categorization (e.g., "worktree", "delegation")
|
|
21
|
+
* @param message - Warning message to log
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* // With client - logs to OpenCode UI
|
|
26
|
+
* logWarn(client, "delegation", "Task timed out after 30s")
|
|
27
|
+
*
|
|
28
|
+
* // Without client - logs to console
|
|
29
|
+
* logWarn(undefined, "delegation", "Task timed out after 30s")
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function logWarn(
|
|
33
|
+
client: OpencodeClient | undefined,
|
|
34
|
+
service: string,
|
|
35
|
+
message: string,
|
|
36
|
+
): void {
|
|
37
|
+
// Guard: No client available, use console fallback (Law 1: Early Exit)
|
|
38
|
+
if (!client) {
|
|
39
|
+
console.warn(`[${service}] ${message}`)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Happy path: Use OpenCode logging API
|
|
44
|
+
client.app
|
|
45
|
+
.log({
|
|
46
|
+
body: { service, level: "warn", message },
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {
|
|
49
|
+
// Silently ignore logging failures - don't disrupt caller
|
|
50
|
+
})
|
|
51
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promise-based mutex for serializing async operations.
|
|
3
|
+
*
|
|
4
|
+
* Provides a simple lock mechanism using native Promise mechanics.
|
|
5
|
+
* No external dependencies required.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives/mutex
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Simple promise-based mutex for serializing async operations.
|
|
12
|
+
*
|
|
13
|
+
* Uses a queue of pending waiters to ensure fair ordering.
|
|
14
|
+
* Each waiter is resolved in FIFO order when the lock is released.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const mutex = new Mutex()
|
|
19
|
+
*
|
|
20
|
+
* // Option 1: Manual acquire/release (use try-finally!)
|
|
21
|
+
* await mutex.acquire()
|
|
22
|
+
* try {
|
|
23
|
+
* await criticalSection()
|
|
24
|
+
* } finally {
|
|
25
|
+
* mutex.release()
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* // Option 2: Automatic acquire/release (preferred)
|
|
29
|
+
* const result = await mutex.runExclusive(async () => {
|
|
30
|
+
* return await criticalSection()
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export class Mutex {
|
|
35
|
+
private locked = false
|
|
36
|
+
private queue: (() => void)[] = []
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Acquire the mutex lock.
|
|
40
|
+
*
|
|
41
|
+
* If the mutex is unlocked, immediately acquires and returns.
|
|
42
|
+
* If locked, waits in queue until released by current holder.
|
|
43
|
+
*
|
|
44
|
+
* @returns Promise that resolves when lock is acquired
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* await mutex.acquire()
|
|
49
|
+
* try {
|
|
50
|
+
* // Critical section - only one caller at a time
|
|
51
|
+
* } finally {
|
|
52
|
+
* mutex.release() // Always release in finally!
|
|
53
|
+
* }
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
async acquire(): Promise<void> {
|
|
57
|
+
// Fast path: lock is available (Law 1: Early Exit)
|
|
58
|
+
if (!this.locked) {
|
|
59
|
+
this.locked = true
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Slow path: wait in queue for lock release
|
|
64
|
+
return new Promise<void>((resolve) => {
|
|
65
|
+
this.queue.push(resolve)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Release the mutex lock.
|
|
71
|
+
*
|
|
72
|
+
* If waiters are queued, passes the lock to the next waiter (FIFO).
|
|
73
|
+
* Otherwise, marks the mutex as unlocked.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* mutex.release() // Must be called after acquire()
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
release(): void {
|
|
81
|
+
const next = this.queue.shift()
|
|
82
|
+
if (next) {
|
|
83
|
+
// Pass lock to next waiter (stays locked)
|
|
84
|
+
next()
|
|
85
|
+
} else {
|
|
86
|
+
// No waiters, unlock
|
|
87
|
+
this.locked = false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Execute a function exclusively under mutex protection.
|
|
93
|
+
*
|
|
94
|
+
* Automatically acquires the lock before execution and releases
|
|
95
|
+
* after completion, even if the function throws an error.
|
|
96
|
+
* This is the preferred way to use the mutex.
|
|
97
|
+
*
|
|
98
|
+
* @param fn - Async function to execute exclusively
|
|
99
|
+
* @returns The function's return value
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* // Serialize tmux commands to prevent socket races
|
|
104
|
+
* const result = await tmuxMutex.runExclusive(async () => {
|
|
105
|
+
* return await execTmuxCommand(["list-windows"])
|
|
106
|
+
* })
|
|
107
|
+
*
|
|
108
|
+
* // Serialize database writes
|
|
109
|
+
* await dbMutex.runExclusive(async () => {
|
|
110
|
+
* await db.update(record)
|
|
111
|
+
* })
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
115
|
+
await this.acquire()
|
|
116
|
+
try {
|
|
117
|
+
return await fn()
|
|
118
|
+
} finally {
|
|
119
|
+
this.release()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell escaping utilities for cross-platform terminal commands.
|
|
3
|
+
*
|
|
4
|
+
* Provides safe escaping functions for Bash, Windows Batch, and AppleScript.
|
|
5
|
+
* All functions validate input for forbidden characters before escaping.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives/shell
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Characters that cannot be safely escaped in any shell.
|
|
12
|
+
* Null bytes (\x00) cannot be represented in C strings and must be rejected.
|
|
13
|
+
*/
|
|
14
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Null byte detection is intentional for security
|
|
15
|
+
const SHELL_FORBIDDEN_CHARS = /[\x00]/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Assert that a string is safe for shell escaping.
|
|
19
|
+
*
|
|
20
|
+
* Null bytes cannot be escaped in any shell and must be rejected outright.
|
|
21
|
+
* This is the first line of defense before any escaping is attempted.
|
|
22
|
+
*
|
|
23
|
+
* @param value - String to validate
|
|
24
|
+
* @param context - Description for error message (e.g., "Bash argument")
|
|
25
|
+
* @throws {Error} if string contains forbidden characters
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* assertShellSafe(userInput, "Bash argument")
|
|
30
|
+
* // Throws: "Bash argument contains null bytes..."
|
|
31
|
+
*
|
|
32
|
+
* assertShellSafe(filePath, "Script path")
|
|
33
|
+
* // Throws: "Script path contains null bytes..."
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function assertShellSafe(value: string, context: string): void {
|
|
37
|
+
// Law 4: Fail Fast - reject invalid input immediately with clear message
|
|
38
|
+
if (SHELL_FORBIDDEN_CHARS.test(value)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`${context} contains null bytes which cannot be safely escaped for shell execution`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Escape a string for safe use in bash double-quoted strings.
|
|
47
|
+
*
|
|
48
|
+
* Handles all shell metacharacters including:
|
|
49
|
+
* - Backslash (\), double quote ("), dollar ($), backtick (`)
|
|
50
|
+
* - Exclamation mark (!) for history expansion
|
|
51
|
+
* - Newlines and carriage returns (replaced with spaces)
|
|
52
|
+
*
|
|
53
|
+
* @param str - String to escape
|
|
54
|
+
* @returns Escaped string safe for bash double-quoted context
|
|
55
|
+
* @throws {Error} if string contains null bytes
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const path = '/home/user/my "project"'
|
|
60
|
+
* const cmd = `cd "${escapeBash(path)}"`
|
|
61
|
+
* // Result: cd "/home/user/my \"project\""
|
|
62
|
+
*
|
|
63
|
+
* const var = '$HOME/file'
|
|
64
|
+
* const cmd = `echo "${escapeBash(var)}"`
|
|
65
|
+
* // Result: echo "\$HOME/file"
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function escapeBash(str: string): string {
|
|
69
|
+
assertShellSafe(str, "Bash argument")
|
|
70
|
+
return str
|
|
71
|
+
.replace(/\\/g, "\\\\") // Backslash first (order matters!)
|
|
72
|
+
.replace(/"/g, '\\"') // Double quotes
|
|
73
|
+
.replace(/\$/g, "\\$") // Dollar sign (variable expansion)
|
|
74
|
+
.replace(/`/g, "\\`") // Backticks (command substitution)
|
|
75
|
+
.replace(/!/g, "\\!") // History expansion
|
|
76
|
+
.replace(/\n/g, " ") // Newlines -> spaces
|
|
77
|
+
.replace(/\r/g, " ") // Carriage returns -> spaces
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Escape a string for safe use in AppleScript double-quoted strings.
|
|
82
|
+
*
|
|
83
|
+
* AppleScript uses different escaping rules than POSIX shells.
|
|
84
|
+
* Only backslash and double quote need escaping.
|
|
85
|
+
* Newlines are replaced with spaces (AppleScript doesn't support \n escapes).
|
|
86
|
+
*
|
|
87
|
+
* @param str - String to escape
|
|
88
|
+
* @returns Escaped string safe for AppleScript double-quoted context
|
|
89
|
+
* @throws {Error} if string contains null bytes
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* const path = '/Users/name/my "project"'
|
|
94
|
+
* const script = `tell application "Terminal" to write text "${escapeAppleScript(path)}"`
|
|
95
|
+
* // Result: ... write text "/Users/name/my \"project\""
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function escapeAppleScript(str: string): string {
|
|
99
|
+
assertShellSafe(str, "AppleScript argument")
|
|
100
|
+
return str
|
|
101
|
+
.replace(/\\/g, "\\\\") // Backslash
|
|
102
|
+
.replace(/"/g, '\\"') // Double quotes
|
|
103
|
+
.replace(/\n/g, " ") // Newlines -> spaces
|
|
104
|
+
.replace(/\r/g, " ") // Carriage returns -> spaces
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Escape a string for safe use in Windows batch files.
|
|
109
|
+
*
|
|
110
|
+
* Handles batch metacharacters:
|
|
111
|
+
* - Percent (%), caret (^), ampersand (&)
|
|
112
|
+
* - Less than (<), greater than (>), pipe (|)
|
|
113
|
+
*
|
|
114
|
+
* @param str - String to escape
|
|
115
|
+
* @returns Escaped string safe for batch file context
|
|
116
|
+
* @throws {Error} if string contains null bytes
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* const path = 'C:\\Users\\name\\project & files'
|
|
121
|
+
* const cmd = `cd /d "${escapeBatch(path)}"`
|
|
122
|
+
* // Result: cd /d "C:\Users\name\project ^& files"
|
|
123
|
+
*
|
|
124
|
+
* const var = '100%'
|
|
125
|
+
* const cmd = `echo ${escapeBatch(var)}`
|
|
126
|
+
* // Result: echo 100%%
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function escapeBatch(str: string): string {
|
|
130
|
+
assertShellSafe(str, "Batch argument")
|
|
131
|
+
return str
|
|
132
|
+
.replace(/%/g, "%%") // Percent (double to escape)
|
|
133
|
+
.replace(/\^/g, "^^") // Caret (escape character itself)
|
|
134
|
+
.replace(/&/g, "^&") // Ampersand
|
|
135
|
+
.replace(/</g, "^<") // Less than
|
|
136
|
+
.replace(/>/g, "^>") // Greater than
|
|
137
|
+
.replace(/\|/g, "^|") // Pipe
|
|
138
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temp directory utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides a reliable temp directory path that resolves symlinks,
|
|
5
|
+
* which is critical on macOS where os.tmpdir() returns a symlink.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives/temp
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fsSync from "node:fs"
|
|
11
|
+
import * as os from "node:os"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the real temp directory path, resolving symlinks.
|
|
15
|
+
*
|
|
16
|
+
* This is critical for macOS where `os.tmpdir()` returns `/var/folders/...`
|
|
17
|
+
* which is actually a symlink to `/private/var/folders/...`. Many tools
|
|
18
|
+
* (including Bun's test harness, VS Code, and Eclipse Theia) need the
|
|
19
|
+
* resolved real path for proper file watching and path comparisons.
|
|
20
|
+
*
|
|
21
|
+
* @returns The real (resolved) temp directory path
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const tempDir = getTempDir()
|
|
26
|
+
* // macOS: "/private/var/folders/xx/xxxxx/T" (resolved)
|
|
27
|
+
* // Linux: "/tmp" (usually not a symlink)
|
|
28
|
+
* // Windows: "C:\\Users\\name\\AppData\\Local\\Temp"
|
|
29
|
+
*
|
|
30
|
+
* // Use for creating temp files
|
|
31
|
+
* const tempFile = path.join(getTempDir(), `script-${Date.now()}.sh`)
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function getTempDir(): string {
|
|
35
|
+
return fsSync.realpathSync.native(os.tmpdir())
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal detection utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to detect the current terminal environment,
|
|
5
|
+
* particularly useful for choosing terminal-specific behaviors.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives/terminal-detect
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if the current process is running inside a tmux session.
|
|
12
|
+
*
|
|
13
|
+
* Detects tmux by checking the TMUX environment variable, which tmux
|
|
14
|
+
* sets to the socket path and session info when spawning child processes.
|
|
15
|
+
*
|
|
16
|
+
* @returns `true` if running inside tmux, `false` otherwise
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* if (isInsideTmux()) {
|
|
21
|
+
* // Use tmux-specific commands (new-window, split-pane, etc.)
|
|
22
|
+
* await openTmuxWindow({ windowName: "dev", cwd: projectDir })
|
|
23
|
+
* } else {
|
|
24
|
+
* // Fall back to platform-specific terminal
|
|
25
|
+
* await openPlatformTerminal(projectDir)
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function isInsideTmux(): boolean {
|
|
30
|
+
// Law 1: Early Exit - simple boolean check, no complex parsing needed
|
|
31
|
+
// The TMUX env var contains socket info like "/tmp/tmux-1000/default,12345,0"
|
|
32
|
+
// We only care if it's set (truthy), not its contents
|
|
33
|
+
return !!process.env.TMUX
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for kdco registry plugins.
|
|
3
|
+
*
|
|
4
|
+
* @module kdco-primitives/types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* OpenCode client instance type.
|
|
11
|
+
* Derived from the factory function return type for type safety.
|
|
12
|
+
*/
|
|
13
|
+
export type OpencodeClient = ReturnType<typeof createOpencodeClient>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promise timeout utility for kdco registry plugins.
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean wrapper around Promise.race for timeout handling,
|
|
5
|
+
* replacing inline timeout patterns throughout the codebase.
|
|
6
|
+
*
|
|
7
|
+
* @module kdco-primitives/with-timeout
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Error thrown when a promise times out.
|
|
12
|
+
* Extends Error for proper instanceof checks and stack traces.
|
|
13
|
+
*/
|
|
14
|
+
export class TimeoutError extends Error {
|
|
15
|
+
readonly name = "TimeoutError" as const
|
|
16
|
+
readonly timeoutMs: number
|
|
17
|
+
|
|
18
|
+
constructor(message: string, timeoutMs: number) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.timeoutMs = timeoutMs
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wraps a promise with a timeout.
|
|
26
|
+
*
|
|
27
|
+
* Uses Promise.race to implement timeout semantics. If the wrapped promise
|
|
28
|
+
* doesn't resolve within the specified time, throws a TimeoutError.
|
|
29
|
+
*
|
|
30
|
+
* **Important:** This does NOT abort the underlying promise - it continues
|
|
31
|
+
* running in the background. Use AbortController for true cancellation.
|
|
32
|
+
*
|
|
33
|
+
* @param promise - The promise to wrap
|
|
34
|
+
* @param ms - Timeout in milliseconds
|
|
35
|
+
* @param message - Optional error message (defaults to "Operation timed out")
|
|
36
|
+
* @returns The resolved value from the promise
|
|
37
|
+
* @throws {TimeoutError} When the timeout expires before the promise resolves
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* // Basic usage
|
|
42
|
+
* const result = await withTimeout(
|
|
43
|
+
* fetchData(),
|
|
44
|
+
* 5000,
|
|
45
|
+
* "Data fetch timed out"
|
|
46
|
+
* )
|
|
47
|
+
*
|
|
48
|
+
* // Handling timeout
|
|
49
|
+
* try {
|
|
50
|
+
* const result = await withTimeout(slowOperation(), 1000)
|
|
51
|
+
* } catch (error) {
|
|
52
|
+
* if (error instanceof TimeoutError) {
|
|
53
|
+
* console.log(`Timed out after ${error.timeoutMs}ms`)
|
|
54
|
+
* }
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export async function withTimeout<T>(
|
|
59
|
+
promise: Promise<T>,
|
|
60
|
+
ms: number,
|
|
61
|
+
message = "Operation timed out",
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
// Guard: Invalid timeout value (Law 1: Early Exit, Law 4: Fail Fast)
|
|
64
|
+
if (typeof ms !== "number" || ms < 0) {
|
|
65
|
+
throw new Error(`withTimeout: timeout must be a non-negative number, got ${ms}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Guard: Zero timeout means immediate rejection
|
|
69
|
+
if (ms === 0) {
|
|
70
|
+
throw new TimeoutError(message, ms)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Race between the promise and a timeout
|
|
74
|
+
// Clear timer when promise resolves to prevent leaks
|
|
75
|
+
let timeoutId: Timer
|
|
76
|
+
return Promise.race([
|
|
77
|
+
promise.finally(() => clearTimeout(timeoutId)),
|
|
78
|
+
new Promise<never>((_, reject) => {
|
|
79
|
+
timeoutId = setTimeout(() => {
|
|
80
|
+
reject(new TimeoutError(message, ms))
|
|
81
|
+
}, ms)
|
|
82
|
+
}),
|
|
83
|
+
])
|
|
84
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test script for Kitty notification improvements
|
|
5
|
+
* This script will test the Kitty-specific features we've added
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { execSync } = require('child_process')
|
|
9
|
+
|
|
10
|
+
console.log('Testing Kitty notification improvements...\n')
|
|
11
|
+
|
|
12
|
+
// Test 1: Check if we're in Kitty
|
|
13
|
+
const isKitty = process.env.TERM?.toLowerCase().includes('kitty') ||
|
|
14
|
+
process.env.TERMINAL?.toLowerCase().includes('kitty') ||
|
|
15
|
+
'kitty'
|
|
16
|
+
|
|
17
|
+
console.log('1. Kitty Detection:')
|
|
18
|
+
console.log(` TERM: ${process.env.TERM || 'undefined'}`)
|
|
19
|
+
console.log(` TERMINAL: ${process.env.TERMINAL || 'undefined'}`)
|
|
20
|
+
console.log(` Detected as Kitty: ${isKitty ? 'Yes' : 'No'}`)
|
|
21
|
+
|
|
22
|
+
// Test 2: Check WINDOWID (Kitty specific)
|
|
23
|
+
const windowId = process.env.WINDOWID
|
|
24
|
+
console.log('\n2. Kitty Window ID:')
|
|
25
|
+
console.log(` WINDOWID: ${windowId || 'undefined'}`)
|
|
26
|
+
|
|
27
|
+
// Test 3: Test xprop availability (for focus detection)
|
|
28
|
+
try {
|
|
29
|
+
const xpropVersion = execSync('xprop -version', { encoding: 'utf8' })
|
|
30
|
+
console.log('\n3. xprop availability:')
|
|
31
|
+
console.log(` Available: Yes`)
|
|
32
|
+
console.log(` Version: ${xpropVersion.split('\n')[0]}`)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.log('\n3. xprop availability:')
|
|
35
|
+
console.log(` Available: No (this is expected on macOS)`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Test 4: Test environment detection
|
|
39
|
+
console.log('\n4. Platform Detection:')
|
|
40
|
+
console.log(` Platform: ${process.platform}`)
|
|
41
|
+
console.log(` Architecture: ${process.arch}`)
|
|
42
|
+
|
|
43
|
+
// Test 5: Test if we can detect focus (simplified)
|
|
44
|
+
console.log('\n5. Focus Detection Test:')
|
|
45
|
+
if (process.platform === 'linux' && windowId) {
|
|
46
|
+
console.log(` Would test focus detection for Kitty window ${windowId}`)
|
|
47
|
+
console.log(` (This would use xprop to check _NET_ACTIVE_WINDOW)`)
|
|
48
|
+
} else if (process.platform === 'darwin') {
|
|
49
|
+
console.log(` Would use macOS Applescript for focus detection`)
|
|
50
|
+
} else {
|
|
51
|
+
console.log(` Focus detection not available on this platform`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('\n6. Kitty Notification Test:')
|
|
55
|
+
if (isKitty && process.platform === 'linux') {
|
|
56
|
+
console.log(' Sending test notification via Kitty OSC 99...')
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Send a test Kitty notification
|
|
60
|
+
const title = 'OpenCode Notify Test'
|
|
61
|
+
const message = 'Kitty notification integration working!'
|
|
62
|
+
const escapeSequence = `\x1b]99;i=1:d=1;p=title;${title}\x1b\\\x1b]99;i=1:d=1;p=body;${message}\x1b\\`
|
|
63
|
+
|
|
64
|
+
process.stdout.write(escapeSequence)
|
|
65
|
+
console.log(' ✓ Test notification sent!')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.log(` ✗ Failed to send test notification: ${error.message}`)
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
console.log(' Skipping - not in Kitty on Linux')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log('\nTest completed!')
|