opencode-pilot 0.1.0 → 0.2.1
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/server.js +44 -1381
- package/test/unit/paths.test.js +4 -46
- 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/service/io.opencode.ntfy.plist +0 -29
- package/test/run_tests.bash +0 -34
- package/test/test_actions.bash +0 -263
- package/test/test_cli.bash +0 -161
- package/test/test_config.bash +0 -438
- package/test/test_helper.bash +0 -140
- package/test/test_logger.bash +0 -401
- package/test/test_notifier.bash +0 -310
- package/test/test_plist.bash +0 -125
- package/test/test_plugin.bash +0 -952
- package/test/test_poll_service.bash +0 -179
- package/test/test_poller.bash +0 -120
- package/test/test_readiness.bash +0 -313
- package/test/test_repo_config.bash +0 -406
- package/test/test_service.bash +0 -1342
- package/test/unit/config.test.js +0 -86
package/service/server.js
CHANGED
|
@@ -1,1135 +1,54 @@
|
|
|
1
|
-
// Standalone
|
|
2
|
-
// Implements Issue #13: Separate callback server as brew service
|
|
1
|
+
// Standalone server for opencode-pilot
|
|
3
2
|
//
|
|
4
|
-
// This service runs persistently
|
|
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
|
|
3
|
+
// This service runs persistently and handles:
|
|
9
4
|
// - Polling for tracker items (GitHub issues, Linear issues)
|
|
5
|
+
// - Health check endpoint
|
|
10
6
|
|
|
11
7
|
import { createServer as createHttpServer } from 'http'
|
|
12
|
-
import {
|
|
13
|
-
import { randomUUID } from 'crypto'
|
|
14
|
-
import { existsSync, unlinkSync, realpathSync, readFileSync } from 'fs'
|
|
8
|
+
import { existsSync, realpathSync, readFileSync } from 'fs'
|
|
15
9
|
import { fileURLToPath } from 'url'
|
|
16
10
|
import { homedir } from 'os'
|
|
17
|
-
import { join
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import YAML from 'yaml'
|
|
18
13
|
|
|
19
14
|
// Default configuration
|
|
20
15
|
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
16
|
const DEFAULT_REPOS_CONFIG = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
|
|
24
17
|
const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000 // 5 minutes
|
|
25
18
|
|
|
26
19
|
/**
|
|
27
|
-
* Load
|
|
28
|
-
*
|
|
29
|
-
* @returns {Object} Config with callbackHttps and callbackHost
|
|
20
|
+
* Load port from config file
|
|
21
|
+
* @returns {number} Port number
|
|
30
22
|
*/
|
|
31
|
-
function
|
|
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
|
-
|
|
23
|
+
function getPortFromConfig() {
|
|
1054
24
|
try {
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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)
|
|
25
|
+
if (existsSync(DEFAULT_REPOS_CONFIG)) {
|
|
26
|
+
const content = readFileSync(DEFAULT_REPOS_CONFIG, 'utf8')
|
|
27
|
+
const config = YAML.parse(content)
|
|
28
|
+
if (config?.port && typeof config.port === 'number') {
|
|
29
|
+
return config.port
|
|
1068
30
|
}
|
|
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
31
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
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' }))
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore errors, use default
|
|
1098
34
|
}
|
|
35
|
+
return DEFAULT_HTTP_PORT
|
|
1099
36
|
}
|
|
1100
37
|
|
|
1101
38
|
/**
|
|
1102
|
-
* Create the HTTP
|
|
39
|
+
* Create the HTTP server (health check only)
|
|
1103
40
|
* @param {number} port - Port to listen on
|
|
1104
41
|
* @returns {http.Server} The HTTP server
|
|
1105
42
|
*/
|
|
1106
|
-
function
|
|
1107
|
-
const callbackConfig = loadCallbackConfig()
|
|
1108
|
-
|
|
43
|
+
function createHttpServer_(port) {
|
|
1109
44
|
const server = createHttpServer(async (req, res) => {
|
|
1110
45
|
const url = new URL(req.url, `http://localhost:${port}`)
|
|
1111
46
|
|
|
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
47
|
// OPTIONS - CORS preflight
|
|
1129
48
|
if (req.method === 'OPTIONS') {
|
|
1130
49
|
res.writeHead(204, {
|
|
1131
50
|
'Access-Control-Allow-Origin': '*',
|
|
1132
|
-
'Access-Control-Allow-Methods': 'GET,
|
|
51
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
1133
52
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
1134
53
|
'Access-Control-Max-Age': '86400',
|
|
1135
54
|
})
|
|
@@ -1144,137 +63,6 @@ function createCallbackServer(port) {
|
|
|
1144
63
|
return
|
|
1145
64
|
}
|
|
1146
65
|
|
|
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
66
|
// Unknown route
|
|
1279
67
|
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
1280
68
|
res.end('Not found')
|
|
@@ -1288,103 +76,22 @@ function createCallbackServer(port) {
|
|
|
1288
76
|
}
|
|
1289
77
|
|
|
1290
78
|
/**
|
|
1291
|
-
*
|
|
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
|
|
79
|
+
* Start the service
|
|
1361
80
|
* @param {Object} config - Configuration options
|
|
1362
81
|
* @param {number} [config.httpPort] - HTTP server port (default: 4097)
|
|
1363
|
-
* @param {string} [config.socketPath] - Unix socket path (default: /tmp/opencode-pilot.sock)
|
|
1364
82
|
* @param {boolean} [config.enablePolling] - Enable polling for tracker items (default: true)
|
|
1365
83
|
* @param {number} [config.pollInterval] - Poll interval in ms (default: 5 minutes)
|
|
1366
84
|
* @param {string} [config.reposConfig] - Path to config.yaml
|
|
1367
|
-
* @returns {Promise<Object>} Service instance with httpServer
|
|
85
|
+
* @returns {Promise<Object>} Service instance with httpServer and polling state
|
|
1368
86
|
*/
|
|
1369
87
|
export async function startService(config = {}) {
|
|
1370
88
|
const httpPort = config.httpPort ?? DEFAULT_HTTP_PORT
|
|
1371
|
-
const socketPath = config.socketPath ?? DEFAULT_SOCKET_PATH
|
|
1372
89
|
const enablePolling = config.enablePolling !== false
|
|
1373
90
|
const pollInterval = config.pollInterval ?? DEFAULT_POLL_INTERVAL
|
|
1374
91
|
const reposConfig = config.reposConfig ?? DEFAULT_REPOS_CONFIG
|
|
1375
92
|
|
|
1376
|
-
//
|
|
1377
|
-
|
|
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)
|
|
93
|
+
// Create HTTP server
|
|
94
|
+
const httpServer = createHttpServer_(httpPort)
|
|
1388
95
|
|
|
1389
96
|
// Start HTTP server
|
|
1390
97
|
await new Promise((resolve, reject) => {
|
|
@@ -1396,23 +103,6 @@ export async function startService(config = {}) {
|
|
|
1396
103
|
httpServer.once('error', reject)
|
|
1397
104
|
})
|
|
1398
105
|
|
|
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
106
|
// Start polling for tracker items if config exists
|
|
1417
107
|
let pollingState = null
|
|
1418
108
|
if (enablePolling && existsSync(reposConfig)) {
|
|
@@ -1433,59 +123,30 @@ export async function startService(config = {}) {
|
|
|
1433
123
|
|
|
1434
124
|
return {
|
|
1435
125
|
httpServer,
|
|
1436
|
-
socketServer,
|
|
1437
|
-
cleanupInterval,
|
|
1438
|
-
socketPath,
|
|
1439
126
|
pollingState,
|
|
1440
127
|
}
|
|
1441
128
|
}
|
|
1442
129
|
|
|
1443
130
|
/**
|
|
1444
|
-
* Stop the
|
|
131
|
+
* Stop the service
|
|
1445
132
|
* @param {Object} service - Service instance from startService
|
|
1446
133
|
*/
|
|
1447
134
|
export async function stopService(service) {
|
|
1448
|
-
if (service.cleanupInterval) {
|
|
1449
|
-
clearInterval(service.cleanupInterval)
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
135
|
// Stop polling if active
|
|
1453
136
|
if (service.pollingState) {
|
|
1454
137
|
service.pollingState.stop()
|
|
1455
138
|
}
|
|
1456
139
|
|
|
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
140
|
if (service.httpServer) {
|
|
1465
141
|
await new Promise((resolve) => {
|
|
1466
142
|
service.httpServer.close(resolve)
|
|
1467
143
|
})
|
|
1468
144
|
}
|
|
1469
145
|
|
|
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
146
|
console.log('[opencode-pilot] Service stopped')
|
|
1486
147
|
}
|
|
1487
148
|
|
|
1488
|
-
//
|
|
149
|
+
// Check if this is the main module
|
|
1489
150
|
// Use realpath comparison to handle symlinks (e.g., /tmp vs /private/tmp on macOS,
|
|
1490
151
|
// or /opt/homebrew/opt vs /opt/homebrew/Cellar)
|
|
1491
152
|
function isMainModule() {
|
|
@@ -1500,24 +161,26 @@ function isMainModule() {
|
|
|
1500
161
|
|
|
1501
162
|
if (isMainModule()) {
|
|
1502
163
|
const config = {
|
|
1503
|
-
httpPort:
|
|
1504
|
-
socketPath: process.env.NTFY_SOCKET_PATH || DEFAULT_SOCKET_PATH,
|
|
164
|
+
httpPort: getPortFromConfig(),
|
|
1505
165
|
}
|
|
1506
166
|
|
|
1507
|
-
console.log('[opencode-pilot] Starting
|
|
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
|
-
})
|
|
167
|
+
console.log('[opencode-pilot] Starting service...')
|
|
1517
168
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
169
|
+
startService(config).then((service) => {
|
|
170
|
+
// Handle graceful shutdown
|
|
171
|
+
process.on('SIGTERM', async () => {
|
|
172
|
+
console.log('[opencode-pilot] Received SIGTERM, shutting down...')
|
|
173
|
+
await stopService(service)
|
|
174
|
+
process.exit(0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
process.on('SIGINT', async () => {
|
|
178
|
+
console.log('[opencode-pilot] Received SIGINT, shutting down...')
|
|
179
|
+
await stopService(service)
|
|
180
|
+
process.exit(0)
|
|
181
|
+
})
|
|
182
|
+
}).catch((err) => {
|
|
183
|
+
console.error(`[opencode-pilot] Failed to start: ${err.message}`)
|
|
184
|
+
process.exit(1)
|
|
1522
185
|
})
|
|
1523
186
|
}
|