opencode-pilot 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.9.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
 
@@ -310,10 +310,10 @@ describe('actions.js', () => {
310
310
  const mockPorts = async () => [3000, 4000];
311
311
  const mockFetch = async (url) => {
312
312
  if (url === 'http://localhost:3000/project/current') {
313
- 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 } }) };
314
314
  }
315
315
  if (url === 'http://localhost:4000/project/current') {
316
- 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 } }) };
317
317
  }
318
318
  return { ok: false };
319
319
  };
@@ -332,7 +332,7 @@ describe('actions.js', () => {
332
332
  const mockPorts = async () => [3000];
333
333
  const mockFetch = async (url) => {
334
334
  if (url === 'http://localhost:3000/project/current') {
335
- 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 } }) };
336
336
  }
337
337
  return { ok: false };
338
338
  };
@@ -352,8 +352,10 @@ describe('actions.js', () => {
352
352
  const mockFetch = async (url) => {
353
353
  if (url === 'http://localhost:3000/project/current') {
354
354
  return { ok: true, json: async () => ({
355
+ id: 'proj',
355
356
  worktree: '/Users/test/project',
356
- sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1']
357
+ sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1'],
358
+ time: { created: 1 }
357
359
  }) };
358
360
  }
359
361
  return { ok: false };
@@ -374,11 +376,11 @@ describe('actions.js', () => {
374
376
  const mockFetch = async (url) => {
375
377
  if (url === 'http://localhost:3000/project/current') {
376
378
  // Global project
377
- return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
379
+ return { ok: true, json: async () => ({ id: 'global', worktree: '/', sandboxes: [], time: { created: 1 } }) };
378
380
  }
379
381
  if (url === 'http://localhost:4000/project/current') {
380
382
  // Specific project
381
- 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 } }) };
382
384
  }
383
385
  return { ok: false };
384
386
  };
@@ -392,17 +394,19 @@ describe('actions.js', () => {
392
394
  assert.strictEqual(result, 'http://localhost:4000');
393
395
  });
394
396
 
395
- test('falls back to global project when no specific match', async () => {
397
+ test('falls back to healthy global project when no specific match', async () => {
396
398
  const { discoverOpencodeServer } = await import('../../service/actions.js');
397
399
 
398
400
  const mockPorts = async () => [3000];
399
401
  const mockFetch = async (url) => {
400
402
  if (url === 'http://localhost:3000/project/current') {
401
- 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 } }) };
402
405
  }
403
406
  return { ok: false };
404
407
  };
405
408
 
409
+ // Global servers (like OpenCode Desktop) should work as fallback
406
410
  const result = await discoverOpencodeServer('/Users/test/random/path', {
407
411
  getPorts: mockPorts,
408
412
  fetch: mockFetch
@@ -436,7 +440,56 @@ describe('actions.js', () => {
436
440
  return { ok: false };
437
441
  }
438
442
  if (url === 'http://localhost:4000/project/current') {
439
- 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 } }) };
470
+ }
471
+ return { ok: false };
472
+ };
473
+
474
+ const result = await discoverOpencodeServer('/Users/test/project', {
475
+ getPorts: mockPorts,
476
+ fetch: mockFetch
477
+ });
478
+
479
+ assert.strictEqual(result, 'http://localhost:4000');
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 } }) };
440
493
  }
441
494
  return { ok: false };
442
495
  };
@@ -448,6 +501,27 @@ describe('actions.js', () => {
448
501
 
449
502
  assert.strictEqual(result, 'http://localhost:4000');
450
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
+ });
451
525
  });
452
526
 
453
527
  describe('executeAction', () => {