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.
- package/.devcontainer/devcontainer.json +16 -0
- package/.github/workflows/ci.yml +67 -0
- package/.releaserc.cjs +28 -0
- package/AGENTS.md +71 -0
- package/CONTRIBUTING.md +102 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/opencode-pilot +809 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/examples/config.yaml +73 -0
- package/examples/templates/default.md +7 -0
- package/examples/templates/devcontainer.md +7 -0
- package/examples/templates/review-feedback.md +7 -0
- package/examples/templates/review.md +15 -0
- package/install.sh +246 -0
- package/package.json +40 -0
- package/plugin/config.js +76 -0
- package/plugin/index.js +260 -0
- package/plugin/logger.js +125 -0
- package/plugin/notifier.js +110 -0
- package/service/actions.js +334 -0
- package/service/io.opencode.ntfy.plist +29 -0
- package/service/logger.js +82 -0
- package/service/poll-service.js +246 -0
- package/service/poller.js +339 -0
- package/service/readiness.js +234 -0
- package/service/repo-config.js +222 -0
- package/service/server.js +1523 -0
- package/service/utils.js +21 -0
- package/test/run_tests.bash +34 -0
- package/test/test_actions.bash +263 -0
- package/test/test_cli.bash +161 -0
- package/test/test_config.bash +438 -0
- package/test/test_helper.bash +140 -0
- package/test/test_logger.bash +401 -0
- package/test/test_notifier.bash +310 -0
- package/test/test_plist.bash +125 -0
- package/test/test_plugin.bash +952 -0
- package/test/test_poll_service.bash +179 -0
- package/test/test_poller.bash +120 -0
- package/test/test_readiness.bash +313 -0
- package/test/test_repo_config.bash +406 -0
- package/test/test_service.bash +1342 -0
- package/test/unit/actions.test.js +235 -0
- package/test/unit/config.test.js +86 -0
- package/test/unit/paths.test.js +77 -0
- package/test/unit/poll-service.test.js +142 -0
- package/test/unit/poller.test.js +347 -0
- package/test/unit/repo-config.test.js +441 -0
- package/test/unit/utils.test.js +53 -0
|
@@ -0,0 +1,1523 @@
|
|
|
1
|
+
// Standalone callback server for opencode-pilot
|
|
2
|
+
// Implements Issue #13: Separate callback server as brew service
|
|
3
|
+
//
|
|
4
|
+
// This service runs persistently via brew services and handles:
|
|
5
|
+
// - HTTP callbacks from ntfy action buttons
|
|
6
|
+
// - Unix socket IPC for plugin communication
|
|
7
|
+
// - Nonce management for permission requests
|
|
8
|
+
// - Session registration and response forwarding
|
|
9
|
+
// - Polling for tracker items (GitHub issues, Linear issues)
|
|
10
|
+
|
|
11
|
+
import { createServer as createHttpServer } from 'http'
|
|
12
|
+
import { createServer as createNetServer } from 'net'
|
|
13
|
+
import { randomUUID } from 'crypto'
|
|
14
|
+
import { existsSync, unlinkSync, realpathSync, readFileSync } from 'fs'
|
|
15
|
+
import { fileURLToPath } from 'url'
|
|
16
|
+
import { homedir } from 'os'
|
|
17
|
+
import { join, dirname } from 'path'
|
|
18
|
+
|
|
19
|
+
// Default configuration
|
|
20
|
+
const DEFAULT_HTTP_PORT = 4097
|
|
21
|
+
const DEFAULT_SOCKET_PATH = '/tmp/opencode-pilot.sock'
|
|
22
|
+
const CONFIG_PATH = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
|
|
23
|
+
const DEFAULT_REPOS_CONFIG = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
|
|
24
|
+
const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load callback config from environment variables and config file
|
|
28
|
+
* Environment variables take precedence over config file values
|
|
29
|
+
* @returns {Object} Config with callbackHttps and callbackHost
|
|
30
|
+
*/
|
|
31
|
+
function loadCallbackConfig() {
|
|
32
|
+
// Start with defaults
|
|
33
|
+
let callbackHttps = false
|
|
34
|
+
let callbackHost = null
|
|
35
|
+
|
|
36
|
+
// Load from config file
|
|
37
|
+
try {
|
|
38
|
+
if (existsSync(CONFIG_PATH)) {
|
|
39
|
+
const content = readFileSync(CONFIG_PATH, 'utf8')
|
|
40
|
+
const config = JSON.parse(content)
|
|
41
|
+
callbackHttps = config.callbackHttps === true
|
|
42
|
+
callbackHost = config.callbackHost || null
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore errors
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Environment variables override config file
|
|
49
|
+
if (process.env.NTFY_CALLBACK_HTTPS !== undefined) {
|
|
50
|
+
callbackHttps = process.env.NTFY_CALLBACK_HTTPS === 'true' || process.env.NTFY_CALLBACK_HTTPS === '1'
|
|
51
|
+
}
|
|
52
|
+
if (process.env.NTFY_CALLBACK_HOST !== undefined && process.env.NTFY_CALLBACK_HOST !== '') {
|
|
53
|
+
callbackHost = process.env.NTFY_CALLBACK_HOST
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { callbackHttps, callbackHost }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Nonce storage: nonce -> { sessionId, permissionId, createdAt }
|
|
60
|
+
const nonces = new Map()
|
|
61
|
+
const NONCE_TTL_MS = 60 * 60 * 1000 // 1 hour
|
|
62
|
+
|
|
63
|
+
// Session storage: sessionId -> socket connection
|
|
64
|
+
const sessions = new Map()
|
|
65
|
+
|
|
66
|
+
// Valid response types
|
|
67
|
+
const VALID_RESPONSES = ['once', 'always', 'reject']
|
|
68
|
+
|
|
69
|
+
// Allowed OpenCode port range (OpenCode uses ports like 7596, 7829, etc.)
|
|
70
|
+
const MIN_OPENCODE_PORT = 1024
|
|
71
|
+
const MAX_OPENCODE_PORT = 65535
|
|
72
|
+
|
|
73
|
+
// Maximum request body size (1MB)
|
|
74
|
+
const MAX_BODY_SIZE = 1024 * 1024
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a simple HTML response page
|
|
78
|
+
* @param {string} title - Page title
|
|
79
|
+
* @param {string} message - Message to display
|
|
80
|
+
* @param {boolean} success - Whether the operation succeeded
|
|
81
|
+
* @returns {string} HTML content
|
|
82
|
+
*/
|
|
83
|
+
function htmlResponse(title, message, success) {
|
|
84
|
+
const color = success ? '#22c55e' : '#ef4444'
|
|
85
|
+
const icon = success ? '✓' : '✗'
|
|
86
|
+
return `<!DOCTYPE html>
|
|
87
|
+
<html>
|
|
88
|
+
<head>
|
|
89
|
+
<meta charset="utf-8">
|
|
90
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
91
|
+
<title>${title} - opencode-pilot</title>
|
|
92
|
+
<style>
|
|
93
|
+
body { font-family: -apple-system, system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #1a1a1a; color: #fff; }
|
|
94
|
+
.container { text-align: center; padding: 2rem; }
|
|
95
|
+
.icon { font-size: 4rem; color: ${color}; }
|
|
96
|
+
.message { font-size: 1.5rem; margin-top: 1rem; }
|
|
97
|
+
.hint { color: #888; margin-top: 1rem; font-size: 0.9rem; }
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<div class="container">
|
|
102
|
+
<div class="icon">${icon}</div>
|
|
103
|
+
<div class="message">${message}</div>
|
|
104
|
+
<div class="hint">You can close this tab</div>
|
|
105
|
+
</div>
|
|
106
|
+
</body>
|
|
107
|
+
</html>`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* HTML-escape a string for safe embedding in HTML
|
|
112
|
+
* @param {string} str - String to escape
|
|
113
|
+
* @returns {string} Escaped string
|
|
114
|
+
*/
|
|
115
|
+
function escapeHtml(str) {
|
|
116
|
+
return String(str)
|
|
117
|
+
.replace(/&/g, '&')
|
|
118
|
+
.replace(/</g, '<')
|
|
119
|
+
.replace(/>/g, '>')
|
|
120
|
+
.replace(/"/g, '"')
|
|
121
|
+
.replace(/'/g, ''')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validate port is in allowed range
|
|
126
|
+
* @param {number} port - Port to validate
|
|
127
|
+
* @returns {boolean} True if valid
|
|
128
|
+
*/
|
|
129
|
+
function isValidPort(port) {
|
|
130
|
+
return Number.isInteger(port) && port >= MIN_OPENCODE_PORT && port <= MAX_OPENCODE_PORT
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate the mobile session UI HTML page
|
|
135
|
+
* @param {Object} params - Page parameters
|
|
136
|
+
* @param {string} params.repoName - Repository name
|
|
137
|
+
* @param {string} params.sessionId - Session ID
|
|
138
|
+
* @param {number} params.opencodePort - OpenCode server port
|
|
139
|
+
* @returns {string} HTML content
|
|
140
|
+
*/
|
|
141
|
+
function mobileSessionPage({ repoName, sessionId, opencodePort }) {
|
|
142
|
+
// Escape values for safe HTML embedding
|
|
143
|
+
const safeRepoName = escapeHtml(repoName)
|
|
144
|
+
const safeSessionId = escapeHtml(sessionId)
|
|
145
|
+
|
|
146
|
+
return `<!DOCTYPE html>
|
|
147
|
+
<html>
|
|
148
|
+
<head>
|
|
149
|
+
<meta charset="utf-8">
|
|
150
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
151
|
+
<title>${safeRepoName} - OpenCode</title>
|
|
152
|
+
<style>
|
|
153
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
154
|
+
html, body {
|
|
155
|
+
font-family: -apple-system, system-ui, 'Segoe UI', sans-serif;
|
|
156
|
+
background: #0d1117;
|
|
157
|
+
color: #e6edf3;
|
|
158
|
+
height: 100dvh;
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
}
|
|
161
|
+
body {
|
|
162
|
+
display: flex;
|
|
163
|
+
flex-direction: column;
|
|
164
|
+
}
|
|
165
|
+
.header {
|
|
166
|
+
position: fixed;
|
|
167
|
+
top: 0;
|
|
168
|
+
left: 0;
|
|
169
|
+
right: 0;
|
|
170
|
+
z-index: 100;
|
|
171
|
+
background: #161b22;
|
|
172
|
+
padding: 12px 16px;
|
|
173
|
+
border-bottom: 1px solid #30363d;
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
transition: transform 0.2s ease-out;
|
|
178
|
+
}
|
|
179
|
+
.header.hidden {
|
|
180
|
+
transform: translateY(-100%);
|
|
181
|
+
}
|
|
182
|
+
.header-icon {
|
|
183
|
+
width: 20px;
|
|
184
|
+
height: 20px;
|
|
185
|
+
background: #238636;
|
|
186
|
+
border-radius: 4px;
|
|
187
|
+
}
|
|
188
|
+
.header-title {
|
|
189
|
+
font-size: 16px;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
}
|
|
192
|
+
.header-status {
|
|
193
|
+
margin-left: auto;
|
|
194
|
+
font-size: 12px;
|
|
195
|
+
color: #7d8590;
|
|
196
|
+
}
|
|
197
|
+
.main {
|
|
198
|
+
flex: 1;
|
|
199
|
+
display: flex;
|
|
200
|
+
flex-direction: column;
|
|
201
|
+
padding: 16px;
|
|
202
|
+
padding-top: 60px; /* Space for fixed header */
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
min-height: 0;
|
|
205
|
+
}
|
|
206
|
+
.session-title {
|
|
207
|
+
flex-shrink: 0;
|
|
208
|
+
font-size: 13px;
|
|
209
|
+
color: #7d8590;
|
|
210
|
+
padding: 8px 0;
|
|
211
|
+
border-bottom: 1px solid #30363d;
|
|
212
|
+
margin-bottom: 12px;
|
|
213
|
+
font-style: italic;
|
|
214
|
+
}
|
|
215
|
+
.messages-list {
|
|
216
|
+
flex: 1;
|
|
217
|
+
overflow-y: auto;
|
|
218
|
+
margin-bottom: 12px;
|
|
219
|
+
padding-bottom: 12px;
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
gap: 8px;
|
|
223
|
+
min-height: 0;
|
|
224
|
+
}
|
|
225
|
+
.message {
|
|
226
|
+
background: #21262d;
|
|
227
|
+
border: 1px solid #30363d;
|
|
228
|
+
border-radius: 6px;
|
|
229
|
+
padding: 12px;
|
|
230
|
+
}
|
|
231
|
+
.message-header {
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 8px;
|
|
235
|
+
margin-bottom: 8px;
|
|
236
|
+
font-size: 12px;
|
|
237
|
+
color: #7d8590;
|
|
238
|
+
}
|
|
239
|
+
.message-role {
|
|
240
|
+
background: #238636;
|
|
241
|
+
color: #fff;
|
|
242
|
+
padding: 2px 8px;
|
|
243
|
+
border-radius: 4px;
|
|
244
|
+
font-size: 11px;
|
|
245
|
+
font-weight: 600;
|
|
246
|
+
text-transform: uppercase;
|
|
247
|
+
}
|
|
248
|
+
.message-content {
|
|
249
|
+
font-size: 14px;
|
|
250
|
+
line-height: 1.5;
|
|
251
|
+
white-space: pre-wrap;
|
|
252
|
+
word-break: break-word;
|
|
253
|
+
overflow-x: auto;
|
|
254
|
+
max-width: 100%;
|
|
255
|
+
}
|
|
256
|
+
.message-content pre {
|
|
257
|
+
overflow-x: auto;
|
|
258
|
+
max-width: 100%;
|
|
259
|
+
}
|
|
260
|
+
.message-content code {
|
|
261
|
+
word-break: break-all;
|
|
262
|
+
}
|
|
263
|
+
.message-loading {
|
|
264
|
+
text-align: center;
|
|
265
|
+
color: #7d8590;
|
|
266
|
+
padding: 40px;
|
|
267
|
+
}
|
|
268
|
+
.message-error {
|
|
269
|
+
background: #3d1e20;
|
|
270
|
+
border-color: #f85149;
|
|
271
|
+
color: #f85149;
|
|
272
|
+
}
|
|
273
|
+
.tool-calls {
|
|
274
|
+
margin-top: 8px;
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
gap: 6px;
|
|
278
|
+
}
|
|
279
|
+
.tool-call {
|
|
280
|
+
background: #161b22;
|
|
281
|
+
border: 1px solid #30363d;
|
|
282
|
+
border-radius: 4px;
|
|
283
|
+
padding: 6px 10px;
|
|
284
|
+
font-size: 12px;
|
|
285
|
+
}
|
|
286
|
+
.tool-call-name {
|
|
287
|
+
color: #58a6ff;
|
|
288
|
+
font-weight: 600;
|
|
289
|
+
margin-bottom: 4px;
|
|
290
|
+
}
|
|
291
|
+
.tool-call-description {
|
|
292
|
+
color: #7d8590;
|
|
293
|
+
font-size: 12px;
|
|
294
|
+
}
|
|
295
|
+
.tool-call-status {
|
|
296
|
+
display: inline-block;
|
|
297
|
+
margin-left: 8px;
|
|
298
|
+
padding: 2px 6px;
|
|
299
|
+
border-radius: 3px;
|
|
300
|
+
font-size: 11px;
|
|
301
|
+
font-weight: 600;
|
|
302
|
+
}
|
|
303
|
+
.tool-call-status.running {
|
|
304
|
+
background: #1f6feb;
|
|
305
|
+
color: #fff;
|
|
306
|
+
}
|
|
307
|
+
.tool-call-status.success {
|
|
308
|
+
background: #238636;
|
|
309
|
+
color: #fff;
|
|
310
|
+
}
|
|
311
|
+
.tool-call-status.error {
|
|
312
|
+
background: #da3633;
|
|
313
|
+
color: #fff;
|
|
314
|
+
}
|
|
315
|
+
.input-container {
|
|
316
|
+
flex-shrink: 0;
|
|
317
|
+
background: #21262d;
|
|
318
|
+
border: 1px solid #30363d;
|
|
319
|
+
border-radius: 8px;
|
|
320
|
+
padding: 12px;
|
|
321
|
+
}
|
|
322
|
+
.input-wrapper {
|
|
323
|
+
display: flex;
|
|
324
|
+
gap: 8px;
|
|
325
|
+
}
|
|
326
|
+
textarea {
|
|
327
|
+
flex: 1;
|
|
328
|
+
background: #0d1117;
|
|
329
|
+
border: 1px solid #30363d;
|
|
330
|
+
border-radius: 6px;
|
|
331
|
+
color: #e6edf3;
|
|
332
|
+
font-family: inherit;
|
|
333
|
+
font-size: 15px;
|
|
334
|
+
padding: 10px 12px;
|
|
335
|
+
resize: none;
|
|
336
|
+
min-height: 44px;
|
|
337
|
+
max-height: 120px;
|
|
338
|
+
}
|
|
339
|
+
textarea:focus {
|
|
340
|
+
outline: none;
|
|
341
|
+
border-color: #238636;
|
|
342
|
+
}
|
|
343
|
+
textarea::placeholder {
|
|
344
|
+
color: #7d8590;
|
|
345
|
+
}
|
|
346
|
+
button {
|
|
347
|
+
background: #238636;
|
|
348
|
+
border: none;
|
|
349
|
+
border-radius: 6px;
|
|
350
|
+
color: #fff;
|
|
351
|
+
font-size: 14px;
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
padding: 10px 20px;
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
white-space: nowrap;
|
|
356
|
+
}
|
|
357
|
+
button:hover {
|
|
358
|
+
background: #2ea043;
|
|
359
|
+
}
|
|
360
|
+
button:disabled {
|
|
361
|
+
background: #21262d;
|
|
362
|
+
color: #7d8590;
|
|
363
|
+
cursor: not-allowed;
|
|
364
|
+
}
|
|
365
|
+
.selectors {
|
|
366
|
+
display: flex;
|
|
367
|
+
gap: 8px;
|
|
368
|
+
margin-bottom: 8px;
|
|
369
|
+
}
|
|
370
|
+
.selector-group {
|
|
371
|
+
flex: 1;
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-direction: column;
|
|
374
|
+
gap: 4px;
|
|
375
|
+
}
|
|
376
|
+
.selector-group label {
|
|
377
|
+
font-size: 11px;
|
|
378
|
+
color: #7d8590;
|
|
379
|
+
text-transform: uppercase;
|
|
380
|
+
}
|
|
381
|
+
select {
|
|
382
|
+
background: #0d1117;
|
|
383
|
+
border: 1px solid #30363d;
|
|
384
|
+
border-radius: 6px;
|
|
385
|
+
color: #e6edf3;
|
|
386
|
+
font-family: inherit;
|
|
387
|
+
font-size: 13px;
|
|
388
|
+
padding: 8px 10px;
|
|
389
|
+
width: 100%;
|
|
390
|
+
appearance: none;
|
|
391
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%237d8590' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 6l3.5 4 3.5-4z'/%3E%3C/svg%3E");
|
|
392
|
+
background-repeat: no-repeat;
|
|
393
|
+
background-position: right 8px center;
|
|
394
|
+
}
|
|
395
|
+
select:focus {
|
|
396
|
+
outline: none;
|
|
397
|
+
border-color: #238636;
|
|
398
|
+
}
|
|
399
|
+
select:disabled {
|
|
400
|
+
opacity: 0.5;
|
|
401
|
+
cursor: not-allowed;
|
|
402
|
+
}
|
|
403
|
+
</style>
|
|
404
|
+
</head>
|
|
405
|
+
<body>
|
|
406
|
+
<div class="header">
|
|
407
|
+
<div class="header-icon"></div>
|
|
408
|
+
<div class="header-title">${safeRepoName}</div>
|
|
409
|
+
<div class="header-status" id="status">Loading...</div>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div class="main">
|
|
413
|
+
<div class="session-title" id="sessionTitle"></div>
|
|
414
|
+
<div class="messages-list" id="messagesList">
|
|
415
|
+
<div class="message">
|
|
416
|
+
<div class="message-loading">Loading session...</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
<div class="input-container">
|
|
421
|
+
<div class="selectors">
|
|
422
|
+
<div class="selector-group">
|
|
423
|
+
<label for="model">Model</label>
|
|
424
|
+
<select id="model" disabled>
|
|
425
|
+
<option value="">Loading...</option>
|
|
426
|
+
</select>
|
|
427
|
+
</div>
|
|
428
|
+
<div class="selector-group">
|
|
429
|
+
<label for="agent">Agent</label>
|
|
430
|
+
<select id="agent" disabled>
|
|
431
|
+
<option value="">Loading...</option>
|
|
432
|
+
</select>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="input-wrapper">
|
|
436
|
+
<textarea id="input" placeholder="Type a message..." rows="1"></textarea>
|
|
437
|
+
<button id="send" disabled>Send</button>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<script>
|
|
444
|
+
const API_BASE = '/api/' + ${opencodePort};
|
|
445
|
+
const SESSION_ID = '${safeSessionId}';
|
|
446
|
+
|
|
447
|
+
const messagesListEl = document.getElementById('messagesList');
|
|
448
|
+
const sessionTitleEl = document.getElementById('sessionTitle');
|
|
449
|
+
const inputEl = document.getElementById('input');
|
|
450
|
+
const sendBtn = document.getElementById('send');
|
|
451
|
+
const statusEl = document.getElementById('status');
|
|
452
|
+
const modelEl = document.getElementById('model');
|
|
453
|
+
const agentEl = document.getElementById('agent');
|
|
454
|
+
const headerEl = document.querySelector('.header');
|
|
455
|
+
|
|
456
|
+
let sessionTitle = '';
|
|
457
|
+
|
|
458
|
+
let isSending = false;
|
|
459
|
+
|
|
460
|
+
// Header hide/show on scroll and keyboard (like iOS Messages)
|
|
461
|
+
let lastScrollTop = 0;
|
|
462
|
+
const mainEl = document.querySelector('.main');
|
|
463
|
+
|
|
464
|
+
function hideHeader() {
|
|
465
|
+
headerEl.classList.add('hidden');
|
|
466
|
+
mainEl.style.paddingTop = '16px'; // Remove header space
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function showHeader() {
|
|
470
|
+
headerEl.classList.remove('hidden');
|
|
471
|
+
mainEl.style.paddingTop = '60px'; // Restore header space
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
messagesListEl.addEventListener('scroll', () => {
|
|
475
|
+
const scrollTop = messagesListEl.scrollTop;
|
|
476
|
+
if (scrollTop > lastScrollTop && scrollTop > 50) {
|
|
477
|
+
// Scrolling down
|
|
478
|
+
hideHeader();
|
|
479
|
+
} else {
|
|
480
|
+
// Scrolling up
|
|
481
|
+
showHeader();
|
|
482
|
+
}
|
|
483
|
+
lastScrollTop = scrollTop;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Hide header when keyboard opens (input focused)
|
|
487
|
+
inputEl.addEventListener('focus', () => {
|
|
488
|
+
hideHeader();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Show header when keyboard closes (input blurred)
|
|
492
|
+
inputEl.addEventListener('blur', () => {
|
|
493
|
+
showHeader();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Auto-resize textarea
|
|
497
|
+
inputEl.addEventListener('input', () => {
|
|
498
|
+
inputEl.style.height = 'auto';
|
|
499
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
|
500
|
+
sendBtn.disabled = !inputEl.value.trim();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Send message
|
|
504
|
+
async function sendMessage() {
|
|
505
|
+
const content = inputEl.value.trim();
|
|
506
|
+
if (!content || isSending) return;
|
|
507
|
+
|
|
508
|
+
isSending = true;
|
|
509
|
+
sendBtn.disabled = true;
|
|
510
|
+
sendBtn.textContent = 'Sending...';
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
// OpenCode's /message endpoint waits for LLM response, which can take minutes.
|
|
514
|
+
// Use AbortController with short timeout - if request is accepted, message is queued.
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
517
|
+
|
|
518
|
+
// Build message body with agent and optional model
|
|
519
|
+
const messageBody = {
|
|
520
|
+
agent: agentEl.value || 'code',
|
|
521
|
+
parts: [{ type: 'text', text: content }]
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// Parse model selection (format: "providerId/modelId")
|
|
525
|
+
const modelValue = modelEl.value;
|
|
526
|
+
if (modelValue) {
|
|
527
|
+
const modelParts = modelValue.split('/');
|
|
528
|
+
if (modelParts.length >= 2) {
|
|
529
|
+
messageBody.model = {
|
|
530
|
+
providerID: modelParts[0],
|
|
531
|
+
modelID: modelParts.slice(1).join('/')
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let requestAccepted = false;
|
|
537
|
+
try {
|
|
538
|
+
const res = await fetch(API_BASE + '/session/' + SESSION_ID + '/message', {
|
|
539
|
+
method: 'POST',
|
|
540
|
+
headers: { 'Content-Type': 'application/json' },
|
|
541
|
+
body: JSON.stringify(messageBody),
|
|
542
|
+
signal: controller.signal
|
|
543
|
+
});
|
|
544
|
+
clearTimeout(timeoutId);
|
|
545
|
+
if (!res.ok) throw new Error('Failed to send');
|
|
546
|
+
requestAccepted = true;
|
|
547
|
+
} catch (fetchErr) {
|
|
548
|
+
clearTimeout(timeoutId);
|
|
549
|
+
// AbortError means request was sent but we timed out waiting for LLM response
|
|
550
|
+
// This is expected - the message was accepted and is being processed
|
|
551
|
+
if (fetchErr.name === 'AbortError') {
|
|
552
|
+
requestAccepted = true;
|
|
553
|
+
} else {
|
|
554
|
+
throw fetchErr;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (requestAccepted) {
|
|
559
|
+
// Clear input and update UI immediately
|
|
560
|
+
inputEl.value = '';
|
|
561
|
+
inputEl.style.height = 'auto';
|
|
562
|
+
isSending = false;
|
|
563
|
+
sendBtn.disabled = true; // Disabled because input is empty
|
|
564
|
+
sendBtn.textContent = 'Send';
|
|
565
|
+
statusEl.textContent = 'Processing...';
|
|
566
|
+
isProcessing = true;
|
|
567
|
+
|
|
568
|
+
// Reload messages to show the new user message and poll for response
|
|
569
|
+
loadSession(false).catch(() => {}); // Don't await - let it happen in background
|
|
570
|
+
startPolling();
|
|
571
|
+
} else {
|
|
572
|
+
isSending = false;
|
|
573
|
+
sendBtn.disabled = !inputEl.value.trim();
|
|
574
|
+
sendBtn.textContent = 'Send';
|
|
575
|
+
}
|
|
576
|
+
} catch (err) {
|
|
577
|
+
statusEl.textContent = 'Failed to send';
|
|
578
|
+
isSending = false;
|
|
579
|
+
sendBtn.disabled = !inputEl.value.trim();
|
|
580
|
+
sendBtn.textContent = 'Send';
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
sendBtn.addEventListener('click', sendMessage);
|
|
585
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
586
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
sendMessage();
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Track last message count to detect new messages
|
|
593
|
+
let lastMessageCount = 0;
|
|
594
|
+
let isProcessing = false;
|
|
595
|
+
let pollInterval = null;
|
|
596
|
+
|
|
597
|
+
// Load session info (including autogenerated title)
|
|
598
|
+
async function loadSessionInfo() {
|
|
599
|
+
try {
|
|
600
|
+
const res = await fetch(API_BASE + '/session/' + SESSION_ID);
|
|
601
|
+
if (!res.ok) return;
|
|
602
|
+
|
|
603
|
+
const session = await res.json();
|
|
604
|
+
if (session.title) {
|
|
605
|
+
sessionTitle = session.title;
|
|
606
|
+
sessionTitleEl.textContent = session.title;
|
|
607
|
+
sessionTitleEl.style.display = 'block';
|
|
608
|
+
// Update document title to include session title
|
|
609
|
+
document.title = sessionTitle + ' - OpenCode';
|
|
610
|
+
}
|
|
611
|
+
} catch (err) {
|
|
612
|
+
// Silently ignore - title is optional
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Render messages in the conversation (limit to last 20 for performance)
|
|
617
|
+
const MAX_MESSAGES = 20;
|
|
618
|
+
|
|
619
|
+
function renderMessages(messages) {
|
|
620
|
+
if (!messages || messages.length === 0) {
|
|
621
|
+
messagesListEl.innerHTML = '<div class="message"><div class="message-loading">No messages yet</div></div>';
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Only show last N messages for performance
|
|
626
|
+
const recentMessages = messages.slice(-MAX_MESSAGES);
|
|
627
|
+
const skipped = messages.length - recentMessages.length;
|
|
628
|
+
|
|
629
|
+
// Group consecutive messages by role to reduce visual clutter
|
|
630
|
+
const groupedMessages = [];
|
|
631
|
+
for (const msg of recentMessages) {
|
|
632
|
+
const role = msg.info?.role;
|
|
633
|
+
const lastGroup = groupedMessages[groupedMessages.length - 1];
|
|
634
|
+
|
|
635
|
+
if (lastGroup && lastGroup.role === role) {
|
|
636
|
+
// Same role, add to current group
|
|
637
|
+
lastGroup.messages.push(msg);
|
|
638
|
+
} else {
|
|
639
|
+
// Different role, start new group
|
|
640
|
+
groupedMessages.push({
|
|
641
|
+
role: role,
|
|
642
|
+
messages: [msg]
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let html = '';
|
|
648
|
+
if (skipped > 0) {
|
|
649
|
+
html += '<div style="text-align:center;color:#7d8590;padding:8px;font-size:13px;">' + skipped + ' older messages not shown</div>';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const group of groupedMessages) {
|
|
653
|
+
const role = group.role || 'unknown';
|
|
654
|
+
const isAssistant = role === 'assistant';
|
|
655
|
+
const roleLabel = isAssistant ? 'Assistant' : 'You';
|
|
656
|
+
const roleColor = isAssistant ? '#238636' : '#1f6feb';
|
|
657
|
+
|
|
658
|
+
// Check if any message in group is in progress
|
|
659
|
+
const isInProgress = group.messages.some(msg => isAssistant && !msg.info?.time?.completed);
|
|
660
|
+
|
|
661
|
+
// Collect all content and tool calls from all messages in group
|
|
662
|
+
let allContent = '';
|
|
663
|
+
const allToolCalls = [];
|
|
664
|
+
|
|
665
|
+
for (const msg of group.messages) {
|
|
666
|
+
// Extract text content and tool calls from message parts
|
|
667
|
+
if (msg.parts) {
|
|
668
|
+
for (const part of msg.parts) {
|
|
669
|
+
if (part.type === 'text') {
|
|
670
|
+
allContent += part.text;
|
|
671
|
+
} else if (part.type === 'tool') {
|
|
672
|
+
allToolCalls.push(part);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Skip groups with no content
|
|
679
|
+
if (!allContent && allToolCalls.length === 0) {
|
|
680
|
+
// Show in-progress assistant messages even without content
|
|
681
|
+
if (isInProgress) {
|
|
682
|
+
allContent = 'Waiting for response...';
|
|
683
|
+
} else {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const statusText = isInProgress ? '<span style="color:#7d8590;margin-left:8px;">Processing...</span>' : '';
|
|
689
|
+
|
|
690
|
+
// Render tool calls
|
|
691
|
+
let toolCallsHtml = '';
|
|
692
|
+
if (allToolCalls.length > 0) {
|
|
693
|
+
toolCallsHtml = '<div class="tool-calls">';
|
|
694
|
+
for (const tool of allToolCalls) {
|
|
695
|
+
const toolName = tool.tool || 'unknown';
|
|
696
|
+
const description = tool.state?.input?.description || tool.state?.input?.prompt || '';
|
|
697
|
+
const status = tool.state?.status || 'unknown';
|
|
698
|
+
const statusClass = status === 'running' ? 'running' : status === 'completed' ? 'success' : 'error';
|
|
699
|
+
const statusLabel = status === 'running' ? '...' : status === 'completed' ? '✓' : '✗';
|
|
700
|
+
|
|
701
|
+
toolCallsHtml += \`
|
|
702
|
+
<div class="tool-call">
|
|
703
|
+
<div class="tool-call-name">
|
|
704
|
+
\${escapeHtml(toolName)}
|
|
705
|
+
<span class="tool-call-status \${statusClass}">\${statusLabel}</span>
|
|
706
|
+
</div>
|
|
707
|
+
\${description ? '<div class="tool-call-description">' + escapeHtml(description) + '</div>' : ''}
|
|
708
|
+
</div>
|
|
709
|
+
\`;
|
|
710
|
+
}
|
|
711
|
+
toolCallsHtml += '</div>';
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const statusText = isInProgress ? '<span style="color:#7d8590;margin-left:8px;">Processing...</span>' : '';
|
|
715
|
+
|
|
716
|
+
html += \`
|
|
717
|
+
<div class="message">
|
|
718
|
+
<div class="message-header">
|
|
719
|
+
<span class="message-role" style="background:\${roleColor}">\${roleLabel}</span>
|
|
720
|
+
\${statusText}
|
|
721
|
+
</div>
|
|
722
|
+
\${allContent ? '<div class="message-content">' + renderMarkdown(allContent) + '</div>' : ''}
|
|
723
|
+
\${toolCallsHtml}
|
|
724
|
+
</div>
|
|
725
|
+
\`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
messagesListEl.innerHTML = html || '<div class="message"><div class="message-loading">No messages yet</div></div>';
|
|
729
|
+
|
|
730
|
+
// Only auto-scroll if user is already near the bottom (within 100px)
|
|
731
|
+
// This prevents jumping when user is scrolled up reading old messages
|
|
732
|
+
const isNearBottom = messagesListEl.scrollHeight - messagesListEl.scrollTop - messagesListEl.clientHeight < 100;
|
|
733
|
+
if (isNearBottom) {
|
|
734
|
+
messagesListEl.scrollTop = messagesListEl.scrollHeight;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Load session messages
|
|
739
|
+
async function loadSession(showLoading = true) {
|
|
740
|
+
try {
|
|
741
|
+
// Fetch messages from the /message endpoint (not embedded in session)
|
|
742
|
+
const res = await fetch(API_BASE + '/session/' + SESSION_ID + '/message');
|
|
743
|
+
if (!res.ok) throw new Error('Session not found');
|
|
744
|
+
|
|
745
|
+
const messages = await res.json();
|
|
746
|
+
lastMessageCount = messages.length;
|
|
747
|
+
|
|
748
|
+
// Find last message to check if we're processing
|
|
749
|
+
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
750
|
+
const lastRole = lastMessage?.info?.role;
|
|
751
|
+
|
|
752
|
+
// Find last assistant message to check if it's in progress
|
|
753
|
+
let lastAssistant = null;
|
|
754
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
755
|
+
if (messages[i].info && messages[i].info.role === 'assistant') {
|
|
756
|
+
lastAssistant = messages[i];
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Check if we're waiting for assistant response
|
|
762
|
+
const assistantInProgress = lastAssistant && !lastAssistant.info?.time?.completed;
|
|
763
|
+
isProcessing = lastRole === 'user' || assistantInProgress;
|
|
764
|
+
|
|
765
|
+
// Render all messages
|
|
766
|
+
renderMessages(messages);
|
|
767
|
+
|
|
768
|
+
// Update status
|
|
769
|
+
if (isProcessing) {
|
|
770
|
+
statusEl.textContent = 'Processing...';
|
|
771
|
+
startPolling();
|
|
772
|
+
} else if (messages.length > 0) {
|
|
773
|
+
statusEl.textContent = 'Ready';
|
|
774
|
+
stopPolling();
|
|
775
|
+
} else {
|
|
776
|
+
statusEl.textContent = 'New session';
|
|
777
|
+
stopPolling();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
sendBtn.disabled = !inputEl.value.trim();
|
|
781
|
+
} catch (err) {
|
|
782
|
+
messagesListEl.innerHTML = '<div class="message message-error"><div class="message-loading">Could not load session</div></div>';
|
|
783
|
+
statusEl.textContent = 'Error';
|
|
784
|
+
stopPolling();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function startPolling() {
|
|
789
|
+
if (pollInterval) return;
|
|
790
|
+
pollInterval = setInterval(() => loadSession(false), 3000);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function stopPolling() {
|
|
794
|
+
if (pollInterval) {
|
|
795
|
+
clearInterval(pollInterval);
|
|
796
|
+
pollInterval = null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function escapeHtml(text) {
|
|
801
|
+
const div = document.createElement('div');
|
|
802
|
+
div.textContent = text;
|
|
803
|
+
return div.innerHTML;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Simple markdown renderer for common patterns
|
|
807
|
+
function renderMarkdown(text) {
|
|
808
|
+
if (!text) return '';
|
|
809
|
+
|
|
810
|
+
// Escape HTML first
|
|
811
|
+
let html = escapeHtml(text);
|
|
812
|
+
|
|
813
|
+
const backtick = String.fromCharCode(96);
|
|
814
|
+
const codeBlockRegex = new RegExp(backtick + backtick + backtick + '(\\\\w*)\\\\n([\\\\s\\\\S]*?)' + backtick + backtick + backtick, 'g');
|
|
815
|
+
const inlineCodeRegex = new RegExp(backtick + '([^' + backtick + ']+)' + backtick, 'g');
|
|
816
|
+
|
|
817
|
+
// Code blocks
|
|
818
|
+
html = html.replace(codeBlockRegex, '<pre><code>$2</code></pre>');
|
|
819
|
+
|
|
820
|
+
// Inline code
|
|
821
|
+
html = html.replace(inlineCodeRegex, '<code style="background:#30363d;padding:2px 6px;border-radius:4px;font-size:13px;">$1</code>');
|
|
822
|
+
|
|
823
|
+
// Bold (**...**)
|
|
824
|
+
html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
825
|
+
|
|
826
|
+
// Italic (*...*)
|
|
827
|
+
html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');
|
|
828
|
+
|
|
829
|
+
// Headers (# ...)
|
|
830
|
+
html = html.replace(/^### (.+)$/gm, '<h4 style="margin:12px 0 8px;font-size:14px;">$1</h4>');
|
|
831
|
+
html = html.replace(/^## (.+)$/gm, '<h3 style="margin:12px 0 8px;font-size:15px;">$1</h3>');
|
|
832
|
+
html = html.replace(/^# (.+)$/gm, '<h2 style="margin:12px 0 8px;font-size:16px;">$1</h2>');
|
|
833
|
+
|
|
834
|
+
// Lists (- ... or * ... or 1. ...)
|
|
835
|
+
html = html.replace(/^[\\-\\*] (.+)$/gm, '<li style="margin-left:20px;margin-bottom:4px;">$1</li>');
|
|
836
|
+
html = html.replace(/^\\d+\\. (.+)$/gm, '<li style="margin-left:20px;margin-bottom:4px;">$1</li>');
|
|
837
|
+
|
|
838
|
+
// Line breaks (but not after list items to avoid extra spacing)
|
|
839
|
+
html = html.replace(/\\n/g, '<br>');
|
|
840
|
+
// Clean up breaks between list items
|
|
841
|
+
html = html.replace(/<\\/li><br>/g, '</li>');
|
|
842
|
+
|
|
843
|
+
return html;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Handle mobile keyboard viewport changes using visualViewport API
|
|
847
|
+
// This ensures the header stays visible when the virtual keyboard opens
|
|
848
|
+
// iOS Safari scrolls the viewport when keyboard opens - we counteract this
|
|
849
|
+
function handleViewportResize() {
|
|
850
|
+
if (window.visualViewport) {
|
|
851
|
+
const viewport = window.visualViewport;
|
|
852
|
+
// Offset the body to counteract iOS Safari's viewport scroll
|
|
853
|
+
// This keeps the header pinned to the top of the visible area
|
|
854
|
+
document.body.style.transform = 'translateY(' + viewport.offsetTop + 'px)';
|
|
855
|
+
document.body.style.height = viewport.height + 'px';
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (window.visualViewport) {
|
|
860
|
+
window.visualViewport.addEventListener('resize', handleViewportResize);
|
|
861
|
+
window.visualViewport.addEventListener('scroll', handleViewportResize);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Load favorite models from local state and provider API
|
|
865
|
+
async function loadModels() {
|
|
866
|
+
try {
|
|
867
|
+
// Load favorites and provider data in parallel
|
|
868
|
+
const [favRes, provRes] = await Promise.all([
|
|
869
|
+
fetch('/favorites'),
|
|
870
|
+
fetch(API_BASE + '/provider')
|
|
871
|
+
]);
|
|
872
|
+
|
|
873
|
+
const favorites = favRes.ok ? await favRes.json() : [];
|
|
874
|
+
if (!provRes.ok) throw new Error('Failed to load providers');
|
|
875
|
+
const data = await provRes.json();
|
|
876
|
+
|
|
877
|
+
const allProviders = data.all || [];
|
|
878
|
+
|
|
879
|
+
modelEl.innerHTML = '';
|
|
880
|
+
|
|
881
|
+
// Add favorite models first
|
|
882
|
+
if (favorites.length > 0) {
|
|
883
|
+
favorites.forEach(fav => {
|
|
884
|
+
const provider = allProviders.find(p => p.id === fav.providerID);
|
|
885
|
+
if (!provider || !provider.models) return;
|
|
886
|
+
const model = provider.models[fav.modelID];
|
|
887
|
+
if (!model) return;
|
|
888
|
+
|
|
889
|
+
const opt = document.createElement('option');
|
|
890
|
+
opt.value = fav.providerID + '/' + fav.modelID;
|
|
891
|
+
opt.textContent = model.name || fav.modelID;
|
|
892
|
+
modelEl.appendChild(opt);
|
|
893
|
+
});
|
|
894
|
+
} else {
|
|
895
|
+
// Fallback if no favorites
|
|
896
|
+
modelEl.innerHTML = '<option value="">Default model</option>';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
modelEl.disabled = false;
|
|
900
|
+
} catch (err) {
|
|
901
|
+
modelEl.innerHTML = '<option value="">Default model</option>';
|
|
902
|
+
modelEl.disabled = false;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Load agents from OpenCode API
|
|
907
|
+
async function loadAgents() {
|
|
908
|
+
try {
|
|
909
|
+
const res = await fetch(API_BASE + '/agent');
|
|
910
|
+
if (!res.ok) throw new Error('Failed to load agents');
|
|
911
|
+
const agents = await res.json();
|
|
912
|
+
|
|
913
|
+
// Filter to user-facing agents:
|
|
914
|
+
// - mode === 'primary' or 'all' (not subagents)
|
|
915
|
+
// - has a description (excludes internal agents like compaction, title, summary)
|
|
916
|
+
const primaryAgents = agents.filter(a => (a.mode === 'primary' || a.mode === 'all') && a.description);
|
|
917
|
+
|
|
918
|
+
agentEl.innerHTML = '';
|
|
919
|
+
primaryAgents.forEach(a => {
|
|
920
|
+
const opt = document.createElement('option');
|
|
921
|
+
opt.value = a.name;
|
|
922
|
+
opt.textContent = a.name;
|
|
923
|
+
if (a.name === 'code') opt.selected = true;
|
|
924
|
+
agentEl.appendChild(opt);
|
|
925
|
+
});
|
|
926
|
+
agentEl.disabled = false;
|
|
927
|
+
} catch (err) {
|
|
928
|
+
agentEl.innerHTML = '<option value="code">code</option>';
|
|
929
|
+
agentEl.disabled = false;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Load session info, messages, and agent/model options
|
|
934
|
+
loadSessionInfo();
|
|
935
|
+
Promise.all([loadSession(), loadModels(), loadAgents()]);
|
|
936
|
+
</script>
|
|
937
|
+
</body>
|
|
938
|
+
</html>`
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Create a nonce for a permission request
|
|
943
|
+
* @param {string} sessionId - OpenCode session ID
|
|
944
|
+
* @param {string} permissionId - Permission request ID
|
|
945
|
+
* @returns {string} The generated nonce
|
|
946
|
+
*/
|
|
947
|
+
function createNonce(sessionId, permissionId) {
|
|
948
|
+
const nonce = randomUUID()
|
|
949
|
+
nonces.set(nonce, {
|
|
950
|
+
sessionId,
|
|
951
|
+
permissionId,
|
|
952
|
+
createdAt: Date.now(),
|
|
953
|
+
})
|
|
954
|
+
return nonce
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Consume a nonce, returning its data if valid
|
|
959
|
+
* @param {string} nonce - The nonce to consume
|
|
960
|
+
* @returns {Object|null} { sessionId, permissionId } or null if invalid/expired
|
|
961
|
+
*/
|
|
962
|
+
function consumeNonce(nonce) {
|
|
963
|
+
const data = nonces.get(nonce)
|
|
964
|
+
if (!data) return null
|
|
965
|
+
|
|
966
|
+
nonces.delete(nonce)
|
|
967
|
+
|
|
968
|
+
if (Date.now() - data.createdAt > NONCE_TTL_MS) {
|
|
969
|
+
return null
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
sessionId: data.sessionId,
|
|
974
|
+
permissionId: data.permissionId,
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Clean up expired nonces
|
|
980
|
+
* @returns {number} Number of expired nonces removed
|
|
981
|
+
*/
|
|
982
|
+
function cleanupNonces() {
|
|
983
|
+
const now = Date.now()
|
|
984
|
+
let removed = 0
|
|
985
|
+
|
|
986
|
+
for (const [nonce, data] of nonces) {
|
|
987
|
+
if (now - data.createdAt > NONCE_TTL_MS) {
|
|
988
|
+
nonces.delete(nonce)
|
|
989
|
+
removed++
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return removed
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Register a session connection
|
|
998
|
+
* @param {string} sessionId - OpenCode session ID
|
|
999
|
+
* @param {net.Socket} socket - Socket connection to the plugin
|
|
1000
|
+
*/
|
|
1001
|
+
function registerSession(sessionId, socket) {
|
|
1002
|
+
console.log(`[opencode-pilot] Session registered: ${sessionId}`)
|
|
1003
|
+
sessions.set(sessionId, socket)
|
|
1004
|
+
|
|
1005
|
+
socket.on('close', () => {
|
|
1006
|
+
console.log(`[opencode-pilot] Session disconnected: ${sessionId}`)
|
|
1007
|
+
sessions.delete(sessionId)
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Send a permission response to a session
|
|
1013
|
+
* @param {string} sessionId - OpenCode session ID
|
|
1014
|
+
* @param {string} permissionId - Permission request ID
|
|
1015
|
+
* @param {string} response - Response type: 'once' | 'always' | 'reject'
|
|
1016
|
+
* @returns {boolean} True if sent successfully
|
|
1017
|
+
*/
|
|
1018
|
+
function sendToSession(sessionId, permissionId, response) {
|
|
1019
|
+
const socket = sessions.get(sessionId)
|
|
1020
|
+
if (!socket) {
|
|
1021
|
+
console.warn(`[opencode-pilot] Session not found: ${sessionId}`)
|
|
1022
|
+
return false
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
try {
|
|
1026
|
+
const message = JSON.stringify({
|
|
1027
|
+
type: 'permission_response',
|
|
1028
|
+
permissionId,
|
|
1029
|
+
response,
|
|
1030
|
+
})
|
|
1031
|
+
socket.write(message + '\n')
|
|
1032
|
+
return true
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
console.error(`[opencode-pilot] Failed to send to session ${sessionId}: ${error.message}`)
|
|
1035
|
+
return false
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Proxy a request to the OpenCode server
|
|
1041
|
+
* @param {http.IncomingMessage} req - Incoming request
|
|
1042
|
+
* @param {http.ServerResponse} res - Outgoing response
|
|
1043
|
+
* @param {number} targetPort - Target port for OpenCode server
|
|
1044
|
+
* @param {string} targetPath - Target path on the OpenCode server
|
|
1045
|
+
*/
|
|
1046
|
+
async function proxyToOpenCode(req, res, targetPort, targetPath) {
|
|
1047
|
+
// Validate port to prevent localhost port scanning
|
|
1048
|
+
if (!isValidPort(targetPort)) {
|
|
1049
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
1050
|
+
res.end(JSON.stringify({ error: 'Invalid port' }))
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
// Read request body for POST/PUT requests with size limit
|
|
1056
|
+
let body = null
|
|
1057
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
1058
|
+
const chunks = []
|
|
1059
|
+
let totalSize = 0
|
|
1060
|
+
for await (const chunk of req) {
|
|
1061
|
+
totalSize += chunk.length
|
|
1062
|
+
if (totalSize > MAX_BODY_SIZE) {
|
|
1063
|
+
res.writeHead(413, { 'Content-Type': 'application/json' })
|
|
1064
|
+
res.end(JSON.stringify({ error: 'Request body too large' }))
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
chunks.push(chunk)
|
|
1068
|
+
}
|
|
1069
|
+
body = Buffer.concat(chunks)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Make request to OpenCode
|
|
1073
|
+
const targetUrl = `http://localhost:${targetPort}${targetPath}`
|
|
1074
|
+
const fetchOptions = {
|
|
1075
|
+
method: req.method,
|
|
1076
|
+
headers: {
|
|
1077
|
+
'Content-Type': req.headers['content-type'] || 'application/json',
|
|
1078
|
+
'Accept': 'application/json',
|
|
1079
|
+
},
|
|
1080
|
+
}
|
|
1081
|
+
if (body) {
|
|
1082
|
+
fetchOptions.body = body
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const proxyRes = await fetch(targetUrl, fetchOptions)
|
|
1086
|
+
|
|
1087
|
+
// Forward response
|
|
1088
|
+
const responseBody = await proxyRes.text()
|
|
1089
|
+
res.writeHead(proxyRes.status, {
|
|
1090
|
+
'Content-Type': proxyRes.headers.get('content-type') || 'application/json',
|
|
1091
|
+
'Access-Control-Allow-Origin': '*',
|
|
1092
|
+
})
|
|
1093
|
+
res.end(responseBody)
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
console.error(`[opencode-pilot] Proxy error: ${error.message}`)
|
|
1096
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
1097
|
+
res.end(JSON.stringify({ error: 'Failed to connect to OpenCode server' }))
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Create the HTTP callback server
|
|
1103
|
+
* @param {number} port - Port to listen on
|
|
1104
|
+
* @returns {http.Server} The HTTP server
|
|
1105
|
+
*/
|
|
1106
|
+
function createCallbackServer(port) {
|
|
1107
|
+
const callbackConfig = loadCallbackConfig()
|
|
1108
|
+
|
|
1109
|
+
const server = createHttpServer(async (req, res) => {
|
|
1110
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
1111
|
+
|
|
1112
|
+
// Redirect to HTTPS if configured and request came directly to HTTP port
|
|
1113
|
+
// (Tailscale Serve handles HTTPS termination and forwards to us)
|
|
1114
|
+
// We detect direct HTTP access by checking the X-Forwarded-Proto header
|
|
1115
|
+
// which Tailscale Serve sets when proxying
|
|
1116
|
+
// Exception: /health endpoint always works on HTTP for monitoring
|
|
1117
|
+
if (callbackConfig.callbackHttps && callbackConfig.callbackHost && url.pathname !== '/health') {
|
|
1118
|
+
const forwardedProto = req.headers['x-forwarded-proto']
|
|
1119
|
+
// If no forwarded proto header, request came directly to HTTP port
|
|
1120
|
+
if (!forwardedProto) {
|
|
1121
|
+
const httpsUrl = `https://${callbackConfig.callbackHost}${url.pathname}${url.search}`
|
|
1122
|
+
res.writeHead(301, { 'Location': httpsUrl })
|
|
1123
|
+
res.end()
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// OPTIONS - CORS preflight
|
|
1129
|
+
if (req.method === 'OPTIONS') {
|
|
1130
|
+
res.writeHead(204, {
|
|
1131
|
+
'Access-Control-Allow-Origin': '*',
|
|
1132
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
1133
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
1134
|
+
'Access-Control-Max-Age': '86400',
|
|
1135
|
+
})
|
|
1136
|
+
res.end()
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// GET /health - Health check
|
|
1141
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
1142
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
1143
|
+
res.end('OK')
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// GET /favorites - Get favorite models from local OpenCode state
|
|
1148
|
+
if (req.method === 'GET' && url.pathname === '/favorites') {
|
|
1149
|
+
try {
|
|
1150
|
+
const modelFile = join(homedir(), '.local', 'state', 'opencode', 'model.json')
|
|
1151
|
+
if (existsSync(modelFile)) {
|
|
1152
|
+
const data = JSON.parse(readFileSync(modelFile, 'utf8'))
|
|
1153
|
+
res.writeHead(200, {
|
|
1154
|
+
'Content-Type': 'application/json',
|
|
1155
|
+
'Access-Control-Allow-Origin': '*'
|
|
1156
|
+
})
|
|
1157
|
+
res.end(JSON.stringify(data.favorite || []))
|
|
1158
|
+
} else {
|
|
1159
|
+
res.writeHead(200, {
|
|
1160
|
+
'Content-Type': 'application/json',
|
|
1161
|
+
'Access-Control-Allow-Origin': '*'
|
|
1162
|
+
})
|
|
1163
|
+
res.end('[]')
|
|
1164
|
+
}
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
res.writeHead(200, {
|
|
1167
|
+
'Content-Type': 'application/json',
|
|
1168
|
+
'Access-Control-Allow-Origin': '*'
|
|
1169
|
+
})
|
|
1170
|
+
res.end('[]')
|
|
1171
|
+
}
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// GET or POST /callback - Permission response from ntfy
|
|
1176
|
+
// GET is used by 'view' actions (opens in browser), POST by 'http' actions
|
|
1177
|
+
if ((req.method === 'GET' || req.method === 'POST') && url.pathname === '/callback') {
|
|
1178
|
+
const nonce = url.searchParams.get('nonce')
|
|
1179
|
+
const response = url.searchParams.get('response')
|
|
1180
|
+
|
|
1181
|
+
// Validate required params
|
|
1182
|
+
if (!nonce || !response) {
|
|
1183
|
+
res.writeHead(400, { 'Content-Type': 'text/html' })
|
|
1184
|
+
res.end(htmlResponse('Error', 'Missing required parameters', false))
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Validate response value
|
|
1189
|
+
if (!VALID_RESPONSES.includes(response)) {
|
|
1190
|
+
res.writeHead(400, { 'Content-Type': 'text/html' })
|
|
1191
|
+
res.end(htmlResponse('Error', 'Invalid response value', false))
|
|
1192
|
+
return
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Validate and consume nonce
|
|
1196
|
+
const payload = consumeNonce(nonce)
|
|
1197
|
+
if (!payload) {
|
|
1198
|
+
res.writeHead(401, { 'Content-Type': 'text/html' })
|
|
1199
|
+
res.end(htmlResponse('Error', 'Invalid or expired nonce', false))
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Forward to session
|
|
1204
|
+
const sent = sendToSession(payload.sessionId, payload.permissionId, response)
|
|
1205
|
+
if (sent) {
|
|
1206
|
+
const actionLabel = response === 'once' ? 'Allowed (once)' : response === 'always' ? 'Allowed (always)' : 'Rejected'
|
|
1207
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
1208
|
+
res.end(htmlResponse('Done', actionLabel, true))
|
|
1209
|
+
} else {
|
|
1210
|
+
res.writeHead(503, { 'Content-Type': 'text/html' })
|
|
1211
|
+
res.end(htmlResponse('Error', 'Session not connected', false))
|
|
1212
|
+
}
|
|
1213
|
+
return
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// GET /m/:port/:repo/session/:sessionId - Mobile session UI
|
|
1217
|
+
const mobileMatch = url.pathname.match(/^\/m\/(\d+)\/([^/]+)\/session\/([^/]+)$/)
|
|
1218
|
+
if (req.method === 'GET' && mobileMatch) {
|
|
1219
|
+
const [, portStr, repoName, sessionId] = mobileMatch
|
|
1220
|
+
const opencodePort = parseInt(portStr, 10)
|
|
1221
|
+
|
|
1222
|
+
// Validate port to prevent abuse
|
|
1223
|
+
if (!isValidPort(opencodePort)) {
|
|
1224
|
+
res.writeHead(400, { 'Content-Type': 'text/html' })
|
|
1225
|
+
res.end(htmlResponse('Error', 'Invalid port', false))
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
1230
|
+
res.end(mobileSessionPage({
|
|
1231
|
+
repoName: decodeURIComponent(repoName),
|
|
1232
|
+
sessionId,
|
|
1233
|
+
opencodePort,
|
|
1234
|
+
}))
|
|
1235
|
+
return
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// API Proxy routes - /api/:port/session/:sessionId
|
|
1239
|
+
const apiSessionMatch = url.pathname.match(/^\/api\/(\d+)\/session\/([^/]+)$/)
|
|
1240
|
+
if (apiSessionMatch) {
|
|
1241
|
+
const [, opencodePort, sessionId] = apiSessionMatch
|
|
1242
|
+
await proxyToOpenCode(req, res, parseInt(opencodePort, 10), `/session/${sessionId}`)
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// API Proxy routes - /api/:port/session/:sessionId/chat
|
|
1247
|
+
const apiChatMatch = url.pathname.match(/^\/api\/(\d+)\/session\/([^/]+)\/chat$/)
|
|
1248
|
+
if (apiChatMatch) {
|
|
1249
|
+
const [, opencodePort, sessionId] = apiChatMatch
|
|
1250
|
+
await proxyToOpenCode(req, res, parseInt(opencodePort, 10), `/session/${sessionId}/chat`)
|
|
1251
|
+
return
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// API Proxy routes - /api/:port/session/:sessionId/message (for new session page)
|
|
1255
|
+
const apiMessageMatch = url.pathname.match(/^\/api\/(\d+)\/session\/([^/]+)\/message$/)
|
|
1256
|
+
if (apiMessageMatch) {
|
|
1257
|
+
const [, opencodePort, sessionId] = apiMessageMatch
|
|
1258
|
+
await proxyToOpenCode(req, res, parseInt(opencodePort, 10), `/session/${sessionId}/message`)
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// API Proxy routes - /api/:port/agent (for agent selection)
|
|
1263
|
+
const apiAgentMatch = url.pathname.match(/^\/api\/(\d+)\/agent$/)
|
|
1264
|
+
if (apiAgentMatch) {
|
|
1265
|
+
const [, opencodePort] = apiAgentMatch
|
|
1266
|
+
await proxyToOpenCode(req, res, parseInt(opencodePort, 10), '/agent')
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// API Proxy routes - /api/:port/provider (for model selection)
|
|
1271
|
+
const apiProviderMatch = url.pathname.match(/^\/api\/(\d+)\/provider$/)
|
|
1272
|
+
if (apiProviderMatch) {
|
|
1273
|
+
const [, opencodePort] = apiProviderMatch
|
|
1274
|
+
await proxyToOpenCode(req, res, parseInt(opencodePort, 10), '/provider')
|
|
1275
|
+
return
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Unknown route
|
|
1279
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
1280
|
+
res.end('Not found')
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
server.on('error', (err) => {
|
|
1284
|
+
console.error(`[opencode-pilot] HTTP server error: ${err.message}`)
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
return server
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Create the Unix socket server for IPC
|
|
1292
|
+
* @param {string} socketPath - Path to the socket file
|
|
1293
|
+
* @returns {net.Server} The socket server
|
|
1294
|
+
*/
|
|
1295
|
+
function createSocketServer(socketPath) {
|
|
1296
|
+
const server = createNetServer((socket) => {
|
|
1297
|
+
console.log('[opencode-pilot] Plugin connected')
|
|
1298
|
+
|
|
1299
|
+
let buffer = ''
|
|
1300
|
+
|
|
1301
|
+
socket.on('data', (data) => {
|
|
1302
|
+
buffer += data.toString()
|
|
1303
|
+
|
|
1304
|
+
// Process complete messages (newline-delimited JSON)
|
|
1305
|
+
let newlineIndex
|
|
1306
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
1307
|
+
const line = buffer.slice(0, newlineIndex)
|
|
1308
|
+
buffer = buffer.slice(newlineIndex + 1)
|
|
1309
|
+
|
|
1310
|
+
if (!line.trim()) continue
|
|
1311
|
+
|
|
1312
|
+
try {
|
|
1313
|
+
const message = JSON.parse(line)
|
|
1314
|
+
handleSocketMessage(socket, message)
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
console.warn(`[opencode-pilot] Invalid message: ${error.message}`)
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
socket.on('error', (err) => {
|
|
1322
|
+
console.warn(`[opencode-pilot] Socket error: ${err.message}`)
|
|
1323
|
+
})
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
server.on('error', (err) => {
|
|
1327
|
+
console.error(`[opencode-pilot] Socket server error: ${err.message}`)
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
return server
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Handle a message from a plugin
|
|
1335
|
+
* @param {net.Socket} socket - The socket connection
|
|
1336
|
+
* @param {Object} message - The parsed message
|
|
1337
|
+
*/
|
|
1338
|
+
function handleSocketMessage(socket, message) {
|
|
1339
|
+
switch (message.type) {
|
|
1340
|
+
case 'register':
|
|
1341
|
+
if (message.sessionId) {
|
|
1342
|
+
registerSession(message.sessionId, socket)
|
|
1343
|
+
socket.write(JSON.stringify({ type: 'registered', sessionId: message.sessionId }) + '\n')
|
|
1344
|
+
}
|
|
1345
|
+
break
|
|
1346
|
+
|
|
1347
|
+
case 'create_nonce':
|
|
1348
|
+
if (message.sessionId && message.permissionId) {
|
|
1349
|
+
const nonce = createNonce(message.sessionId, message.permissionId)
|
|
1350
|
+
socket.write(JSON.stringify({ type: 'nonce_created', nonce, permissionId: message.permissionId }) + '\n')
|
|
1351
|
+
}
|
|
1352
|
+
break
|
|
1353
|
+
|
|
1354
|
+
default:
|
|
1355
|
+
console.warn(`[opencode-pilot] Unknown message type: ${message.type}`)
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Start the callback service
|
|
1361
|
+
* @param {Object} config - Configuration options
|
|
1362
|
+
* @param {number} [config.httpPort] - HTTP server port (default: 4097)
|
|
1363
|
+
* @param {string} [config.socketPath] - Unix socket path (default: /tmp/opencode-pilot.sock)
|
|
1364
|
+
* @param {boolean} [config.enablePolling] - Enable polling for tracker items (default: true)
|
|
1365
|
+
* @param {number} [config.pollInterval] - Poll interval in ms (default: 5 minutes)
|
|
1366
|
+
* @param {string} [config.reposConfig] - Path to config.yaml
|
|
1367
|
+
* @returns {Promise<Object>} Service instance with httpServer, socketServer, polling, and cleanup interval
|
|
1368
|
+
*/
|
|
1369
|
+
export async function startService(config = {}) {
|
|
1370
|
+
const httpPort = config.httpPort ?? DEFAULT_HTTP_PORT
|
|
1371
|
+
const socketPath = config.socketPath ?? DEFAULT_SOCKET_PATH
|
|
1372
|
+
const enablePolling = config.enablePolling !== false
|
|
1373
|
+
const pollInterval = config.pollInterval ?? DEFAULT_POLL_INTERVAL
|
|
1374
|
+
const reposConfig = config.reposConfig ?? DEFAULT_REPOS_CONFIG
|
|
1375
|
+
|
|
1376
|
+
// Clean up stale socket file
|
|
1377
|
+
if (existsSync(socketPath)) {
|
|
1378
|
+
try {
|
|
1379
|
+
unlinkSync(socketPath)
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
console.warn(`[opencode-pilot] Could not remove stale socket: ${err.message}`)
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Create servers
|
|
1386
|
+
const httpServer = createCallbackServer(httpPort)
|
|
1387
|
+
const socketServer = createSocketServer(socketPath)
|
|
1388
|
+
|
|
1389
|
+
// Start HTTP server
|
|
1390
|
+
await new Promise((resolve, reject) => {
|
|
1391
|
+
httpServer.listen(httpPort, () => {
|
|
1392
|
+
const actualPort = httpServer.address().port
|
|
1393
|
+
console.log(`[opencode-pilot] HTTP server listening on port ${actualPort}`)
|
|
1394
|
+
resolve()
|
|
1395
|
+
})
|
|
1396
|
+
httpServer.once('error', reject)
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
// Start socket server
|
|
1400
|
+
await new Promise((resolve, reject) => {
|
|
1401
|
+
socketServer.listen(socketPath, () => {
|
|
1402
|
+
console.log(`[opencode-pilot] Socket server listening at ${socketPath}`)
|
|
1403
|
+
resolve()
|
|
1404
|
+
})
|
|
1405
|
+
socketServer.once('error', reject)
|
|
1406
|
+
})
|
|
1407
|
+
|
|
1408
|
+
// Start periodic nonce cleanup
|
|
1409
|
+
const cleanupInterval = setInterval(() => {
|
|
1410
|
+
const removed = cleanupNonces()
|
|
1411
|
+
if (removed > 0) {
|
|
1412
|
+
console.log(`[opencode-pilot] Cleaned up ${removed} expired nonces`)
|
|
1413
|
+
}
|
|
1414
|
+
}, 60 * 1000) // Every minute
|
|
1415
|
+
|
|
1416
|
+
// Start polling for tracker items if config exists
|
|
1417
|
+
let pollingState = null
|
|
1418
|
+
if (enablePolling && existsSync(reposConfig)) {
|
|
1419
|
+
try {
|
|
1420
|
+
// Dynamic import to avoid circular dependencies
|
|
1421
|
+
const { startPolling } = await import('./poll-service.js')
|
|
1422
|
+
pollingState = startPolling({
|
|
1423
|
+
configPath: reposConfig,
|
|
1424
|
+
interval: pollInterval,
|
|
1425
|
+
})
|
|
1426
|
+
console.log(`[opencode-pilot] Polling enabled with config: ${reposConfig}`)
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
console.warn(`[opencode-pilot] Could not start polling: ${err.message}`)
|
|
1429
|
+
}
|
|
1430
|
+
} else if (enablePolling) {
|
|
1431
|
+
console.log(`[opencode-pilot] Polling disabled (no config.yaml at ${reposConfig})`)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return {
|
|
1435
|
+
httpServer,
|
|
1436
|
+
socketServer,
|
|
1437
|
+
cleanupInterval,
|
|
1438
|
+
socketPath,
|
|
1439
|
+
pollingState,
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Stop the callback service
|
|
1445
|
+
* @param {Object} service - Service instance from startService
|
|
1446
|
+
*/
|
|
1447
|
+
export async function stopService(service) {
|
|
1448
|
+
if (service.cleanupInterval) {
|
|
1449
|
+
clearInterval(service.cleanupInterval)
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Stop polling if active
|
|
1453
|
+
if (service.pollingState) {
|
|
1454
|
+
service.pollingState.stop()
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Close all active session connections first
|
|
1458
|
+
// This is necessary because server.close() waits for all connections to close
|
|
1459
|
+
for (const [sessionId, socket] of sessions) {
|
|
1460
|
+
socket.destroy()
|
|
1461
|
+
}
|
|
1462
|
+
sessions.clear()
|
|
1463
|
+
|
|
1464
|
+
if (service.httpServer) {
|
|
1465
|
+
await new Promise((resolve) => {
|
|
1466
|
+
service.httpServer.close(resolve)
|
|
1467
|
+
})
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
if (service.socketServer) {
|
|
1471
|
+
await new Promise((resolve) => {
|
|
1472
|
+
service.socketServer.close(resolve)
|
|
1473
|
+
})
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Clean up socket file
|
|
1477
|
+
if (service.socketPath && existsSync(service.socketPath)) {
|
|
1478
|
+
try {
|
|
1479
|
+
unlinkSync(service.socketPath)
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
// Ignore errors
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
console.log('[opencode-pilot] Service stopped')
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// If run directly, start the service
|
|
1489
|
+
// Use realpath comparison to handle symlinks (e.g., /tmp vs /private/tmp on macOS,
|
|
1490
|
+
// or /opt/homebrew/opt vs /opt/homebrew/Cellar)
|
|
1491
|
+
function isMainModule() {
|
|
1492
|
+
try {
|
|
1493
|
+
const currentFile = realpathSync(fileURLToPath(import.meta.url))
|
|
1494
|
+
const argvFile = realpathSync(process.argv[1])
|
|
1495
|
+
return currentFile === argvFile
|
|
1496
|
+
} catch {
|
|
1497
|
+
return false
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (isMainModule()) {
|
|
1502
|
+
const config = {
|
|
1503
|
+
httpPort: parseInt(process.env.NTFY_CALLBACK_PORT || '4097', 10),
|
|
1504
|
+
socketPath: process.env.NTFY_SOCKET_PATH || DEFAULT_SOCKET_PATH,
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
console.log('[opencode-pilot] Starting callback service...')
|
|
1508
|
+
|
|
1509
|
+
const service = await startService(config)
|
|
1510
|
+
|
|
1511
|
+
// Handle graceful shutdown
|
|
1512
|
+
process.on('SIGTERM', async () => {
|
|
1513
|
+
console.log('[opencode-pilot] Received SIGTERM, shutting down...')
|
|
1514
|
+
await stopService(service)
|
|
1515
|
+
process.exit(0)
|
|
1516
|
+
})
|
|
1517
|
+
|
|
1518
|
+
process.on('SIGINT', async () => {
|
|
1519
|
+
console.log('[opencode-pilot] Received SIGINT, shutting down...')
|
|
1520
|
+
await stopService(service)
|
|
1521
|
+
process.exit(0)
|
|
1522
|
+
})
|
|
1523
|
+
}
|