opencode-pilot 0.19.1 → 0.20.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/README.md CHANGED
@@ -31,7 +31,7 @@ npm install -g opencode-pilot
31
31
  }
32
32
  ```
33
33
 
34
- The daemon will auto-start when OpenCode launches.
34
+ The daemon will auto-start when OpenCode launches. If a newer version of the plugin is installed, the daemon will automatically restart to pick up the new version.
35
35
 
36
36
  Or start manually:
37
37
 
@@ -117,7 +117,8 @@ sources:
117
117
 
118
118
  ```bash
119
119
  opencode-pilot start # Start the service (foreground)
120
- opencode-pilot status # Check status
120
+ opencode-pilot stop # Stop the running service
121
+ opencode-pilot status # Show version and service status
121
122
  opencode-pilot config # Validate and show config
122
123
  opencode-pilot clear # Show state summary
123
124
  opencode-pilot clear --all # Clear all processed state
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { fileURLToPath } from "url";
15
15
  import { dirname, join } from "path";
16
- import { existsSync, readFileSync } from "fs";
16
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
17
17
  import { execSync } from "child_process";
18
18
  import os from "os";
19
19
  import YAML from "yaml";
@@ -39,12 +39,73 @@ function findServiceDir() {
39
39
  const serviceDir = findServiceDir();
40
40
 
41
41
  // Paths
42
- const PILOT_CONFIG_FILE = join(os.homedir(), ".config/opencode/pilot/config.yaml");
43
- const PILOT_TEMPLATES_DIR = join(os.homedir(), ".config/opencode/pilot/templates");
42
+ const PILOT_CONFIG_DIR = join(os.homedir(), ".config/opencode/pilot");
43
+ const PILOT_CONFIG_FILE = join(PILOT_CONFIG_DIR, "config.yaml");
44
+ const PILOT_TEMPLATES_DIR = join(PILOT_CONFIG_DIR, "templates");
45
+ const PILOT_PID_FILE = join(PILOT_CONFIG_DIR, "pilot.pid");
44
46
 
45
47
  // Default port
46
48
  const DEFAULT_PORT = 4097;
47
49
 
50
+ /**
51
+ * Write PID file
52
+ */
53
+ function writePidFile() {
54
+ try {
55
+ mkdirSync(PILOT_CONFIG_DIR, { recursive: true });
56
+ writeFileSync(PILOT_PID_FILE, String(process.pid));
57
+ } catch (err) {
58
+ console.warn(`[opencode-pilot] Could not write PID file: ${err.message}`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Remove PID file
64
+ */
65
+ function removePidFile() {
66
+ try {
67
+ if (existsSync(PILOT_PID_FILE)) {
68
+ unlinkSync(PILOT_PID_FILE);
69
+ }
70
+ } catch {
71
+ // Ignore errors on cleanup
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Read PID from file
77
+ * @returns {number|null} PID or null if not found/invalid
78
+ */
79
+ function readPidFile() {
80
+ try {
81
+ if (existsSync(PILOT_PID_FILE)) {
82
+ const content = readFileSync(PILOT_PID_FILE, "utf8").trim();
83
+ const pid = parseInt(content, 10);
84
+ if (!isNaN(pid) && pid > 0) {
85
+ return pid;
86
+ }
87
+ }
88
+ } catch {
89
+ // Ignore errors
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Check if a process is running
96
+ * @param {number} pid - Process ID
97
+ * @returns {boolean} True if process is running
98
+ */
99
+ function isProcessRunning(pid) {
100
+ try {
101
+ // Signal 0 doesn't kill, just checks if process exists
102
+ process.kill(pid, 0);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
48
109
  /**
49
110
  * Load port from config file
50
111
  * @returns {number} Port number
@@ -114,7 +175,8 @@ Usage:
114
175
 
115
176
  Commands:
116
177
  start Start the polling service (foreground)
117
- status Show service status
178
+ stop Stop the running service
179
+ status Show service status and version
118
180
  config Validate and show configuration
119
181
  clear Clear processed state entries
120
182
  test-source NAME Test a source by fetching items and showing mappings
@@ -133,7 +195,8 @@ The service handles:
133
195
 
134
196
  Examples:
135
197
  opencode-pilot start # Start service (foreground)
136
- opencode-pilot status # Check status
198
+ opencode-pilot stop # Stop the service
199
+ opencode-pilot status # Check status and version
137
200
  opencode-pilot config # Validate and show config
138
201
  opencode-pilot clear --all # Clear all processed state
139
202
  opencode-pilot clear --expired # Clear expired entries
@@ -157,6 +220,9 @@ async function startCommand() {
157
220
 
158
221
  console.log("[opencode-pilot] Starting polling service...");
159
222
 
223
+ // Write PID file
224
+ writePidFile();
225
+
160
226
  // Dynamic import of the service module
161
227
  const { startService, stopService } = await import(serverPath);
162
228
 
@@ -167,29 +233,91 @@ async function startCommand() {
167
233
  const service = await startService(config);
168
234
 
169
235
  // Handle graceful shutdown
170
- process.on("SIGTERM", async () => {
171
- console.log("[opencode-pilot] Received SIGTERM, shutting down...");
236
+ const shutdown = async (signal) => {
237
+ console.log(`[opencode-pilot] Received ${signal}, shutting down...`);
238
+ removePidFile();
172
239
  await stopService(service);
173
240
  process.exit(0);
174
- });
241
+ };
175
242
 
176
- process.on("SIGINT", async () => {
177
- console.log("[opencode-pilot] Received SIGINT, shutting down...");
178
- await stopService(service);
179
- process.exit(0);
180
- });
243
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
244
+ process.on("SIGINT", () => shutdown("SIGINT"));
181
245
 
182
246
  // Keep running until signal received
183
247
  await new Promise(() => {});
184
248
  }
185
249
 
250
+ // ============================================================================
251
+ // Stop Command
252
+ // ============================================================================
253
+
254
+ function stopCommand() {
255
+ const pid = readPidFile();
256
+
257
+ if (!pid) {
258
+ console.log("Service is not running (no PID file found)");
259
+ process.exit(0);
260
+ }
261
+
262
+ if (!isProcessRunning(pid)) {
263
+ console.log(`Service is not running (stale PID file for pid ${pid})`);
264
+ removePidFile();
265
+ process.exit(0);
266
+ }
267
+
268
+ console.log(`Stopping opencode-pilot (pid ${pid})...`);
269
+
270
+ try {
271
+ process.kill(pid, "SIGTERM");
272
+
273
+ // Wait for process to exit (up to 5 seconds)
274
+ let attempts = 0;
275
+ const maxAttempts = 50;
276
+ const checkInterval = 100;
277
+
278
+ const waitForExit = () => {
279
+ if (!isProcessRunning(pid)) {
280
+ console.log("Service stopped");
281
+ process.exit(0);
282
+ }
283
+
284
+ attempts++;
285
+ if (attempts >= maxAttempts) {
286
+ console.log("Service did not stop gracefully, sending SIGKILL...");
287
+ try {
288
+ process.kill(pid, "SIGKILL");
289
+ } catch {
290
+ // Process may have exited
291
+ }
292
+ removePidFile();
293
+ console.log("Service killed");
294
+ process.exit(0);
295
+ }
296
+
297
+ setTimeout(waitForExit, checkInterval);
298
+ };
299
+
300
+ setTimeout(waitForExit, checkInterval);
301
+ } catch (err) {
302
+ if (err.code === "ESRCH") {
303
+ console.log("Service is not running");
304
+ removePidFile();
305
+ } else {
306
+ console.error(`Failed to stop service: ${err.message}`);
307
+ process.exit(1);
308
+ }
309
+ }
310
+ }
311
+
186
312
  // ============================================================================
187
313
  // Status Command
188
314
  // ============================================================================
189
315
 
190
- function statusCommand() {
191
- console.log("opencode-pilot status");
192
- console.log("=====================");
316
+ async function statusCommand() {
317
+ const { getVersion } = await import(join(serviceDir, "version.js"));
318
+ const version = getVersion();
319
+ console.log(`opencode-pilot v${version}`);
320
+ console.log("=".repeat(`opencode-pilot v${version}`.length));
193
321
  console.log("");
194
322
 
195
323
  // Service running? Check if HTTP responds
@@ -702,8 +830,12 @@ async function main() {
702
830
  await startCommand();
703
831
  break;
704
832
 
833
+ case "stop":
834
+ stopCommand();
835
+ break;
836
+
705
837
  case "status":
706
- statusCommand();
838
+ await statusCommand();
707
839
  break;
708
840
 
709
841
  case "config":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
package/plugin/index.js CHANGED
@@ -2,12 +2,14 @@
2
2
  //
3
3
  // Add "opencode-pilot" to your opencode.json plugins array to enable.
4
4
  // The plugin checks if the daemon is running and starts it if needed.
5
+ // If the running daemon has a different version, it will be restarted.
5
6
 
6
7
  import { existsSync, readFileSync } from 'fs'
7
- import { spawn } from 'child_process'
8
+ import { spawn, spawnSync } from 'child_process'
8
9
  import { join } from 'path'
9
10
  import { homedir } from 'os'
10
11
  import YAML from 'yaml'
12
+ import { getVersion } from '../service/version.js'
11
13
 
12
14
  const DEFAULT_PORT = 4097
13
15
  const CONFIG_PATH = join(homedir(), '.config', 'opencode', 'pilot', 'config.yaml')
@@ -32,31 +34,67 @@ function getPort() {
32
34
  }
33
35
 
34
36
  /**
35
- * OpenCode plugin that auto-starts the daemon if not running
37
+ * Start the daemon as a detached background process
38
+ */
39
+ function startDaemon() {
40
+ try {
41
+ const child = spawn('npx', ['opencode-pilot', 'start'], {
42
+ detached: true,
43
+ stdio: 'ignore',
44
+ })
45
+ child.unref()
46
+ } catch {
47
+ // Ignore start errors
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Stop the daemon synchronously
53
+ */
54
+ function stopDaemon() {
55
+ try {
56
+ spawnSync('npx', ['opencode-pilot', 'stop'], {
57
+ stdio: 'ignore',
58
+ timeout: 10000,
59
+ })
60
+ } catch {
61
+ // Ignore stop errors
62
+ }
63
+ }
64
+
65
+ /**
66
+ * OpenCode plugin that auto-starts the daemon if not running.
67
+ * If a daemon is running with a different version, it will be restarted.
36
68
  */
37
69
  export const PilotPlugin = async () => {
38
70
  const port = getPort()
71
+ const ourVersion = getVersion()
39
72
 
40
73
  try {
41
74
  // Check if daemon is already running
42
75
  const res = await fetch(`http://localhost:${port}/health`, {
43
76
  signal: AbortSignal.timeout(1000)
44
77
  })
78
+
45
79
  if (res.ok) {
46
- // Already running, nothing to do
80
+ // Check version
81
+ const data = await res.json()
82
+ const runningVersion = data.version
83
+
84
+ if (runningVersion && runningVersion !== ourVersion && ourVersion !== 'unknown') {
85
+ // Version mismatch - restart daemon
86
+ console.log(`[opencode-pilot] Version mismatch (running: ${runningVersion}, plugin: ${ourVersion}), restarting...`)
87
+ stopDaemon()
88
+ // Small delay to ensure port is released
89
+ await new Promise(resolve => setTimeout(resolve, 500))
90
+ startDaemon()
91
+ }
92
+ // else: same version, nothing to do
47
93
  return {}
48
94
  }
49
95
  } catch {
50
- // Not running, start it as detached background process
51
- try {
52
- const child = spawn('npx', ['opencode-pilot', 'start'], {
53
- detached: true,
54
- stdio: 'ignore',
55
- })
56
- child.unref()
57
- } catch {
58
- // Ignore start errors
59
- }
96
+ // Not running or error, start it
97
+ startDaemon()
60
98
  }
61
99
 
62
100
  return {}
@@ -14,6 +14,84 @@ import { resolveWorktreeDirectory, getProjectInfo, getProjectInfoForDirectory }
14
14
  import path from "path";
15
15
  import os from "os";
16
16
 
17
+ /**
18
+ * Parse a slash command from the beginning of a prompt
19
+ * Returns null if the prompt doesn't start with a slash command
20
+ *
21
+ * @param {string} prompt - The prompt text
22
+ * @returns {object|null} { command, arguments, rest } or null
23
+ */
24
+ export function parseSlashCommand(prompt) {
25
+ if (!prompt || typeof prompt !== 'string') {
26
+ return null;
27
+ }
28
+
29
+ // Match /command at the start, followed by optional arguments on the same line
30
+ // Command names can contain letters, numbers, hyphens, and underscores
31
+ const match = prompt.match(/^\/([a-zA-Z0-9_-]+)(?:\s+(.*))?$/m);
32
+
33
+ if (!match) {
34
+ return null;
35
+ }
36
+
37
+ const command = match[1];
38
+ const firstLineArgs = match[2]?.trim() || '';
39
+
40
+ // Find where the first line ends to get the "rest" of the prompt
41
+ const firstNewline = prompt.indexOf('\n');
42
+ const rest = firstNewline >= 0 ? prompt.slice(firstNewline + 1).trim() : '';
43
+
44
+ return {
45
+ command,
46
+ arguments: firstLineArgs,
47
+ rest,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Send a command to a session via the /command endpoint
53
+ *
54
+ * @param {string} serverUrl - Server URL
55
+ * @param {string} sessionId - Session ID
56
+ * @param {string} directory - Working directory
57
+ * @param {object} parsedCommand - Parsed command from parseSlashCommand
58
+ * @param {object} [options] - Options
59
+ * @param {string} [options.agent] - Agent to use
60
+ * @param {string} [options.model] - Model to use
61
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
62
+ * @returns {Promise<Response>} The fetch response
63
+ */
64
+ async function sendCommand(serverUrl, sessionId, directory, parsedCommand, options = {}) {
65
+ const fetchFn = options.fetch || fetch;
66
+
67
+ const commandUrl = new URL(`/session/${sessionId}/command`, serverUrl);
68
+ commandUrl.searchParams.set('directory', directory);
69
+
70
+ // Build command body per OpenCode API schema
71
+ const commandBody = {
72
+ command: parsedCommand.command,
73
+ arguments: parsedCommand.arguments,
74
+ };
75
+
76
+ // Add agent if specified
77
+ if (options.agent) {
78
+ commandBody.agent = options.agent;
79
+ }
80
+
81
+ // Add model if specified (the /command endpoint takes model as a single string)
82
+ if (options.model) {
83
+ commandBody.model = options.model;
84
+ }
85
+
86
+ debug(`sendCommand: POST ${commandUrl} command=${parsedCommand.command} args=${parsedCommand.arguments}`);
87
+
88
+ return fetchFn(commandUrl.toString(), {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify(commandBody),
92
+ });
93
+ }
94
+
17
95
  /**
18
96
  * Get running opencode server ports by parsing lsof output
19
97
  * @returns {Promise<number[]>} Array of port numbers
@@ -455,50 +533,65 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
455
533
  debug(`sendMessageToSession: updated title for session ${sessionId}`);
456
534
  }
457
535
 
458
- // Step 2: Send the message
459
- const messageUrl = new URL(`/session/${sessionId}/message`, serverUrl);
460
- messageUrl.searchParams.set('directory', directory);
461
-
462
- const messageBody = {
463
- parts: [{ type: 'text', text: prompt }],
464
- };
465
-
466
- if (options.agent) {
467
- messageBody.agent = options.agent;
468
- }
469
-
470
- if (options.model) {
471
- const [providerID, modelID] = options.model.includes('/')
472
- ? options.model.split('/', 2)
473
- : ['anthropic', options.model];
474
- messageBody.providerID = providerID;
475
- messageBody.modelID = modelID;
476
- }
536
+ // Step 2: Check if the prompt starts with a slash command
537
+ const parsedCommand = parseSlashCommand(prompt);
477
538
 
478
539
  // Use AbortController with timeout (same pattern as createSessionViaApi)
479
540
  const controller = new AbortController();
480
541
  const timeoutId = setTimeout(() => controller.abort(), 10000);
481
542
 
482
543
  try {
483
- const messageResponse = await fetchFn(messageUrl.toString(), {
484
- method: 'POST',
485
- headers: { 'Content-Type': 'application/json' },
486
- body: JSON.stringify(messageBody),
487
- signal: controller.signal,
488
- });
544
+ let response;
545
+
546
+ if (parsedCommand) {
547
+ // Use the /command endpoint for slash commands
548
+ debug(`sendMessageToSession: detected command /${parsedCommand.command}`);
549
+ response = await sendCommand(serverUrl, sessionId, directory, parsedCommand, {
550
+ agent: options.agent,
551
+ model: options.model,
552
+ fetch: (url, opts) => fetchFn(url, { ...opts, signal: controller.signal }),
553
+ });
554
+ } else {
555
+ // Use the /message endpoint for regular prompts
556
+ const messageUrl = new URL(`/session/${sessionId}/message`, serverUrl);
557
+ messageUrl.searchParams.set('directory', directory);
558
+
559
+ const messageBody = {
560
+ parts: [{ type: 'text', text: prompt }],
561
+ };
562
+
563
+ if (options.agent) {
564
+ messageBody.agent = options.agent;
565
+ }
566
+
567
+ if (options.model) {
568
+ const [providerID, modelID] = options.model.includes('/')
569
+ ? options.model.split('/', 2)
570
+ : ['anthropic', options.model];
571
+ messageBody.providerID = providerID;
572
+ messageBody.modelID = modelID;
573
+ }
574
+
575
+ response = await fetchFn(messageUrl.toString(), {
576
+ method: 'POST',
577
+ headers: { 'Content-Type': 'application/json' },
578
+ body: JSON.stringify(messageBody),
579
+ signal: controller.signal,
580
+ });
581
+ }
489
582
 
490
583
  clearTimeout(timeoutId);
491
584
 
492
- if (!messageResponse.ok) {
493
- const errorText = await messageResponse.text();
494
- throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
585
+ if (!response.ok) {
586
+ const errorText = await response.text();
587
+ throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
495
588
  }
496
589
 
497
- debug(`sendMessageToSession: sent message to session ${sessionId}`);
590
+ debug(`sendMessageToSession: sent ${parsedCommand ? 'command' : 'message'} to session ${sessionId}`);
498
591
  } catch (abortErr) {
499
592
  clearTimeout(timeoutId);
500
593
  if (abortErr.name === 'AbortError') {
501
- debug(`sendMessageToSession: message request started for session ${sessionId} (response aborted as expected)`);
594
+ debug(`sendMessageToSession: request started for session ${sessionId} (response aborted as expected)`);
502
595
  } else {
503
596
  throw abortErr;
504
597
  }
@@ -610,58 +703,72 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
610
703
  });
611
704
  }
612
705
 
613
- // Step 3: Send the initial message
614
- const messageUrl = new URL(`/session/${session.id}/message`, serverUrl);
615
- messageUrl.searchParams.set('directory', directory);
616
-
617
- // Build message body
618
- const messageBody = {
619
- parts: [{ type: 'text', text: prompt }],
620
- };
621
-
622
- // Add agent if specified
623
- if (options.agent) {
624
- messageBody.agent = options.agent;
625
- }
626
-
627
- // Add model if specified (format: provider/model)
628
- if (options.model) {
629
- const [providerID, modelID] = options.model.includes('/')
630
- ? options.model.split('/', 2)
631
- : ['anthropic', options.model];
632
- messageBody.providerID = providerID;
633
- messageBody.modelID = modelID;
634
- }
706
+ // Step 3: Check if the prompt starts with a slash command
707
+ const parsedCommand = parseSlashCommand(prompt);
635
708
 
636
- // Use AbortController with timeout for the message POST
637
- // The /session/{id}/message endpoint returns a chunked/streaming response
638
- // that stays open until the agent completes. We only need to verify the
639
- // request was accepted (2xx status), not wait for the full response.
709
+ // Use AbortController with timeout for the request
710
+ // The endpoints return a chunked/streaming response that stays open until
711
+ // the agent completes. We only need to verify the request was accepted.
640
712
  const controller = new AbortController();
641
713
  const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
642
714
 
643
715
  try {
644
- const messageResponse = await fetchFn(messageUrl.toString(), {
645
- method: 'POST',
646
- headers: { 'Content-Type': 'application/json' },
647
- body: JSON.stringify(messageBody),
648
- signal: controller.signal,
649
- });
716
+ let response;
717
+
718
+ if (parsedCommand) {
719
+ // Use the /command endpoint for slash commands
720
+ debug(`createSessionViaApi: detected command /${parsedCommand.command}`);
721
+ response = await sendCommand(serverUrl, session.id, directory, parsedCommand, {
722
+ agent: options.agent,
723
+ model: options.model,
724
+ fetch: (url, opts) => fetchFn(url, { ...opts, signal: controller.signal }),
725
+ });
726
+ } else {
727
+ // Use the /message endpoint for regular prompts
728
+ const messageUrl = new URL(`/session/${session.id}/message`, serverUrl);
729
+ messageUrl.searchParams.set('directory', directory);
730
+
731
+ // Build message body
732
+ const messageBody = {
733
+ parts: [{ type: 'text', text: prompt }],
734
+ };
735
+
736
+ // Add agent if specified
737
+ if (options.agent) {
738
+ messageBody.agent = options.agent;
739
+ }
740
+
741
+ // Add model if specified (format: provider/model)
742
+ if (options.model) {
743
+ const [providerID, modelID] = options.model.includes('/')
744
+ ? options.model.split('/', 2)
745
+ : ['anthropic', options.model];
746
+ messageBody.providerID = providerID;
747
+ messageBody.modelID = modelID;
748
+ }
749
+
750
+ response = await fetchFn(messageUrl.toString(), {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify(messageBody),
754
+ signal: controller.signal,
755
+ });
756
+ }
650
757
 
651
758
  clearTimeout(timeoutId);
652
759
 
653
- if (!messageResponse.ok) {
654
- const errorText = await messageResponse.text();
655
- throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
760
+ if (!response.ok) {
761
+ const errorText = await response.text();
762
+ throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
656
763
  }
657
764
 
658
- debug(`createSessionViaApi: sent message to session ${session.id}`);
765
+ debug(`createSessionViaApi: sent ${parsedCommand ? 'command' : 'message'} to session ${session.id}`);
659
766
  } catch (abortErr) {
660
767
  clearTimeout(timeoutId);
661
768
  // AbortError is expected - we intentionally abort after verifying the request started
662
- // The server accepted our message, we just don't need to wait for the response
769
+ // The server accepted our request, we just don't need to wait for the response
663
770
  if (abortErr.name === 'AbortError') {
664
- debug(`createSessionViaApi: message request started for session ${session.id} (response aborted as expected)`);
771
+ debug(`createSessionViaApi: request started for session ${session.id} (response aborted as expected)`);
665
772
  } else {
666
773
  throw abortErr;
667
774
  }
package/service/server.js CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'url'
10
10
  import { homedir } from 'os'
11
11
  import { join } from 'path'
12
12
  import YAML from 'yaml'
13
+ import { getVersion } from './version.js'
13
14
 
14
15
  // Default configuration
15
16
  const DEFAULT_HTTP_PORT = 4097
@@ -56,10 +57,11 @@ function createHttpServer_(port) {
56
57
  return
57
58
  }
58
59
 
59
- // GET /health - Health check
60
+ // GET /health - Health check with version
60
61
  if (req.method === 'GET' && url.pathname === '/health') {
61
- res.writeHead(200, { 'Content-Type': 'text/plain' })
62
- res.end('OK')
62
+ const version = getVersion()
63
+ res.writeHead(200, { 'Content-Type': 'application/json' })
64
+ res.end(JSON.stringify({ status: 'ok', version }))
63
65
  return
64
66
  }
65
67
 
@@ -0,0 +1,34 @@
1
+ // Shared version utility for opencode-pilot
2
+ //
3
+ // Returns the version from package.json
4
+
5
+ import { existsSync, readFileSync } from 'fs'
6
+ import { join, dirname } from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = dirname(__filename)
11
+
12
+ /**
13
+ * Get version from package.json
14
+ * Checks multiple locations for compatibility with different install methods
15
+ * @returns {string} Version string or 'unknown'
16
+ */
17
+ export function getVersion() {
18
+ const candidates = [
19
+ join(__dirname, '..', 'package.json'), // Development: service/../package.json
20
+ join(__dirname, '..', '..', 'package.json'), // Homebrew: libexec/../package.json
21
+ ]
22
+
23
+ for (const packagePath of candidates) {
24
+ try {
25
+ if (existsSync(packagePath)) {
26
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'))
27
+ if (pkg.version) return pkg.version
28
+ }
29
+ } catch {
30
+ // Try next candidate
31
+ }
32
+ }
33
+ return 'unknown'
34
+ }
@@ -97,6 +97,96 @@ describe('actions.js', () => {
97
97
  });
98
98
  });
99
99
 
100
+ describe('parseSlashCommand', () => {
101
+ test('parses /review command with URL argument', async () => {
102
+ const { parseSlashCommand } = await import('../../service/actions.js');
103
+
104
+ const result = parseSlashCommand('/review https://github.com/org/repo/pull/123');
105
+
106
+ assert.strictEqual(result.command, 'review');
107
+ assert.strictEqual(result.arguments, 'https://github.com/org/repo/pull/123');
108
+ assert.strictEqual(result.rest, '');
109
+ });
110
+
111
+ test('parses /devcontainer command with URL argument', async () => {
112
+ const { parseSlashCommand } = await import('../../service/actions.js');
113
+
114
+ const result = parseSlashCommand('/devcontainer https://github.com/org/repo/issues/456');
115
+
116
+ assert.strictEqual(result.command, 'devcontainer');
117
+ assert.strictEqual(result.arguments, 'https://github.com/org/repo/issues/456');
118
+ assert.strictEqual(result.rest, '');
119
+ });
120
+
121
+ test('parses command with multiline rest content', async () => {
122
+ const { parseSlashCommand } = await import('../../service/actions.js');
123
+
124
+ const prompt = `/review https://github.com/org/repo/pull/123
125
+
126
+ Review this pull request:
127
+
128
+ Check for bugs and security issues.`;
129
+
130
+ const result = parseSlashCommand(prompt);
131
+
132
+ assert.strictEqual(result.command, 'review');
133
+ assert.strictEqual(result.arguments, 'https://github.com/org/repo/pull/123');
134
+ assert.ok(result.rest.includes('Review this pull request'));
135
+ assert.ok(result.rest.includes('Check for bugs'));
136
+ });
137
+
138
+ test('parses command without arguments', async () => {
139
+ const { parseSlashCommand } = await import('../../service/actions.js');
140
+
141
+ const result = parseSlashCommand('/help');
142
+
143
+ assert.strictEqual(result.command, 'help');
144
+ assert.strictEqual(result.arguments, '');
145
+ assert.strictEqual(result.rest, '');
146
+ });
147
+
148
+ test('parses command with hyphen in name', async () => {
149
+ const { parseSlashCommand } = await import('../../service/actions.js');
150
+
151
+ const result = parseSlashCommand('/my-custom-command arg1 arg2');
152
+
153
+ assert.strictEqual(result.command, 'my-custom-command');
154
+ assert.strictEqual(result.arguments, 'arg1 arg2');
155
+ });
156
+
157
+ test('returns null for regular prompt without slash', async () => {
158
+ const { parseSlashCommand } = await import('../../service/actions.js');
159
+
160
+ const result = parseSlashCommand('Fix the bug in the login page');
161
+
162
+ assert.strictEqual(result, null);
163
+ });
164
+
165
+ test('returns null for prompt with slash in middle', async () => {
166
+ const { parseSlashCommand } = await import('../../service/actions.js');
167
+
168
+ const result = parseSlashCommand('Check the path/to/file for issues');
169
+
170
+ assert.strictEqual(result, null);
171
+ });
172
+
173
+ test('returns null for empty string', async () => {
174
+ const { parseSlashCommand } = await import('../../service/actions.js');
175
+
176
+ assert.strictEqual(parseSlashCommand(''), null);
177
+ assert.strictEqual(parseSlashCommand(null), null);
178
+ assert.strictEqual(parseSlashCommand(undefined), null);
179
+ });
180
+
181
+ test('returns null for just a slash', async () => {
182
+ const { parseSlashCommand } = await import('../../service/actions.js');
183
+
184
+ const result = parseSlashCommand('/');
185
+
186
+ assert.strictEqual(result, null);
187
+ });
188
+ });
189
+
100
190
  describe('getActionConfig', () => {
101
191
  test('merges source, repo, and defaults', async () => {
102
192
  const { getActionConfig } = await import('../../service/actions.js');
@@ -903,6 +993,253 @@ describe('actions.js', () => {
903
993
  assert.ok(result.warning, 'Should include warning about message failure');
904
994
  assert.ok(result.warning.includes('Failed to send message'), 'Warning should mention message failure');
905
995
  });
996
+
997
+ test('uses /command endpoint for slash commands', async () => {
998
+ const { createSessionViaApi } = await import('../../service/actions.js');
999
+
1000
+ const mockSessionId = 'ses_cmd123';
1001
+ let commandCalled = false;
1002
+ let commandUrl = null;
1003
+ let commandBody = null;
1004
+ let messageCalled = false;
1005
+
1006
+ const mockFetch = async (url, opts) => {
1007
+ const urlObj = new URL(url);
1008
+
1009
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
1010
+ return {
1011
+ ok: true,
1012
+ json: async () => ({ id: mockSessionId }),
1013
+ };
1014
+ }
1015
+
1016
+ if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
1017
+ commandCalled = true;
1018
+ commandUrl = url;
1019
+ commandBody = JSON.parse(opts.body);
1020
+ return {
1021
+ ok: true,
1022
+ json: async () => ({ success: true }),
1023
+ };
1024
+ }
1025
+
1026
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1027
+ messageCalled = true;
1028
+ return {
1029
+ ok: true,
1030
+ json: async () => ({ success: true }),
1031
+ };
1032
+ }
1033
+
1034
+ // PATCH for title update
1035
+ if (opts?.method === 'PATCH') {
1036
+ return { ok: true, json: async () => ({}) };
1037
+ }
1038
+
1039
+ return { ok: false, text: async () => 'Not found' };
1040
+ };
1041
+
1042
+ const result = await createSessionViaApi(
1043
+ 'http://localhost:4096',
1044
+ '/path/to/project',
1045
+ '/review https://github.com/org/repo/pull/123',
1046
+ { fetch: mockFetch }
1047
+ );
1048
+
1049
+ assert.ok(result.success, 'Should succeed');
1050
+ assert.ok(commandCalled, 'Should call /command endpoint');
1051
+ assert.ok(!messageCalled, 'Should NOT call /message endpoint');
1052
+ assert.ok(commandUrl.includes('/command'), 'URL should be /command endpoint');
1053
+ assert.strictEqual(commandBody.command, 'review', 'Should pass command name');
1054
+ assert.strictEqual(commandBody.arguments, 'https://github.com/org/repo/pull/123', 'Should pass arguments');
1055
+ });
1056
+
1057
+ test('uses /message endpoint for regular prompts (not slash commands)', async () => {
1058
+ const { createSessionViaApi } = await import('../../service/actions.js');
1059
+
1060
+ const mockSessionId = 'ses_msg123';
1061
+ let commandCalled = false;
1062
+ let messageCalled = false;
1063
+ let messageBody = null;
1064
+
1065
+ const mockFetch = async (url, opts) => {
1066
+ const urlObj = new URL(url);
1067
+
1068
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
1069
+ return {
1070
+ ok: true,
1071
+ json: async () => ({ id: mockSessionId }),
1072
+ };
1073
+ }
1074
+
1075
+ if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
1076
+ commandCalled = true;
1077
+ return {
1078
+ ok: true,
1079
+ json: async () => ({ success: true }),
1080
+ };
1081
+ }
1082
+
1083
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1084
+ messageCalled = true;
1085
+ messageBody = JSON.parse(opts.body);
1086
+ return {
1087
+ ok: true,
1088
+ json: async () => ({ success: true }),
1089
+ };
1090
+ }
1091
+
1092
+ return { ok: false, text: async () => 'Not found' };
1093
+ };
1094
+
1095
+ const result = await createSessionViaApi(
1096
+ 'http://localhost:4096',
1097
+ '/path/to/project',
1098
+ 'Fix the bug in the login page',
1099
+ { fetch: mockFetch }
1100
+ );
1101
+
1102
+ assert.ok(result.success, 'Should succeed');
1103
+ assert.ok(!commandCalled, 'Should NOT call /command endpoint');
1104
+ assert.ok(messageCalled, 'Should call /message endpoint');
1105
+ assert.strictEqual(messageBody.parts[0].text, 'Fix the bug in the login page', 'Should pass prompt as message text');
1106
+ });
1107
+ });
1108
+
1109
+ describe('sendMessageToSession slash command routing', () => {
1110
+ test('uses /command endpoint for slash commands', async () => {
1111
+ const { sendMessageToSession } = await import('../../service/actions.js');
1112
+
1113
+ let commandCalled = false;
1114
+ let commandBody = null;
1115
+ let messageCalled = false;
1116
+
1117
+ const mockFetch = async (url, opts) => {
1118
+ const urlObj = new URL(url);
1119
+
1120
+ if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
1121
+ commandCalled = true;
1122
+ commandBody = JSON.parse(opts.body);
1123
+ return {
1124
+ ok: true,
1125
+ json: async () => ({ success: true }),
1126
+ };
1127
+ }
1128
+
1129
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1130
+ messageCalled = true;
1131
+ return {
1132
+ ok: true,
1133
+ json: async () => ({ success: true }),
1134
+ };
1135
+ }
1136
+
1137
+ // PATCH for title update
1138
+ if (opts?.method === 'PATCH') {
1139
+ return { ok: true, json: async () => ({}) };
1140
+ }
1141
+
1142
+ return { ok: false, text: async () => 'Not found' };
1143
+ };
1144
+
1145
+ const result = await sendMessageToSession(
1146
+ 'http://localhost:4096',
1147
+ 'ses_existing',
1148
+ '/path/to/project',
1149
+ '/devcontainer https://github.com/org/repo/issues/456',
1150
+ { fetch: mockFetch }
1151
+ );
1152
+
1153
+ assert.ok(result.success, 'Should succeed');
1154
+ assert.ok(commandCalled, 'Should call /command endpoint');
1155
+ assert.ok(!messageCalled, 'Should NOT call /message endpoint');
1156
+ assert.strictEqual(commandBody.command, 'devcontainer', 'Should pass command name');
1157
+ assert.strictEqual(commandBody.arguments, 'https://github.com/org/repo/issues/456', 'Should pass arguments');
1158
+ });
1159
+
1160
+ test('uses /message endpoint for regular prompts', async () => {
1161
+ const { sendMessageToSession } = await import('../../service/actions.js');
1162
+
1163
+ let commandCalled = false;
1164
+ let messageCalled = false;
1165
+ let messageBody = null;
1166
+
1167
+ const mockFetch = async (url, opts) => {
1168
+ const urlObj = new URL(url);
1169
+
1170
+ if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
1171
+ commandCalled = true;
1172
+ return {
1173
+ ok: true,
1174
+ json: async () => ({ success: true }),
1175
+ };
1176
+ }
1177
+
1178
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1179
+ messageCalled = true;
1180
+ messageBody = JSON.parse(opts.body);
1181
+ return {
1182
+ ok: true,
1183
+ json: async () => ({ success: true }),
1184
+ };
1185
+ }
1186
+
1187
+ return { ok: false, text: async () => 'Not found' };
1188
+ };
1189
+
1190
+ const result = await sendMessageToSession(
1191
+ 'http://localhost:4096',
1192
+ 'ses_existing',
1193
+ '/path/to/project',
1194
+ 'Please fix the bug',
1195
+ { fetch: mockFetch }
1196
+ );
1197
+
1198
+ assert.ok(result.success, 'Should succeed');
1199
+ assert.ok(!commandCalled, 'Should NOT call /command endpoint');
1200
+ assert.ok(messageCalled, 'Should call /message endpoint');
1201
+ assert.strictEqual(messageBody.parts[0].text, 'Please fix the bug', 'Should pass prompt as message text');
1202
+ });
1203
+
1204
+ test('passes agent and model to /command endpoint', async () => {
1205
+ const { sendMessageToSession } = await import('../../service/actions.js');
1206
+
1207
+ let commandBody = null;
1208
+
1209
+ const mockFetch = async (url, opts) => {
1210
+ const urlObj = new URL(url);
1211
+
1212
+ if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
1213
+ commandBody = JSON.parse(opts.body);
1214
+ return {
1215
+ ok: true,
1216
+ json: async () => ({ success: true }),
1217
+ };
1218
+ }
1219
+
1220
+ // PATCH for title update
1221
+ if (opts?.method === 'PATCH') {
1222
+ return { ok: true, json: async () => ({}) };
1223
+ }
1224
+
1225
+ return { ok: false, text: async () => 'Not found' };
1226
+ };
1227
+
1228
+ await sendMessageToSession(
1229
+ 'http://localhost:4096',
1230
+ 'ses_existing',
1231
+ '/path/to/project',
1232
+ '/review https://github.com/org/repo/pull/123',
1233
+ {
1234
+ fetch: mockFetch,
1235
+ agent: 'code',
1236
+ model: 'anthropic/claude-sonnet-4-20250514',
1237
+ }
1238
+ );
1239
+
1240
+ assert.strictEqual(commandBody.agent, 'code', 'Should pass agent');
1241
+ assert.strictEqual(commandBody.model, 'anthropic/claude-sonnet-4-20250514', 'Should pass model as string');
1242
+ });
906
1243
  });
907
1244
 
908
1245
  describe('session reuse', () => {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests for service/server.js - HTTP server and health endpoint
3
+ */
4
+
5
+ import { test, describe, afterEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { readFileSync } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ // Get expected version from package.json
15
+ function getExpectedVersion() {
16
+ const packagePath = join(__dirname, '..', '..', 'package.json');
17
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
18
+ return pkg.version;
19
+ }
20
+
21
+ describe('service/server.js', () => {
22
+ let service = null;
23
+
24
+ afterEach(async () => {
25
+ if (service) {
26
+ const { stopService } = await import('../../service/server.js');
27
+ await stopService(service);
28
+ service = null;
29
+ }
30
+ });
31
+
32
+ describe('health endpoint', () => {
33
+ test('returns JSON with status and version', async () => {
34
+ const { startService, stopService } = await import('../../service/server.js');
35
+
36
+ // Start service on random port
37
+ service = await startService({
38
+ httpPort: 0, // Let OS assign port
39
+ enablePolling: false
40
+ });
41
+
42
+ const port = service.httpServer.address().port;
43
+ const res = await fetch(`http://localhost:${port}/health`);
44
+
45
+ assert.strictEqual(res.status, 200);
46
+ assert.strictEqual(res.headers.get('content-type'), 'application/json');
47
+
48
+ const data = await res.json();
49
+ assert.strictEqual(data.status, 'ok');
50
+ assert.strictEqual(typeof data.version, 'string');
51
+ assert.strictEqual(data.version, getExpectedVersion());
52
+ });
53
+
54
+ });
55
+
56
+ describe('CORS', () => {
57
+ test('OPTIONS returns CORS headers', async () => {
58
+ const { startService } = await import('../../service/server.js');
59
+
60
+ service = await startService({
61
+ httpPort: 0,
62
+ enablePolling: false
63
+ });
64
+
65
+ const port = service.httpServer.address().port;
66
+ const res = await fetch(`http://localhost:${port}/health`, {
67
+ method: 'OPTIONS'
68
+ });
69
+
70
+ assert.strictEqual(res.status, 204);
71
+ assert.strictEqual(res.headers.get('access-control-allow-origin'), '*');
72
+ });
73
+ });
74
+
75
+ describe('unknown routes', () => {
76
+ test('returns 404 for unknown paths', async () => {
77
+ const { startService } = await import('../../service/server.js');
78
+
79
+ service = await startService({
80
+ httpPort: 0,
81
+ enablePolling: false
82
+ });
83
+
84
+ const port = service.httpServer.address().port;
85
+ const res = await fetch(`http://localhost:${port}/unknown`);
86
+
87
+ assert.strictEqual(res.status, 404);
88
+ });
89
+ });
90
+ });