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/.github/workflows/ci.yml +8 -1
- package/.releaserc.cjs +10 -1
- package/AGENTS.md +6 -12
- package/README.md +31 -25
- package/bin/opencode-pilot +47 -209
- package/examples/config.yaml +2 -9
- package/package.json +6 -6
- package/plugin/index.js +45 -245
- package/service/{io.opencode.ntfy.plist → io.opencode.pilot.plist} +5 -5
- package/service/server.js +44 -1381
- package/test/run_tests.bash +1 -1
- package/test/test_actions.bash +21 -36
- package/test/test_cli.bash +20 -24
- package/test/test_plist.bash +11 -12
- package/test/test_poller.bash +20 -20
- package/test/test_repo_config.bash +19 -233
- package/test/test_service.bash +48 -1095
- package/test/unit/paths.test.js +16 -43
- package/test/unit/plugin.test.js +46 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/plugin/config.js +0 -76
- package/plugin/logger.js +0 -125
- package/plugin/notifier.js +0 -110
- package/test/test_config.bash +0 -438
- package/test/test_logger.bash +0 -401
- package/test/test_notifier.bash +0 -310
- package/test/test_plugin.bash +0 -952
- package/test/unit/config.test.js +0 -86
package/plugin/index.js
CHANGED
|
@@ -1,260 +1,60 @@
|
|
|
1
|
-
// opencode-pilot -
|
|
1
|
+
// opencode-pilot plugin - Auto-start daemon when OpenCode launches
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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-
|
|
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-
|
|
21
|
+
<string>/usr/local/var/log/opencode-pilot.log</string>
|
|
22
22
|
|
|
23
23
|
<key>StandardErrorPath</key>
|
|
24
|
-
<string>/usr/local/var/log/opencode-
|
|
24
|
+
<string>/usr/local/var/log/opencode-pilot.log</string>
|
|
25
25
|
|
|
26
26
|
<key>WorkingDirectory</key>
|
|
27
|
-
<string>/usr/local/opt/opencode-
|
|
27
|
+
<string>/usr/local/opt/opencode-pilot/libexec</string>
|
|
28
28
|
</dict>
|
|
29
29
|
</plist>
|