opencode-pilot 0.8.0 → 0.9.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.
@@ -42,6 +42,8 @@ sources:
42
42
  # completed: false
43
43
  # item:
44
44
  # id: "reminder:{id}"
45
+ # session:
46
+ # name: "{title}" # Optional: custom session name (presets have semantic defaults)
45
47
 
46
48
  # Tool config for custom MCP servers (GitHub/Linear have built-in config)
47
49
  # tools:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -18,7 +18,9 @@ import os from "os";
18
18
  */
19
19
  async function getOpencodePorts() {
20
20
  try {
21
- const output = execSync('lsof -i -P 2>/dev/null | grep -E "opencode.*LISTEN" || true', {
21
+ // Use full path to lsof since /usr/sbin may not be in PATH in all contexts
22
+ // (e.g., when running as a service or from certain shell environments)
23
+ const output = execSync('/usr/sbin/lsof -i -P 2>/dev/null | grep -E "opencode.*LISTEN" || true', {
22
24
  encoding: 'utf-8',
23
25
  timeout: 30000
24
26
  });
@@ -75,6 +77,24 @@ function getPathMatchScore(targetPath, worktree, sandboxes = []) {
75
77
  return 0; // No match
76
78
  }
77
79
 
80
+ /**
81
+ * Verify a server is healthy by checking it returns valid project data
82
+ * @param {string} url - Server URL
83
+ * @param {object} project - Project data already fetched from /project/current
84
+ * @returns {boolean} True if server appears healthy
85
+ */
86
+ function isServerHealthy(project) {
87
+ // A healthy server should return a project with an id and time.created
88
+ // Stale/broken servers may return HTML or incomplete JSON
89
+ return !!(
90
+ project &&
91
+ typeof project === 'object' &&
92
+ project.id &&
93
+ project.time &&
94
+ typeof project.time.created === 'number'
95
+ );
96
+ }
97
+
78
98
  /**
79
99
  * Discover a running opencode server that matches the target directory
80
100
  *
@@ -82,7 +102,7 @@ function getPathMatchScore(targetPath, worktree, sandboxes = []) {
82
102
  * 1. Exact sandbox match (highest priority)
83
103
  * 2. Exact worktree match
84
104
  * 3. Target is subdirectory of worktree
85
- * 4. Global project (worktree="/") as fallback
105
+ * 4. Global project (worktree="/") as fallback (e.g., OpenCode Desktop)
86
106
  *
87
107
  * @param {string} targetDir - The directory we want to work in
88
108
  * @param {object} [options] - Options for testing/mocking
@@ -115,6 +135,13 @@ export async function discoverOpencodeServer(targetDir, options = {}) {
115
135
  }
116
136
 
117
137
  const project = await response.json();
138
+
139
+ // Health check: verify response has expected structure
140
+ if (!isServerHealthy(project)) {
141
+ debug(`discoverOpencodeServer: ${url} failed health check (invalid project data)`);
142
+ continue;
143
+ }
144
+
118
145
  const worktree = project.worktree || '/';
119
146
  const sandboxes = project.sandboxes || [];
120
147
 
@@ -242,7 +269,7 @@ export function getCommandInfoNew(item, config, templatesDir, serverUrl) {
242
269
  // Build session name
243
270
  const sessionName = config.session?.name
244
271
  ? buildSessionName(config.session.name, item)
245
- : `session-${item.number || item.id || Date.now()}`;
272
+ : (item.title || `session-${Date.now()}`);
246
273
 
247
274
  // Build command args
248
275
  const args = ["opencode", "run"];
@@ -297,13 +324,14 @@ function buildPrompt(item, config) {
297
324
  /**
298
325
  * Build command args for action
299
326
  * Uses "opencode run" for non-interactive execution
327
+ * @deprecated Legacy function - not currently used. See getCommandInfoNew instead.
300
328
  * @returns {object} { args: string[], cwd: string }
301
329
  */
302
330
  function buildCommandArgs(item, config) {
303
331
  const repoPath = expandPath(config.repo_path || ".");
304
332
  const sessionTitle = config.session?.name_template
305
333
  ? buildSessionName(config.session.name_template, item)
306
- : `issue-${item.number || Date.now()}`;
334
+ : (item.title || `session-${Date.now()}`);
307
335
 
308
336
  // Build opencode run command args array (non-interactive)
309
337
  // Note: --title sets session title (--session is for continuing existing sessions)
@@ -22,6 +22,8 @@ my-issues:
22
22
  item:
23
23
  id: "{html_url}"
24
24
  repo: "{repository_full_name}"
25
+ session:
26
+ name: "{title}"
25
27
 
26
28
  review-requests:
27
29
  name: review-requests
@@ -33,6 +35,8 @@ review-requests:
33
35
  item:
34
36
  id: "{html_url}"
35
37
  repo: "{repository_full_name}"
38
+ session:
39
+ name: "Review: {title}"
36
40
 
37
41
  my-prs-feedback:
38
42
  name: my-prs-feedback
@@ -46,6 +50,8 @@ my-prs-feedback:
46
50
  item:
47
51
  id: "{html_url}"
48
52
  repo: "{repository_full_name}"
53
+ session:
54
+ name: "Feedback: {title}"
49
55
  # Reprocess when PR is updated (new commits pushed, new comments, etc.)
50
56
  # This ensures we re-trigger after addressing review feedback
51
57
  reprocess_on:
@@ -21,3 +21,5 @@ my-issues:
21
21
  # teamId and assigneeId are required - user must provide
22
22
  item:
23
23
  id: "linear:{id}"
24
+ session:
25
+ name: "{title}"
@@ -260,6 +260,38 @@ describe('actions.js', () => {
260
260
 
261
261
  assert.ok(!cmdInfo.args.includes('--attach'), 'Should not include --attach flag');
262
262
  });
263
+
264
+ test('uses item title as session name when no session.name configured', async () => {
265
+ const { getCommandInfoNew } = await import('../../service/actions.js');
266
+
267
+ const item = { id: 'reminder-123', title: 'Review quarterly reports' };
268
+ const config = {
269
+ path: '~/code/backend',
270
+ prompt: 'default'
271
+ };
272
+
273
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
274
+
275
+ const titleIndex = cmdInfo.args.indexOf('--title');
276
+ assert.ok(titleIndex !== -1, 'Should have --title flag');
277
+ assert.strictEqual(cmdInfo.args[titleIndex + 1], 'Review quarterly reports', 'Should use item title as session name');
278
+ });
279
+
280
+ test('falls back to timestamp when no session.name and no title', async () => {
281
+ const { getCommandInfoNew } = await import('../../service/actions.js');
282
+
283
+ const item = { id: 'item-123' };
284
+ const config = {
285
+ path: '~/code/backend',
286
+ prompt: 'default'
287
+ };
288
+
289
+ const cmdInfo = getCommandInfoNew(item, config, templatesDir);
290
+
291
+ const titleIndex = cmdInfo.args.indexOf('--title');
292
+ assert.ok(titleIndex !== -1, 'Should have --title flag');
293
+ assert.ok(cmdInfo.args[titleIndex + 1].startsWith('session-'), 'Should fall back to session-{timestamp}');
294
+ });
263
295
  });
264
296
 
265
297
  describe('discoverOpencodeServer', () => {
@@ -278,10 +310,10 @@ describe('actions.js', () => {
278
310
  const mockPorts = async () => [3000, 4000];
279
311
  const mockFetch = async (url) => {
280
312
  if (url === 'http://localhost:3000/project/current') {
281
- return { ok: true, json: async () => ({ worktree: '/Users/test/project-a', sandboxes: [] }) };
313
+ return { ok: true, json: async () => ({ id: 'proj-a', worktree: '/Users/test/project-a', sandboxes: [], time: { created: 1 } }) };
282
314
  }
283
315
  if (url === 'http://localhost:4000/project/current') {
284
- return { ok: true, json: async () => ({ worktree: '/Users/test/project-b', sandboxes: [] }) };
316
+ return { ok: true, json: async () => ({ id: 'proj-b', worktree: '/Users/test/project-b', sandboxes: [], time: { created: 1 } }) };
285
317
  }
286
318
  return { ok: false };
287
319
  };
@@ -300,7 +332,7 @@ describe('actions.js', () => {
300
332
  const mockPorts = async () => [3000];
301
333
  const mockFetch = async (url) => {
302
334
  if (url === 'http://localhost:3000/project/current') {
303
- return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
335
+ return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
304
336
  }
305
337
  return { ok: false };
306
338
  };
@@ -320,8 +352,10 @@ describe('actions.js', () => {
320
352
  const mockFetch = async (url) => {
321
353
  if (url === 'http://localhost:3000/project/current') {
322
354
  return { ok: true, json: async () => ({
355
+ id: 'proj',
323
356
  worktree: '/Users/test/project',
324
- sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1']
357
+ sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1'],
358
+ time: { created: 1 }
325
359
  }) };
326
360
  }
327
361
  return { ok: false };
@@ -342,11 +376,11 @@ describe('actions.js', () => {
342
376
  const mockFetch = async (url) => {
343
377
  if (url === 'http://localhost:3000/project/current') {
344
378
  // Global project
345
- return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
379
+ return { ok: true, json: async () => ({ id: 'global', worktree: '/', sandboxes: [], time: { created: 1 } }) };
346
380
  }
347
381
  if (url === 'http://localhost:4000/project/current') {
348
382
  // Specific project
349
- return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
383
+ return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
350
384
  }
351
385
  return { ok: false };
352
386
  };
@@ -360,17 +394,19 @@ describe('actions.js', () => {
360
394
  assert.strictEqual(result, 'http://localhost:4000');
361
395
  });
362
396
 
363
- test('falls back to global project when no specific match', async () => {
397
+ test('falls back to healthy global project when no specific match', async () => {
364
398
  const { discoverOpencodeServer } = await import('../../service/actions.js');
365
399
 
366
400
  const mockPorts = async () => [3000];
367
401
  const mockFetch = async (url) => {
368
402
  if (url === 'http://localhost:3000/project/current') {
369
- return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
403
+ // Healthy global project with proper structure
404
+ return { ok: true, json: async () => ({ id: 'global', worktree: '/', sandboxes: [], time: { created: 1 } }) };
370
405
  }
371
406
  return { ok: false };
372
407
  };
373
408
 
409
+ // Global servers (like OpenCode Desktop) should work as fallback
374
410
  const result = await discoverOpencodeServer('/Users/test/random/path', {
375
411
  getPorts: mockPorts,
376
412
  fetch: mockFetch
@@ -404,7 +440,33 @@ describe('actions.js', () => {
404
440
  return { ok: false };
405
441
  }
406
442
  if (url === 'http://localhost:4000/project/current') {
407
- return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
443
+ return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
444
+ }
445
+ return { ok: false };
446
+ };
447
+
448
+ const result = await discoverOpencodeServer('/Users/test/project', {
449
+ getPorts: mockPorts,
450
+ fetch: mockFetch
451
+ });
452
+
453
+ assert.strictEqual(result, 'http://localhost:4000');
454
+ });
455
+
456
+ test('skips servers that return invalid JSON', async () => {
457
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
458
+
459
+ const mockPorts = async () => [3000, 4000];
460
+ const mockFetch = async (url) => {
461
+ if (url === 'http://localhost:3000/project/current') {
462
+ // Stale server returning HTML
463
+ return {
464
+ ok: true,
465
+ json: async () => { throw new SyntaxError('Unexpected token < in JSON'); }
466
+ };
467
+ }
468
+ if (url === 'http://localhost:4000/project/current') {
469
+ return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
408
470
  }
409
471
  return { ok: false };
410
472
  };
@@ -416,6 +478,50 @@ describe('actions.js', () => {
416
478
 
417
479
  assert.strictEqual(result, 'http://localhost:4000');
418
480
  });
481
+
482
+ test('skips servers with incomplete project data (missing time)', async () => {
483
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
484
+
485
+ const mockPorts = async () => [3000, 4000];
486
+ const mockFetch = async (url) => {
487
+ if (url === 'http://localhost:3000/project/current') {
488
+ // Server returning incomplete response (missing time.created)
489
+ return { ok: true, json: async () => ({ id: 'broken', worktree: '/Users/test/project', sandboxes: [] }) };
490
+ }
491
+ if (url === 'http://localhost:4000/project/current') {
492
+ return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
493
+ }
494
+ return { ok: false };
495
+ };
496
+
497
+ const result = await discoverOpencodeServer('/Users/test/project', {
498
+ getPorts: mockPorts,
499
+ fetch: mockFetch
500
+ });
501
+
502
+ assert.strictEqual(result, 'http://localhost:4000');
503
+ });
504
+
505
+ test('returns null when server returns unhealthy project data', async () => {
506
+ const { discoverOpencodeServer } = await import('../../service/actions.js');
507
+
508
+ const mockPorts = async () => [3000];
509
+ const mockFetch = async (url) => {
510
+ if (url === 'http://localhost:3000/project/current') {
511
+ // Server returns incomplete/unhealthy data (missing id and time)
512
+ return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
513
+ }
514
+ return { ok: false };
515
+ };
516
+
517
+ // Server that returns unhealthy project data should be skipped
518
+ const result = await discoverOpencodeServer('/Users/test/specific-project', {
519
+ getPorts: mockPorts,
520
+ fetch: mockFetch
521
+ });
522
+
523
+ assert.strictEqual(result, null, 'Should return null when server returns unhealthy project data');
524
+ });
419
525
  });
420
526
 
421
527
  describe('executeAction', () => {
@@ -664,6 +664,44 @@ sources:
664
664
  const filteredItem = { repository_full_name: 'other/repo' };
665
665
  assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
666
666
  });
667
+
668
+ test('github presets include semantic session names', async () => {
669
+ writeFileSync(configPath, `
670
+ sources:
671
+ - preset: github/my-issues
672
+ - preset: github/review-requests
673
+ - preset: github/my-prs-feedback
674
+ `);
675
+
676
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
677
+ loadRepoConfig(configPath);
678
+ const sources = getSources();
679
+
680
+ // my-issues: just the title
681
+ assert.strictEqual(sources[0].session.name, '{title}', 'my-issues should use title');
682
+
683
+ // review-requests: "Review: {title}"
684
+ assert.strictEqual(sources[1].session.name, 'Review: {title}', 'review-requests should prefix with Review:');
685
+
686
+ // my-prs-feedback: "Feedback: {title}"
687
+ assert.strictEqual(sources[2].session.name, 'Feedback: {title}', 'my-prs-feedback should prefix with Feedback:');
688
+ });
689
+
690
+ test('linear preset includes session name', async () => {
691
+ writeFileSync(configPath, `
692
+ sources:
693
+ - preset: linear/my-issues
694
+ args:
695
+ teamId: "team-uuid"
696
+ assigneeId: "user-uuid"
697
+ `);
698
+
699
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
700
+ loadRepoConfig(configPath);
701
+ const sources = getSources();
702
+
703
+ assert.strictEqual(sources[0].session.name, '{title}', 'linear/my-issues should use title');
704
+ });
667
705
  });
668
706
 
669
707
  describe('shorthand syntax', () => {