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.
package/examples/config.yaml
CHANGED
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -18,7 +18,9 @@ import os from "os";
|
|
|
18
18
|
*/
|
|
19
19
|
async function getOpencodePorts() {
|
|
20
20
|
try {
|
|
21
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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:
|
|
@@ -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
|
-
|
|
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', () => {
|