opencode-pilot 0.9.0 → 0.9.2

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.2",
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,10 @@ 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
+ *
106
+ * NOTE: Global servers (worktree="/") are NOT used - sessions spawned via
107
+ * pilot should run in isolated mode rather than attach to the global project,
108
+ * since the global project doesn't have the right working directory context.
86
109
  *
87
110
  * @param {string} targetDir - The directory we want to work in
88
111
  * @param {object} [options] - Options for testing/mocking
@@ -115,9 +138,22 @@ export async function discoverOpencodeServer(targetDir, options = {}) {
115
138
  }
116
139
 
117
140
  const project = await response.json();
141
+
142
+ // Health check: verify response has expected structure
143
+ if (!isServerHealthy(project)) {
144
+ debug(`discoverOpencodeServer: ${url} failed health check (invalid project data)`);
145
+ continue;
146
+ }
147
+
118
148
  const worktree = project.worktree || '/';
119
149
  const sandboxes = project.sandboxes || [];
120
150
 
151
+ // Skip global servers - pilot sessions should run isolated
152
+ if (worktree === '/') {
153
+ debug(`discoverOpencodeServer: ${url} is global project, skipping`);
154
+ continue;
155
+ }
156
+
121
157
  const score = getPathMatchScore(targetDir, worktree, sandboxes);
122
158
  debug(`discoverOpencodeServer: ${url} worktree=${worktree} score=${score}`);
123
159
 
@@ -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,23 +394,25 @@ 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('skips global project servers', 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
+ // Global project with worktree="/"
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 should be skipped - pilot sessions should run isolated
406
410
  const result = await discoverOpencodeServer('/Users/test/random/path', {
407
411
  getPorts: mockPorts,
408
412
  fetch: mockFetch
409
413
  });
410
414
 
411
- assert.strictEqual(result, 'http://localhost:3000');
415
+ assert.strictEqual(result, null);
412
416
  });
413
417
 
414
418
  test('returns null when fetch fails for all servers', async () => {
@@ -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', () => {