opencode-pilot 0.1.0 → 0.2.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/plugin/index.js CHANGED
@@ -1,260 +1,60 @@
1
- // opencode-pilot - ntfy notification plugin for OpenCode
1
+ // opencode-pilot plugin - Auto-start daemon when OpenCode launches
2
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.
3
+ // Add "opencode-pilot" to your opencode.json plugins array to enable.
4
+ // The plugin checks if the daemon is running and starts it if needed.
5
+
6
+ import { existsSync, readFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { homedir } from 'os'
9
+ import YAML from 'yaml'
9
10
 
10
- import { basename } from 'path'
11
- import { sendNotification } from './notifier.js'
12
- import { loadConfig } from './config.js'
13
- import { initLogger, debug } from './logger.js'
11
+ const DEFAULT_PORT = 4097
12
+ const CONFIG_PATH = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
14
13
 
15
14
  /**
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)
15
+ * Load port from config file
16
+ * @returns {number} Port number
26
17
  */
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}`
18
+ function getPort() {
19
+ try {
20
+ if (existsSync(CONFIG_PATH)) {
21
+ const content = readFileSync(CONFIG_PATH, 'utf8')
22
+ const config = YAML.parse(content)
23
+ if (config?.port && typeof config.port === 'number') {
24
+ return config.port
25
+ }
26
+ }
27
+ } catch {
28
+ // Ignore errors, use default
37
29
  }
38
-
39
- // Fall back to basename for regular directories
40
- return basename(directory) || 'unknown'
30
+ return DEFAULT_PORT
41
31
  }
42
32
 
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 })
33
+ /**
34
+ * OpenCode plugin that auto-starts the daemon if not running
35
+ */
36
+ export const PilotPlugin = async ({ $ }) => {
37
+ const port = getPort()
38
+
39
+ try {
40
+ // Check if daemon is already running
41
+ const res = await fetch(`http://localhost:${port}/health`, {
42
+ signal: AbortSignal.timeout(1000)
43
+ })
44
+ if (res.ok) {
45
+ // Already running, nothing to do
46
+ return {}
82
47
  }
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
48
+ } catch {
49
+ // Not running, start it
98
50
  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
51
+ await $`opencode-pilot start &`.quiet()
107
52
  } catch {
108
- // Error (likely 404) means session doesn't exist in our instance
109
- rejectedSessions.add(sessionId)
110
- return false
53
+ // Ignore start errors (maybe already starting)
111
54
  }
112
55
  }
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
- }
56
+
57
+ return {}
258
58
  }
259
59
 
260
- export default Notify
60
+ export default PilotPlugin
@@ -3,12 +3,12 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>Label</key>
6
- <string>io.opencode.ntfy</string>
6
+ <string>io.opencode.pilot</string>
7
7
 
8
8
  <key>ProgramArguments</key>
9
9
  <array>
10
10
  <string>/usr/local/bin/node</string>
11
- <string>/usr/local/opt/opencode-ntfy/libexec/server.js</string>
11
+ <string>/usr/local/opt/opencode-pilot/libexec/server.js</string>
12
12
  </array>
13
13
 
14
14
  <key>RunAtLoad</key>
@@ -18,12 +18,12 @@
18
18
  <true/>
19
19
 
20
20
  <key>StandardOutPath</key>
21
- <string>/usr/local/var/log/opencode-ntfy.log</string>
21
+ <string>/usr/local/var/log/opencode-pilot.log</string>
22
22
 
23
23
  <key>StandardErrorPath</key>
24
- <string>/usr/local/var/log/opencode-ntfy.log</string>
24
+ <string>/usr/local/var/log/opencode-pilot.log</string>
25
25
 
26
26
  <key>WorkingDirectory</key>
27
- <string>/usr/local/opt/opencode-ntfy/libexec</string>
27
+ <string>/usr/local/opt/opencode-pilot/libexec</string>
28
28
  </dict>
29
29
  </plist>