opencode-pilot 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.
Files changed (50) hide show
  1. package/.devcontainer/devcontainer.json +16 -0
  2. package/.github/workflows/ci.yml +67 -0
  3. package/.releaserc.cjs +28 -0
  4. package/AGENTS.md +71 -0
  5. package/CONTRIBUTING.md +102 -0
  6. package/LICENSE +21 -0
  7. package/README.md +72 -0
  8. package/bin/opencode-pilot +809 -0
  9. package/dist/opencode-ntfy.tar.gz +0 -0
  10. package/examples/config.yaml +73 -0
  11. package/examples/templates/default.md +7 -0
  12. package/examples/templates/devcontainer.md +7 -0
  13. package/examples/templates/review-feedback.md +7 -0
  14. package/examples/templates/review.md +15 -0
  15. package/install.sh +246 -0
  16. package/package.json +40 -0
  17. package/plugin/config.js +76 -0
  18. package/plugin/index.js +260 -0
  19. package/plugin/logger.js +125 -0
  20. package/plugin/notifier.js +110 -0
  21. package/service/actions.js +334 -0
  22. package/service/io.opencode.ntfy.plist +29 -0
  23. package/service/logger.js +82 -0
  24. package/service/poll-service.js +246 -0
  25. package/service/poller.js +339 -0
  26. package/service/readiness.js +234 -0
  27. package/service/repo-config.js +222 -0
  28. package/service/server.js +1523 -0
  29. package/service/utils.js +21 -0
  30. package/test/run_tests.bash +34 -0
  31. package/test/test_actions.bash +263 -0
  32. package/test/test_cli.bash +161 -0
  33. package/test/test_config.bash +438 -0
  34. package/test/test_helper.bash +140 -0
  35. package/test/test_logger.bash +401 -0
  36. package/test/test_notifier.bash +310 -0
  37. package/test/test_plist.bash +125 -0
  38. package/test/test_plugin.bash +952 -0
  39. package/test/test_poll_service.bash +179 -0
  40. package/test/test_poller.bash +120 -0
  41. package/test/test_readiness.bash +313 -0
  42. package/test/test_repo_config.bash +406 -0
  43. package/test/test_service.bash +1342 -0
  44. package/test/unit/actions.test.js +235 -0
  45. package/test/unit/config.test.js +86 -0
  46. package/test/unit/paths.test.js +77 -0
  47. package/test/unit/poll-service.test.js +142 -0
  48. package/test/unit/poller.test.js +347 -0
  49. package/test/unit/repo-config.test.js +441 -0
  50. package/test/unit/utils.test.js +53 -0
@@ -0,0 +1,260 @@
1
+ // opencode-pilot - ntfy notification plugin for OpenCode
2
+ //
3
+ // This plugin sends notifications via ntfy.sh when:
4
+ // - Session goes idle after delay
5
+ // - Errors or retries occur
6
+ //
7
+ // Configuration via ~/.config/opencode-pilot/config.yaml
8
+ // See README.md for full configuration options.
9
+
10
+ import { basename } from 'path'
11
+ import { sendNotification } from './notifier.js'
12
+ import { loadConfig } from './config.js'
13
+ import { initLogger, debug } from './logger.js'
14
+
15
+ /**
16
+ * Parse directory path to extract repo name (and branch if in devcontainer clone)
17
+ *
18
+ * Devcontainer clone paths follow the pattern:
19
+ * /path/.cache/devcontainer-clones/{repo}/{branch}
20
+ *
21
+ * For these paths, returns "{repo}/{branch}" to show both in notifications.
22
+ * For regular paths, returns just the basename.
23
+ *
24
+ * @param {string} directory - Directory path from OpenCode
25
+ * @returns {string} Repo name (with branch suffix for devcontainer clones)
26
+ */
27
+ function parseRepoInfo(directory) {
28
+ if (!directory) {
29
+ return 'unknown'
30
+ }
31
+
32
+ // Check for devcontainer-clones path pattern
33
+ const devcontainerMatch = directory.match(/devcontainer-clones\/([^/]+)\/([^/]+)$/)
34
+ if (devcontainerMatch) {
35
+ const [, repo, branch] = devcontainerMatch
36
+ return `${repo}/${branch}`
37
+ }
38
+
39
+ // Fall back to basename for regular directories
40
+ return basename(directory) || 'unknown'
41
+ }
42
+
43
+ // Load configuration from config file and environment
44
+ const config = loadConfig()
45
+
46
+ // Initialize debug logger (writes to file when enabled, no-op when disabled)
47
+ initLogger({ debug: config.debug, debugPath: config.debugPath })
48
+
49
+ const Notify = async ({ client, directory }) => {
50
+ if (!config.topic) {
51
+ debug('Plugin disabled: no topic configured')
52
+ return {}
53
+ }
54
+
55
+ // Use directory from OpenCode (the actual repo), not process.cwd() (may be temp devcontainer dir)
56
+ // For devcontainer clones, show both repo and branch name
57
+ const repoName = parseRepoInfo(directory)
58
+
59
+ debug(`Plugin initialized: topic=${config.topic}, repo=${repoName}`)
60
+
61
+ // Per-conversation state tracking (Issue #34)
62
+ // Each conversation has its own idle timer, cancel state, etc.
63
+ // Key: conversationId (OpenCode session ID)
64
+ const conversations = new Map()
65
+
66
+ // Session ownership verification (Issue #50)
67
+ // Prevents duplicate notifications when multiple OpenCode instances are running.
68
+ // Events may be broadcast to all plugin instances, so we verify each session
69
+ // belongs to our OpenCode instance before processing.
70
+ const verifiedSessions = new Set() // Sessions confirmed to be ours
71
+ const rejectedSessions = new Set() // Sessions confirmed to be foreign
72
+
73
+ // Global state (shared across all conversations in this plugin instance)
74
+ let retryCount = 0
75
+ let lastErrorTime = 0
76
+
77
+ // Helper to get or create conversation state (Issue #34)
78
+ const getConversation = (id) => {
79
+ if (!id) return null
80
+ if (!conversations.has(id)) {
81
+ conversations.set(id, { idleTimer: null, wasCanceled: false })
82
+ }
83
+ return conversations.get(id)
84
+ }
85
+
86
+ // Verify session ownership by checking if it exists in our OpenCode instance (Issue #50)
87
+ // Returns true if session belongs to us, false if it's foreign
88
+ const verifySessionOwnership = async (sessionId) => {
89
+ if (!sessionId) return false
90
+
91
+ // Already verified as ours
92
+ if (verifiedSessions.has(sessionId)) return true
93
+
94
+ // Already rejected as foreign
95
+ if (rejectedSessions.has(sessionId)) return false
96
+
97
+ // Query our OpenCode instance to check if session exists
98
+ try {
99
+ const result = await client.session.get({ id: sessionId })
100
+ if (result.data) {
101
+ verifiedSessions.add(sessionId)
102
+ return true
103
+ }
104
+ // Session doesn't exist in our instance - it's foreign
105
+ rejectedSessions.add(sessionId)
106
+ return false
107
+ } catch {
108
+ // Error (likely 404) means session doesn't exist in our instance
109
+ rejectedSessions.add(sessionId)
110
+ return false
111
+ }
112
+ }
113
+
114
+ return {
115
+ event: async ({ event }) => {
116
+ debug(`Event: ${event.type}`)
117
+
118
+ // Handle session status events (idle, busy, retry notifications)
119
+ if (event.type === 'session.status') {
120
+ const status = event.properties?.status?.type
121
+ // Extract conversation ID from event for per-conversation tracking (Issue #34)
122
+ // OpenCode uses sessionID (capital ID)
123
+ const conversationId = event.properties?.sessionID || event.properties?.info?.id
124
+ debug(`Event: session.status, status=${status}, sessionId=${conversationId || 'unknown'}`)
125
+
126
+ // Verify session belongs to our OpenCode instance (Issue #50)
127
+ // Skip events for sessions from other OpenCode instances to prevent duplicates
128
+ const isOurs = await verifySessionOwnership(conversationId)
129
+ if (!isOurs) return
130
+
131
+ const conv = getConversation(conversationId)
132
+
133
+ // Skip if no conversation ID or already canceled
134
+ if (!conv) return
135
+ if (conv.wasCanceled && status !== 'canceled') return
136
+
137
+ // Handle canceled status - suppress all future notifications for this conversation
138
+ if (status === 'canceled') {
139
+ conv.wasCanceled = true
140
+ if (conv.idleTimer) {
141
+ clearTimeout(conv.idleTimer)
142
+ conv.idleTimer = null
143
+ }
144
+ // Clean up conversation state
145
+ conversations.delete(conversationId)
146
+ return
147
+ }
148
+
149
+ // Handle retry status
150
+ if (status === 'retry') {
151
+ retryCount++
152
+
153
+ // Check if we should notify based on config
154
+ const shouldNotifyFirst = config.retryNotifyFirst && retryCount === 1
155
+ const shouldNotifyAfterN = config.retryNotifyAfter > 0 && retryCount === config.retryNotifyAfter
156
+
157
+ if (shouldNotifyFirst || shouldNotifyAfterN) {
158
+ await sendNotification({
159
+ server: config.server,
160
+ topic: config.topic,
161
+ title: `Retry (${repoName})`,
162
+ message: `Retry attempt #${retryCount}`,
163
+ priority: 4,
164
+ tags: ['repeat'],
165
+ authToken: config.authToken,
166
+ })
167
+ }
168
+ return
169
+ }
170
+
171
+ // Reset retry counter on any non-retry status
172
+ if (retryCount > 0) {
173
+ retryCount = 0
174
+ }
175
+
176
+ // Handle idle status - set timer for THIS conversation
177
+ if (status === 'idle' && !conv.idleTimer) {
178
+ debug(`Idle timer starting: ${config.idleDelayMs}ms for session ${conversationId}`)
179
+ // Capture conversation ID in closure so notification goes to correct conversation
180
+ const capturedSessionId = conversationId
181
+
182
+ conv.idleTimer = setTimeout(async () => {
183
+ // Clear timer reference immediately to prevent race conditions
184
+ const currentConv = conversations.get(capturedSessionId)
185
+ if (currentConv) {
186
+ currentConv.idleTimer = null
187
+ }
188
+
189
+ // Don't send notification if conversation was canceled
190
+ if (currentConv?.wasCanceled) {
191
+ return
192
+ }
193
+
194
+ await sendNotification({
195
+ server: config.server,
196
+ topic: config.topic,
197
+ title: `Idle (${repoName})`,
198
+ message: 'Session waiting for input',
199
+ authToken: config.authToken,
200
+ })
201
+ }, config.idleDelayMs)
202
+ } else if (status === 'busy' && conv.idleTimer) {
203
+ debug(`Idle timer cancelled: session ${conversationId} now busy`)
204
+ clearTimeout(conv.idleTimer)
205
+ conv.idleTimer = null
206
+ }
207
+ }
208
+
209
+ // Handle session.error events (debounced error notifications)
210
+ if (event.type === 'session.error') {
211
+ if (!config.errorNotify) {
212
+ return
213
+ }
214
+
215
+ const now = Date.now()
216
+ const timeSinceLastError = now - lastErrorTime
217
+
218
+ if (lastErrorTime === 0 || timeSinceLastError >= config.errorDebounceMs) {
219
+ lastErrorTime = now
220
+
221
+ // Extract error message with fallback chain
222
+ const errorMessage =
223
+ event.properties?.error?.message ||
224
+ event.properties?.message ||
225
+ event.properties?.error?.code ||
226
+ event.properties?.error?.type ||
227
+ 'Unknown error'
228
+
229
+ await sendNotification({
230
+ server: config.server,
231
+ topic: config.topic,
232
+ title: `Error (${repoName})`,
233
+ message: errorMessage,
234
+ priority: 5,
235
+ tags: ['warning'],
236
+ authToken: config.authToken,
237
+ })
238
+ }
239
+ }
240
+ },
241
+
242
+ // Cleanup on shutdown
243
+ shutdown: async () => {
244
+ // Clear all conversation idle timers
245
+ for (const [, conv] of conversations) {
246
+ if (conv.idleTimer) {
247
+ clearTimeout(conv.idleTimer)
248
+ conv.idleTimer = null
249
+ }
250
+ }
251
+ conversations.clear()
252
+
253
+ // Clear session ownership caches (Issue #50)
254
+ verifiedSessions.clear()
255
+ rejectedSessions.clear()
256
+ },
257
+ }
258
+ }
259
+
260
+ export default Notify
@@ -0,0 +1,125 @@
1
+ // Debug logging module for opencode-pilot plugin
2
+ // Writes to ~/.config/opencode-pilot/debug.log when enabled via NTFY_DEBUG=true or config.debug
3
+ //
4
+ // Usage:
5
+ // import { initLogger, debug } from './logger.js'
6
+ // initLogger({ debug: true, debugPath: '/custom/path.log' })
7
+ // debug('Event received', { type: 'session.status', status: 'idle' })
8
+
9
+ import { appendFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs'
10
+ import { join, dirname } from 'path'
11
+ import { homedir } from 'os'
12
+
13
+ // Maximum log file size before rotation (1MB)
14
+ export const MAX_LOG_SIZE = 1024 * 1024
15
+
16
+ // Default log path
17
+ const DEFAULT_LOG_PATH = join(homedir(), '.config', 'opencode-pilot', 'debug.log')
18
+
19
+ // Module state
20
+ let enabled = false
21
+ let logPath = DEFAULT_LOG_PATH
22
+
23
+ /**
24
+ * Initialize the logger with configuration
25
+ * @param {Object} options
26
+ * @param {boolean} [options.debug] - Enable debug logging
27
+ * @param {string} [options.debugPath] - Custom log file path
28
+ */
29
+ export function initLogger(options = {}) {
30
+ // Check environment variables first, then options
31
+ const envDebug = process.env.NTFY_DEBUG
32
+ const envDebugPath = process.env.NTFY_DEBUG_PATH
33
+
34
+ // Enable if NTFY_DEBUG is set to any truthy value (not 'false' or '0')
35
+ if (envDebug !== undefined && envDebug !== '' && envDebug !== 'false' && envDebug !== '0') {
36
+ enabled = true
37
+ } else if (options.debug !== undefined) {
38
+ enabled = Boolean(options.debug)
39
+ } else {
40
+ enabled = false
41
+ }
42
+
43
+ // Set log path (env var takes precedence)
44
+ if (envDebugPath) {
45
+ logPath = envDebugPath
46
+ } else if (options.debugPath) {
47
+ logPath = options.debugPath
48
+ } else {
49
+ logPath = DEFAULT_LOG_PATH
50
+ }
51
+
52
+ // Create directory if it doesn't exist
53
+ if (enabled) {
54
+ try {
55
+ const dir = dirname(logPath)
56
+ if (!existsSync(dir)) {
57
+ mkdirSync(dir, { recursive: true })
58
+ }
59
+ } catch {
60
+ // Silently ignore directory creation errors
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Write a debug log entry
67
+ * @param {string} message - Log message
68
+ * @param {Object} [data] - Optional data to include
69
+ */
70
+ export function debug(message, data) {
71
+ if (!enabled) {
72
+ return
73
+ }
74
+
75
+ try {
76
+ // Check file size and rotate if needed
77
+ rotateIfNeeded()
78
+
79
+ // Format log entry with ISO 8601 timestamp
80
+ const timestamp = new Date().toISOString()
81
+ let entry = `[${timestamp}] ${message}`
82
+
83
+ // Append data if provided
84
+ if (data !== undefined) {
85
+ if (typeof data === 'object') {
86
+ entry += ' ' + JSON.stringify(data)
87
+ } else {
88
+ entry += ' ' + String(data)
89
+ }
90
+ }
91
+
92
+ entry += '\n'
93
+
94
+ // Ensure directory exists
95
+ const dir = dirname(logPath)
96
+ if (!existsSync(dir)) {
97
+ mkdirSync(dir, { recursive: true })
98
+ }
99
+
100
+ // Append to log file
101
+ appendFileSync(logPath, entry)
102
+ } catch {
103
+ // Silently ignore write errors to avoid affecting the plugin
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Rotate log file if it exceeds MAX_LOG_SIZE
109
+ */
110
+ function rotateIfNeeded() {
111
+ try {
112
+ if (!existsSync(logPath)) {
113
+ return
114
+ }
115
+
116
+ const stats = statSync(logPath)
117
+ if (stats.size > MAX_LOG_SIZE) {
118
+ // Simple rotation: just truncate the file
119
+ // For more sophisticated rotation, could rename to .old first
120
+ unlinkSync(logPath)
121
+ }
122
+ } catch {
123
+ // Silently ignore rotation errors
124
+ }
125
+ }
@@ -0,0 +1,110 @@
1
+ // ntfy HTTP client for sending notifications
2
+
3
+ import { debug } from './logger.js'
4
+
5
+ // Deduplication cache: track recently sent notifications to prevent duplicates
6
+ // Key: hash of notification content, Value: timestamp
7
+ const recentNotifications = new Map()
8
+ const DEDUPE_WINDOW_MS = 5000 // 5 seconds
9
+
10
+ /**
11
+ * Generate a simple hash for deduplication
12
+ * @param {string} str - String to hash
13
+ * @returns {string} Simple hash
14
+ */
15
+ function simpleHash(str) {
16
+ let hash = 0
17
+ for (let i = 0; i < str.length; i++) {
18
+ const char = str.charCodeAt(i)
19
+ hash = ((hash << 5) - hash) + char
20
+ hash = hash & hash // Convert to 32bit integer
21
+ }
22
+ return hash.toString(36)
23
+ }
24
+
25
+ /**
26
+ * Check if notification was recently sent (for deduplication)
27
+ * @param {string} key - Deduplication key
28
+ * @returns {boolean} True if duplicate
29
+ */
30
+ function isDuplicate(key) {
31
+ const now = Date.now()
32
+
33
+ // Clean up old entries
34
+ for (const [k, timestamp] of recentNotifications) {
35
+ if (now - timestamp > DEDUPE_WINDOW_MS) {
36
+ recentNotifications.delete(k)
37
+ }
38
+ }
39
+
40
+ if (recentNotifications.has(key)) {
41
+ return true
42
+ }
43
+
44
+ recentNotifications.set(key, now)
45
+ return false
46
+ }
47
+
48
+ /**
49
+ * Build headers for ntfy requests
50
+ * @param {string} [authToken] - Optional ntfy access token for Bearer auth
51
+ * @returns {Object} Headers object
52
+ */
53
+ function buildHeaders(authToken) {
54
+ const headers = {
55
+ 'Content-Type': 'application/json',
56
+ }
57
+
58
+ if (authToken) {
59
+ headers['Authorization'] = `Bearer ${authToken}`
60
+ }
61
+
62
+ return headers
63
+ }
64
+
65
+ /**
66
+ * Send a basic notification to ntfy
67
+ * @param {Object} options
68
+ * @param {string} options.server - ntfy server URL
69
+ * @param {string} options.topic - ntfy topic name
70
+ * @param {string} options.title - Notification title
71
+ * @param {string} options.message - Notification message
72
+ * @param {number} [options.priority] - Priority (1-5, default 3)
73
+ * @param {string[]} [options.tags] - Emoji tags
74
+ * @param {string} [options.authToken] - Optional ntfy access token for protected topics
75
+ */
76
+ export async function sendNotification({ server, topic, title, message, priority, tags, authToken }) {
77
+ // Deduplicate: skip if same notification sent recently
78
+ const dedupeKey = simpleHash(`${topic}:${title}:${message}`)
79
+ if (isDuplicate(dedupeKey)) {
80
+ debug(`Notification skipped (duplicate): ${title}`)
81
+ return
82
+ }
83
+
84
+ const body = {
85
+ topic,
86
+ title,
87
+ message,
88
+ }
89
+
90
+ // Add optional fields only if provided
91
+ if (priority !== undefined) {
92
+ body.priority = priority
93
+ }
94
+ if (tags && tags.length > 0) {
95
+ body.tags = tags
96
+ }
97
+
98
+ try {
99
+ debug(`Notification sending: ${title}`)
100
+ const response = await fetch(server, {
101
+ method: 'POST',
102
+ headers: buildHeaders(authToken),
103
+ body: JSON.stringify(body),
104
+ })
105
+ debug(`Notification sent: ${title} (status=${response.status})`)
106
+ } catch (error) {
107
+ debug(`Notification failed: ${title} (error=${error.message})`)
108
+ // Silently ignore - errors here shouldn't affect the user
109
+ }
110
+ }