opencode-pilot 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,9 +19,9 @@ npm install -g opencode-pilot
19
19
 
20
20
  ## Quick Start
21
21
 
22
- 1. **Create config** - Copy [examples/config.yaml](examples/config.yaml) to `~/.config/opencode-pilot/config.yaml` and customize
22
+ 1. **Create config** - Copy [examples/config.yaml](examples/config.yaml) to `~/.config/opencode/pilot/config.yaml` and customize
23
23
 
24
- 2. **Create templates** - Add prompt templates to `~/.config/opencode-pilot/templates/`
24
+ 2. **Create templates** - Add prompt templates to `~/.config/opencode/pilot/templates/`
25
25
 
26
26
  3. **Enable the plugin** - Add to your `opencode.json`:
27
27
 
@@ -67,7 +67,7 @@ Three ways to configure sources, from simplest to most flexible:
67
67
 
68
68
  ### Prompt Templates
69
69
 
70
- Create prompt templates as markdown files in `~/.config/opencode-pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
70
+ Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
71
71
 
72
72
  ## CLI Commands
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -5,17 +5,139 @@
5
5
  * Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
6
6
  */
7
7
 
8
- import { spawn } from "child_process";
8
+ import { spawn, execSync } from "child_process";
9
9
  import { readFileSync, existsSync } from "fs";
10
10
  import { debug } from "./logger.js";
11
11
  import { getNestedValue } from "./utils.js";
12
12
  import path from "path";
13
13
  import os from "os";
14
14
 
15
+ /**
16
+ * Get running opencode server ports by parsing lsof output
17
+ * @returns {Promise<number[]>} Array of port numbers
18
+ */
19
+ async function getOpencodePorts() {
20
+ try {
21
+ const output = execSync('lsof -i -P 2>/dev/null | grep -E "opencode.*LISTEN" || true', {
22
+ encoding: 'utf-8',
23
+ timeout: 30000
24
+ });
25
+
26
+ const ports = [];
27
+ for (const line of output.split('\n')) {
28
+ // Parse lines like: opencode- 6897 athal 12u IPv4 ... TCP *:60993 (LISTEN)
29
+ const match = line.match(/:(\d+)\s+\(LISTEN\)/);
30
+ if (match) {
31
+ ports.push(parseInt(match[1], 10));
32
+ }
33
+ }
34
+ return ports;
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check if targetPath is within or equal to worktree path
42
+ * @param {string} targetPath - The path we're looking for
43
+ * @param {string} worktree - The server's worktree path
44
+ * @param {string[]} sandboxes - Array of sandbox paths
45
+ * @returns {number} Match score (higher = better match, 0 = no match)
46
+ */
47
+ function getPathMatchScore(targetPath, worktree, sandboxes = []) {
48
+ // Normalize paths
49
+ const normalizedTarget = path.resolve(targetPath);
50
+ const normalizedWorktree = path.resolve(worktree);
51
+
52
+ // Exact sandbox match (highest priority)
53
+ for (const sandbox of sandboxes) {
54
+ const normalizedSandbox = path.resolve(sandbox);
55
+ if (normalizedTarget === normalizedSandbox || normalizedTarget.startsWith(normalizedSandbox + path.sep)) {
56
+ return normalizedSandbox.length + 1000; // Bonus for sandbox match
57
+ }
58
+ }
59
+
60
+ // Exact worktree match
61
+ if (normalizedTarget === normalizedWorktree) {
62
+ return normalizedWorktree.length + 500; // Bonus for exact match
63
+ }
64
+
65
+ // Target is subdirectory of worktree
66
+ if (normalizedTarget.startsWith(normalizedWorktree + path.sep)) {
67
+ return normalizedWorktree.length;
68
+ }
69
+
70
+ // Global project (worktree = "/") matches everything with lowest priority
71
+ if (normalizedWorktree === '/') {
72
+ return 1;
73
+ }
74
+
75
+ return 0; // No match
76
+ }
77
+
78
+ /**
79
+ * Discover a running opencode server that matches the target directory
80
+ *
81
+ * Queries all running opencode servers and finds the best match based on:
82
+ * 1. Exact sandbox match (highest priority)
83
+ * 2. Exact worktree match
84
+ * 3. Target is subdirectory of worktree
85
+ * 4. Global project (worktree="/") as fallback
86
+ *
87
+ * @param {string} targetDir - The directory we want to work in
88
+ * @param {object} [options] - Options for testing/mocking
89
+ * @param {function} [options.getPorts] - Function to get server ports
90
+ * @param {function} [options.fetch] - Function to fetch URLs
91
+ * @returns {Promise<string|null>} Server URL (e.g., "http://localhost:4096") or null
92
+ */
93
+ export async function discoverOpencodeServer(targetDir, options = {}) {
94
+ const getPorts = options.getPorts || getOpencodePorts;
95
+ const fetchFn = options.fetch || fetch;
96
+
97
+ const ports = await getPorts();
98
+ if (ports.length === 0) {
99
+ debug('discoverOpencodeServer: no servers found');
100
+ return null;
101
+ }
102
+
103
+ debug(`discoverOpencodeServer: checking ${ports.length} servers for ${targetDir}`);
104
+
105
+ let bestMatch = null;
106
+ let bestScore = 0;
107
+
108
+ for (const port of ports) {
109
+ const url = `http://localhost:${port}`;
110
+ try {
111
+ const response = await fetchFn(`${url}/project/current`);
112
+ if (!response.ok) {
113
+ debug(`discoverOpencodeServer: ${url} returned ${response.status}`);
114
+ continue;
115
+ }
116
+
117
+ const project = await response.json();
118
+ const worktree = project.worktree || '/';
119
+ const sandboxes = project.sandboxes || [];
120
+
121
+ const score = getPathMatchScore(targetDir, worktree, sandboxes);
122
+ debug(`discoverOpencodeServer: ${url} worktree=${worktree} score=${score}`);
123
+
124
+ if (score > bestScore) {
125
+ bestScore = score;
126
+ bestMatch = url;
127
+ }
128
+ } catch (err) {
129
+ debug(`discoverOpencodeServer: ${url} error: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ debug(`discoverOpencodeServer: best match=${bestMatch} score=${bestScore}`);
134
+ return bestMatch;
135
+ }
136
+
15
137
  // Default templates directory
16
138
  const DEFAULT_TEMPLATES_DIR = path.join(
17
139
  os.homedir(),
18
- ".config/opencode-pilot/templates"
140
+ ".config/opencode/pilot/templates"
19
141
  );
20
142
 
21
143
  /**
@@ -109,9 +231,10 @@ export function getActionConfig(source, repoConfig, defaults) {
109
231
  * @param {object} item - Item to create session for
110
232
  * @param {object} config - Merged action config
111
233
  * @param {string} [templatesDir] - Templates directory path (for testing)
234
+ * @param {string} [serverUrl] - URL of running opencode server to attach to
112
235
  * @returns {object} { args: string[], cwd: string }
113
236
  */
114
- export function getCommandInfoNew(item, config, templatesDir) {
237
+ export function getCommandInfoNew(item, config, templatesDir, serverUrl) {
115
238
  // Determine working directory: working_dir > path > repo_path > home
116
239
  const workingDir = config.working_dir || config.path || config.repo_path || "~";
117
240
  const cwd = expandPath(workingDir);
@@ -124,6 +247,11 @@ export function getCommandInfoNew(item, config, templatesDir) {
124
247
  // Build command args
125
248
  const args = ["opencode", "run"];
126
249
 
250
+ // Add --attach if server URL is provided
251
+ if (serverUrl) {
252
+ args.push("--attach", serverUrl);
253
+ }
254
+
127
255
  // Add session title
128
256
  args.push("--title", sessionName);
129
257
 
@@ -264,11 +392,29 @@ function runSpawn(args, options = {}) {
264
392
  * @param {object} config - Repo config with action settings
265
393
  * @param {object} [options] - Execution options
266
394
  * @param {boolean} [options.dryRun] - If true, return command without executing
395
+ * @param {function} [options.discoverServer] - Custom server discovery function (for testing)
267
396
  * @returns {Promise<object>} Result with command, stdout, stderr, exitCode
268
397
  */
269
398
  export async function executeAction(item, config, options = {}) {
270
- const cmdInfo = getCommandInfo(item, config);
271
- const command = buildCommand(item, config); // For logging/display
399
+ // Get working directory first to determine which server to attach to
400
+ const workingDir = config.working_dir || config.path || config.repo_path || "~";
401
+ const cwd = expandPath(workingDir);
402
+
403
+ // Discover running opencode server for this directory
404
+ const discoverFn = options.discoverServer || discoverOpencodeServer;
405
+ const serverUrl = await discoverFn(cwd);
406
+
407
+ debug(`executeAction: discovered server=${serverUrl} for cwd=${cwd}`);
408
+
409
+ // Build command info with server URL for --attach flag
410
+ const cmdInfo = getCommandInfoNew(item, config, undefined, serverUrl);
411
+
412
+ // Build command string for display
413
+ const quoteArgs = (args) => args.map(a =>
414
+ a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
415
+ ).join(" ");
416
+ const cmdStr = quoteArgs(cmdInfo.args);
417
+ const command = cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
272
418
 
273
419
  debug(`executeAction: command=${command}`);
274
420
  debug(`executeAction: args=${JSON.stringify(cmdInfo.args)}, cwd=${cmdInfo.cwd}`);
@@ -95,6 +95,11 @@ export async function pollOnce(options = {}) {
95
95
  // Load configuration
96
96
  loadRepoConfig(configPath);
97
97
 
98
+ // Ensure poller is initialized for state tracking
99
+ if (!pollerInstance) {
100
+ pollerInstance = createPoller({ configPath });
101
+ }
102
+
98
103
  // Get all sources
99
104
  const sources = getAllSources();
100
105
 
package/service/poller.js CHANGED
@@ -282,7 +282,7 @@ export async function pollGenericSource(source, options = {}) {
282
282
  * @returns {object} Poller instance
283
283
  */
284
284
  export function createPoller(options = {}) {
285
- const stateFile = options.stateFile || path.join(os.homedir(), '.config/opencode-pilot/poll-state.json');
285
+ const stateFile = options.stateFile || path.join(os.homedir(), '.config/opencode/pilot/poll-state.json');
286
286
  const configPath = options.configPath;
287
287
 
288
288
  // Load existing state
@@ -20,13 +20,13 @@ import { expandPreset, expandGitHubShorthand, getProviderConfig } from "./preset
20
20
  // Default config path
21
21
  const DEFAULT_CONFIG_PATH = path.join(
22
22
  os.homedir(),
23
- ".config/opencode-pilot/config.yaml"
23
+ ".config/opencode/pilot/config.yaml"
24
24
  );
25
25
 
26
26
  // Default templates directory
27
27
  const DEFAULT_TEMPLATES_DIR = path.join(
28
28
  os.homedir(),
29
- ".config/opencode-pilot/templates"
29
+ ".config/opencode/pilot/templates"
30
30
  );
31
31
 
32
32
  // In-memory config cache (for testing and runtime)
package/service/server.js CHANGED
@@ -13,7 +13,7 @@ import YAML from 'yaml'
13
13
 
14
14
  // Default configuration
15
15
  const DEFAULT_HTTP_PORT = 4097
16
- const DEFAULT_REPOS_CONFIG = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
16
+ const DEFAULT_REPOS_CONFIG = join(homedir(), '.config', 'opencode', 'pilot', 'config.yaml')
17
17
  const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000 // 5 minutes
18
18
 
19
19
  /**
@@ -231,5 +231,235 @@ describe('actions.js', () => {
231
231
  assert.ok(lastArg.includes('/devcontainer issue-66'), 'Prompt should include /devcontainer command');
232
232
  assert.ok(lastArg.includes('Fix bug'), 'Prompt should include title');
233
233
  });
234
+
235
+ test('includes --attach when serverUrl is provided', async () => {
236
+ const { getCommandInfoNew } = await import('../../service/actions.js');
237
+
238
+ const item = { number: 123, title: 'Fix bug' };
239
+ const config = {
240
+ path: '~/code/backend',
241
+ prompt: 'default'
242
+ };
243
+
244
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir, 'http://localhost:4096');
245
+
246
+ assert.ok(cmdInfo.args.includes('--attach'), 'Should include --attach flag');
247
+ assert.ok(cmdInfo.args.includes('http://localhost:4096'), 'Should include server URL');
248
+ });
249
+
250
+ test('does not include --attach when serverUrl is null', async () => {
251
+ const { getCommandInfoNew } = await import('../../service/actions.js');
252
+
253
+ const item = { number: 123, title: 'Fix bug' };
254
+ const config = {
255
+ path: '~/code/backend',
256
+ prompt: 'default'
257
+ };
258
+
259
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir, null);
260
+
261
+ assert.ok(!cmdInfo.args.includes('--attach'), 'Should not include --attach flag');
262
+ });
263
+ });
264
+
265
+ describe('discoverOpencodeServer', () => {
266
+ test('returns null when no servers running', async () => {
267
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
268
+
269
+ // Mock with empty port list
270
+ const result = await discoverOpencodeServer('/some/path', { getPorts: async () => [] });
271
+
272
+ assert.strictEqual(result, null);
273
+ });
274
+
275
+ test('returns matching server URL for exact worktree match', async () => {
276
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
277
+
278
+ const mockPorts = async () => [3000, 4000];
279
+ const mockFetch = async (url) => {
280
+ if (url === 'http://localhost:3000/project/current') {
281
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project-a', sandboxes: [] }) };
282
+ }
283
+ if (url === 'http://localhost:4000/project/current') {
284
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project-b', sandboxes: [] }) };
285
+ }
286
+ return { ok: false };
287
+ };
288
+
289
+ const result = await discoverOpencodeServer('/Users/test/project-b', {
290
+ getPorts: mockPorts,
291
+ fetch: mockFetch
292
+ });
293
+
294
+ assert.strictEqual(result, 'http://localhost:4000');
295
+ });
296
+
297
+ test('returns matching server URL for subdirectory of worktree', async () => {
298
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
299
+
300
+ const mockPorts = async () => [3000];
301
+ const mockFetch = async (url) => {
302
+ if (url === 'http://localhost:3000/project/current') {
303
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
304
+ }
305
+ return { ok: false };
306
+ };
307
+
308
+ const result = await discoverOpencodeServer('/Users/test/project/src/components', {
309
+ getPorts: mockPorts,
310
+ fetch: mockFetch
311
+ });
312
+
313
+ assert.strictEqual(result, 'http://localhost:3000');
314
+ });
315
+
316
+ test('returns matching server URL when cwd is in sandboxes', async () => {
317
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
318
+
319
+ const mockPorts = async () => [3000];
320
+ const mockFetch = async (url) => {
321
+ if (url === 'http://localhost:3000/project/current') {
322
+ return { ok: true, json: async () => ({
323
+ worktree: '/Users/test/project',
324
+ sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1']
325
+ }) };
326
+ }
327
+ return { ok: false };
328
+ };
329
+
330
+ const result = await discoverOpencodeServer('/Users/test/.opencode/worktree/abc/sandbox-1', {
331
+ getPorts: mockPorts,
332
+ fetch: mockFetch
333
+ });
334
+
335
+ assert.strictEqual(result, 'http://localhost:3000');
336
+ });
337
+
338
+ test('prefers more specific worktree match over less specific', async () => {
339
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
340
+
341
+ const mockPorts = async () => [3000, 4000];
342
+ const mockFetch = async (url) => {
343
+ if (url === 'http://localhost:3000/project/current') {
344
+ // Global project
345
+ return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
346
+ }
347
+ if (url === 'http://localhost:4000/project/current') {
348
+ // Specific project
349
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
350
+ }
351
+ return { ok: false };
352
+ };
353
+
354
+ const result = await discoverOpencodeServer('/Users/test/project/src', {
355
+ getPorts: mockPorts,
356
+ fetch: mockFetch
357
+ });
358
+
359
+ // Should prefer the more specific match (port 4000)
360
+ assert.strictEqual(result, 'http://localhost:4000');
361
+ });
362
+
363
+ test('falls back to global project when no specific match', async () => {
364
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
365
+
366
+ const mockPorts = async () => [3000];
367
+ const mockFetch = async (url) => {
368
+ if (url === 'http://localhost:3000/project/current') {
369
+ return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
370
+ }
371
+ return { ok: false };
372
+ };
373
+
374
+ const result = await discoverOpencodeServer('/Users/test/random/path', {
375
+ getPorts: mockPorts,
376
+ fetch: mockFetch
377
+ });
378
+
379
+ assert.strictEqual(result, 'http://localhost:3000');
380
+ });
381
+
382
+ test('returns null when fetch fails for all servers', async () => {
383
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
384
+
385
+ const mockPorts = async () => [3000, 4000];
386
+ const mockFetch = async () => {
387
+ throw new Error('Connection refused');
388
+ };
389
+
390
+ const result = await discoverOpencodeServer('/some/path', {
391
+ getPorts: mockPorts,
392
+ fetch: mockFetch
393
+ });
394
+
395
+ assert.strictEqual(result, null);
396
+ });
397
+
398
+ test('skips servers that return non-ok response', async () => {
399
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
400
+
401
+ const mockPorts = async () => [3000, 4000];
402
+ const mockFetch = async (url) => {
403
+ if (url === 'http://localhost:3000/project/current') {
404
+ return { ok: false };
405
+ }
406
+ if (url === 'http://localhost:4000/project/current') {
407
+ return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
408
+ }
409
+ return { ok: false };
410
+ };
411
+
412
+ const result = await discoverOpencodeServer('/Users/test/project', {
413
+ getPorts: mockPorts,
414
+ fetch: mockFetch
415
+ });
416
+
417
+ assert.strictEqual(result, 'http://localhost:4000');
418
+ });
419
+ });
420
+
421
+ describe('executeAction', () => {
422
+ test('discovers server and includes --attach in dry run', async () => {
423
+ const { executeAction } = await import('../../service/actions.js');
424
+
425
+ const item = { number: 123, title: 'Fix bug' };
426
+ const config = {
427
+ path: tempDir,
428
+ prompt: 'default'
429
+ };
430
+
431
+ // Mock server discovery
432
+ const mockDiscoverServer = async () => 'http://localhost:4096';
433
+
434
+ const result = await executeAction(item, config, {
435
+ dryRun: true,
436
+ discoverServer: mockDiscoverServer
437
+ });
438
+
439
+ assert.ok(result.dryRun);
440
+ assert.ok(result.command.includes('--attach'), 'Command should include --attach flag');
441
+ assert.ok(result.command.includes('http://localhost:4096'), 'Command should include server URL');
442
+ });
443
+
444
+ test('does not include --attach when no server discovered', async () => {
445
+ const { executeAction } = await import('../../service/actions.js');
446
+
447
+ const item = { number: 123, title: 'Fix bug' };
448
+ const config = {
449
+ path: tempDir,
450
+ prompt: 'default'
451
+ };
452
+
453
+ // Mock no server found
454
+ const mockDiscoverServer = async () => null;
455
+
456
+ const result = await executeAction(item, config, {
457
+ dryRun: true,
458
+ discoverServer: mockDiscoverServer
459
+ });
460
+
461
+ assert.ok(result.dryRun);
462
+ assert.ok(!result.command.includes('--attach'), 'Command should not include --attach flag');
463
+ });
234
464
  });
235
465
  });