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/service/server.js CHANGED
@@ -1,1135 +1,54 @@
1
- // Standalone callback server for opencode-pilot
2
- // Implements Issue #13: Separate callback server as brew service
1
+ // Standalone server for opencode-pilot
3
2
  //
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
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 { createServer as createNetServer } from 'net'
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, dirname } from 'path'
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 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
20
+ * Load port from config file
21
+ * @returns {number} Port number
30
22
  */
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, '&amp;')
118
- .replace(/</g, '&lt;')
119
- .replace(/>/g, '&gt;')
120
- .replace(/"/g, '&quot;')
121
- .replace(/'/g, '&#039;')
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
- // 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)
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
- 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' }))
32
+ } catch {
33
+ // Ignore errors, use default
1098
34
  }
35
+ return DEFAULT_HTTP_PORT
1099
36
  }
1100
37
 
1101
38
  /**
1102
- * Create the HTTP callback server
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 createCallbackServer(port) {
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, POST, OPTIONS',
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
- * 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
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, socketServer, polling, and cleanup interval
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
- // 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)
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 callback service
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
- // If run directly, start the service
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: parseInt(process.env.NTFY_CALLBACK_PORT || '4097', 10),
1504
- socketPath: process.env.NTFY_SOCKET_PATH || DEFAULT_SOCKET_PATH,
164
+ httpPort: getPortFromConfig(),
1505
165
  }
1506
166
 
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
- })
167
+ console.log('[opencode-pilot] Starting service...')
1517
168
 
1518
- process.on('SIGINT', async () => {
1519
- console.log('[opencode-pilot] Received SIGINT, shutting down...')
1520
- await stopService(service)
1521
- process.exit(0)
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
  }