opencode-pilot 0.18.2 → 0.19.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/AGENTS.md +6 -5
- package/README.md +20 -2
- package/examples/config.yaml +7 -1
- package/package.json +4 -2
- package/service/actions.js +284 -0
- package/service/poller.js +43 -55
- package/service/worktree.js +50 -6
- package/test/integration/session-reuse.test.js +347 -0
- package/test/unit/actions.test.js +295 -2
- package/test/unit/worktree.test.js +150 -9
package/AGENTS.md
CHANGED
|
@@ -61,13 +61,14 @@ npx opencode-pilot status
|
|
|
61
61
|
|
|
62
62
|
## Configuration
|
|
63
63
|
|
|
64
|
-
Config file: `~/.config/opencode
|
|
64
|
+
Config file: `~/.config/opencode/pilot/config.yaml`
|
|
65
65
|
|
|
66
66
|
Configuration has these sections:
|
|
67
|
-
- `
|
|
68
|
-
- `
|
|
69
|
-
- `
|
|
67
|
+
- `defaults` - default values applied to all sources
|
|
68
|
+
- `repos_dir` - directory to auto-discover repos via git remotes
|
|
69
|
+
- `sources` - polling sources with presets, shorthand, or full MCP tool config
|
|
70
|
+
- `tools` - field mappings to normalize different MCP APIs
|
|
70
71
|
|
|
71
|
-
Template files: `~/.config/opencode
|
|
72
|
+
Template files: `~/.config/opencode/pilot/templates/*.md`
|
|
72
73
|
|
|
73
74
|
See [examples/config.yaml](examples/config.yaml) for a complete example.
|
package/README.md
CHANGED
|
@@ -74,6 +74,23 @@ Session names for `my-prs-attention` indicate the condition: "Conflicts: {title}
|
|
|
74
74
|
|
|
75
75
|
Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
|
|
76
76
|
|
|
77
|
+
### Session and Sandbox Reuse
|
|
78
|
+
|
|
79
|
+
By default, pilot reuses existing sessions and sandboxes to avoid duplicates:
|
|
80
|
+
|
|
81
|
+
- **Session reuse**: If a non-archived session already exists for the target directory, pilot appends to it instead of creating a new session. Archived sessions are never reused.
|
|
82
|
+
- **Sandbox reuse**: When `worktree: "new"` with a `worktree_name`, pilot first checks if a sandbox with that name already exists and reuses it.
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
defaults:
|
|
86
|
+
# Disable session reuse (always create new sessions)
|
|
87
|
+
reuse_active_session: false
|
|
88
|
+
# Disable sandbox reuse (always create new worktrees)
|
|
89
|
+
prefer_existing_sandbox: false
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
When multiple sessions exist for the same directory, pilot prefers idle sessions over busy ones, then selects the most recently updated.
|
|
93
|
+
|
|
77
94
|
### Worktree Support
|
|
78
95
|
|
|
79
96
|
Run sessions in isolated git worktrees instead of the main project directory. This uses OpenCode's built-in worktree management API to create and manage worktrees.
|
|
@@ -81,7 +98,7 @@ Run sessions in isolated git worktrees instead of the main project directory. Th
|
|
|
81
98
|
```yaml
|
|
82
99
|
sources:
|
|
83
100
|
- preset: github/my-issues
|
|
84
|
-
# Create a fresh worktree for each session
|
|
101
|
+
# Create a fresh worktree for each session (or reuse if name matches)
|
|
85
102
|
worktree: "new"
|
|
86
103
|
worktree_name: "issue-{number}" # Optional: name template
|
|
87
104
|
|
|
@@ -91,9 +108,10 @@ sources:
|
|
|
91
108
|
```
|
|
92
109
|
|
|
93
110
|
**Options:**
|
|
94
|
-
- `worktree: "new"` - Create a new worktree via OpenCode's API
|
|
111
|
+
- `worktree: "new"` - Create a new worktree via OpenCode's API (or reuse existing if name matches)
|
|
95
112
|
- `worktree: "name"` - Look up existing worktree by name from project sandboxes
|
|
96
113
|
- `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
|
|
114
|
+
- `prefer_existing_sandbox: false` - Disable sandbox reuse for this source
|
|
97
115
|
|
|
98
116
|
## CLI Commands
|
|
99
117
|
|
package/examples/config.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Example config.yaml for opencode-pilot
|
|
2
|
-
# Copy to ~/.config/opencode
|
|
2
|
+
# Copy to ~/.config/opencode/pilot/config.yaml
|
|
3
3
|
|
|
4
4
|
# Preferred OpenCode server port for attaching sessions
|
|
5
5
|
# When multiple OpenCode instances are running, pilot will attach new sessions
|
|
@@ -19,6 +19,12 @@ repos_dir: ~/code
|
|
|
19
19
|
defaults:
|
|
20
20
|
agent: plan
|
|
21
21
|
prompt: default
|
|
22
|
+
# Session reuse: append to existing non-archived session instead of creating new
|
|
23
|
+
# Default: true. Set to false to always create new sessions.
|
|
24
|
+
# reuse_active_session: true
|
|
25
|
+
# Sandbox reuse: reuse existing worktree/sandbox with matching name
|
|
26
|
+
# Default: true. Set to false to always create new sandboxes.
|
|
27
|
+
# prefer_existing_sandbox: true
|
|
22
28
|
|
|
23
29
|
sources:
|
|
24
30
|
# Presets - common patterns with sensible defaults
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-pilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"description": "Automation daemon for OpenCode - polls for work and spawns sessions",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"opencode-pilot": "./bin/opencode-pilot"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test test/unit/*.test.js"
|
|
28
|
+
"test": "node --test test/unit/*.test.js",
|
|
29
|
+
"test:integration": "node --test test/integration/*.test.js",
|
|
30
|
+
"test:all": "node --test test/unit/*.test.js test/integration/*.test.js"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@semantic-release/git": "^10.0.1",
|
package/service/actions.js
CHANGED
|
@@ -306,6 +306,258 @@ export function buildCommand(item, config) {
|
|
|
306
306
|
return `[API] POST /session?directory=${cwd} (title: "${sessionName}")`;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* List sessions from the OpenCode server
|
|
311
|
+
*
|
|
312
|
+
* @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
|
|
313
|
+
* @param {object} [options] - Options
|
|
314
|
+
* @param {string} [options.directory] - Filter by directory
|
|
315
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
316
|
+
* @returns {Promise<Array>} Array of session objects
|
|
317
|
+
*/
|
|
318
|
+
export async function listSessions(serverUrl, options = {}) {
|
|
319
|
+
const fetchFn = options.fetch || fetch;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const url = new URL('/session', serverUrl);
|
|
323
|
+
if (options.directory) {
|
|
324
|
+
url.searchParams.set('directory', options.directory);
|
|
325
|
+
}
|
|
326
|
+
// Only get root sessions (not child/forked sessions)
|
|
327
|
+
url.searchParams.set('roots', 'true');
|
|
328
|
+
|
|
329
|
+
const response = await fetchFn(url.toString());
|
|
330
|
+
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
debug(`listSessions: ${serverUrl} returned ${response.status}`);
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sessions = await response.json();
|
|
337
|
+
return Array.isArray(sessions) ? sessions : [];
|
|
338
|
+
} catch (err) {
|
|
339
|
+
debug(`listSessions: error - ${err.message}`);
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if a session is archived
|
|
346
|
+
* A session is archived if time.archived is set (it's a timestamp)
|
|
347
|
+
*
|
|
348
|
+
* @param {object} session - Session object from API
|
|
349
|
+
* @returns {boolean} True if session is archived
|
|
350
|
+
*/
|
|
351
|
+
export function isSessionArchived(session) {
|
|
352
|
+
return session?.time?.archived !== undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get session statuses from the OpenCode server
|
|
357
|
+
* Returns a map of sessionId -> status (idle, busy, retry)
|
|
358
|
+
* Sessions not in the map are considered idle
|
|
359
|
+
*
|
|
360
|
+
* @param {string} serverUrl - Server URL
|
|
361
|
+
* @param {object} [options] - Options
|
|
362
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
363
|
+
* @returns {Promise<object>} Map of sessionId -> status object
|
|
364
|
+
*/
|
|
365
|
+
export async function getSessionStatuses(serverUrl, options = {}) {
|
|
366
|
+
const fetchFn = options.fetch || fetch;
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const response = await fetchFn(`${serverUrl}/session/status`);
|
|
370
|
+
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
debug(`getSessionStatuses: ${serverUrl} returned ${response.status}`);
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return await response.json();
|
|
377
|
+
} catch (err) {
|
|
378
|
+
debug(`getSessionStatuses: error - ${err.message}`);
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Find the best session to reuse from a list of candidates
|
|
385
|
+
* Prefers idle sessions, then most recently updated
|
|
386
|
+
*
|
|
387
|
+
* @param {Array} sessions - Array of non-archived sessions
|
|
388
|
+
* @param {object} statuses - Map of sessionId -> status from /session/status
|
|
389
|
+
* @returns {object|null} Best session to reuse, or null if none
|
|
390
|
+
*/
|
|
391
|
+
export function selectBestSession(sessions, statuses) {
|
|
392
|
+
if (!sessions || sessions.length === 0) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Separate idle vs busy/retry sessions
|
|
397
|
+
const idle = [];
|
|
398
|
+
const other = [];
|
|
399
|
+
|
|
400
|
+
for (const session of sessions) {
|
|
401
|
+
const status = statuses[session.id];
|
|
402
|
+
// Sessions not in statuses map are idle (per OpenCode behavior)
|
|
403
|
+
if (!status || status.type === 'idle') {
|
|
404
|
+
idle.push(session);
|
|
405
|
+
} else {
|
|
406
|
+
other.push(session);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Sort by most recently updated (highest time.updated first)
|
|
411
|
+
const sortByUpdated = (a, b) => (b.time?.updated || 0) - (a.time?.updated || 0);
|
|
412
|
+
|
|
413
|
+
// Prefer idle sessions
|
|
414
|
+
if (idle.length > 0) {
|
|
415
|
+
idle.sort(sortByUpdated);
|
|
416
|
+
return idle[0];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Fall back to busy/retry sessions (sorted by most recent)
|
|
420
|
+
if (other.length > 0) {
|
|
421
|
+
other.sort(sortByUpdated);
|
|
422
|
+
return other[0];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Send a message to an existing session
|
|
430
|
+
*
|
|
431
|
+
* @param {string} serverUrl - Server URL
|
|
432
|
+
* @param {string} sessionId - Session ID to send message to
|
|
433
|
+
* @param {string} directory - Working directory
|
|
434
|
+
* @param {string} prompt - The prompt/message to send
|
|
435
|
+
* @param {object} [options] - Options
|
|
436
|
+
* @param {string} [options.title] - Update session title (optional)
|
|
437
|
+
* @param {string} [options.agent] - Agent to use
|
|
438
|
+
* @param {string} [options.model] - Model to use
|
|
439
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
440
|
+
* @returns {Promise<object>} Result with sessionId, success, error
|
|
441
|
+
*/
|
|
442
|
+
export async function sendMessageToSession(serverUrl, sessionId, directory, prompt, options = {}) {
|
|
443
|
+
const fetchFn = options.fetch || fetch;
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
// Step 1: Update session title if provided
|
|
447
|
+
if (options.title) {
|
|
448
|
+
const updateUrl = new URL(`/session/${sessionId}`, serverUrl);
|
|
449
|
+
updateUrl.searchParams.set('directory', directory);
|
|
450
|
+
await fetchFn(updateUrl.toString(), {
|
|
451
|
+
method: 'PATCH',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify({ title: options.title }),
|
|
454
|
+
});
|
|
455
|
+
debug(`sendMessageToSession: updated title for session ${sessionId}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Step 2: Send the message
|
|
459
|
+
const messageUrl = new URL(`/session/${sessionId}/message`, serverUrl);
|
|
460
|
+
messageUrl.searchParams.set('directory', directory);
|
|
461
|
+
|
|
462
|
+
const messageBody = {
|
|
463
|
+
parts: [{ type: 'text', text: prompt }],
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
if (options.agent) {
|
|
467
|
+
messageBody.agent = options.agent;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (options.model) {
|
|
471
|
+
const [providerID, modelID] = options.model.includes('/')
|
|
472
|
+
? options.model.split('/', 2)
|
|
473
|
+
: ['anthropic', options.model];
|
|
474
|
+
messageBody.providerID = providerID;
|
|
475
|
+
messageBody.modelID = modelID;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Use AbortController with timeout (same pattern as createSessionViaApi)
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const messageResponse = await fetchFn(messageUrl.toString(), {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers: { 'Content-Type': 'application/json' },
|
|
486
|
+
body: JSON.stringify(messageBody),
|
|
487
|
+
signal: controller.signal,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
clearTimeout(timeoutId);
|
|
491
|
+
|
|
492
|
+
if (!messageResponse.ok) {
|
|
493
|
+
const errorText = await messageResponse.text();
|
|
494
|
+
throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
debug(`sendMessageToSession: sent message to session ${sessionId}`);
|
|
498
|
+
} catch (abortErr) {
|
|
499
|
+
clearTimeout(timeoutId);
|
|
500
|
+
if (abortErr.name === 'AbortError') {
|
|
501
|
+
debug(`sendMessageToSession: message request started for session ${sessionId} (response aborted as expected)`);
|
|
502
|
+
} else {
|
|
503
|
+
throw abortErr;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
success: true,
|
|
509
|
+
sessionId,
|
|
510
|
+
directory,
|
|
511
|
+
reused: true,
|
|
512
|
+
};
|
|
513
|
+
} catch (err) {
|
|
514
|
+
debug(`sendMessageToSession: error - ${err.message}`);
|
|
515
|
+
return {
|
|
516
|
+
success: false,
|
|
517
|
+
error: err.message,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Find an existing session to reuse for the given directory
|
|
524
|
+
* Returns null if no suitable session found (archived sessions are excluded)
|
|
525
|
+
*
|
|
526
|
+
* @param {string} serverUrl - Server URL
|
|
527
|
+
* @param {string} directory - Working directory to match
|
|
528
|
+
* @param {object} [options] - Options
|
|
529
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
530
|
+
* @returns {Promise<object|null>} Session to reuse, or null
|
|
531
|
+
*/
|
|
532
|
+
export async function findReusableSession(serverUrl, directory, options = {}) {
|
|
533
|
+
// Get sessions for this directory
|
|
534
|
+
const sessions = await listSessions(serverUrl, {
|
|
535
|
+
directory,
|
|
536
|
+
fetch: options.fetch
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (sessions.length === 0) {
|
|
540
|
+
debug(`findReusableSession: no sessions found for ${directory}`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Filter out archived sessions
|
|
545
|
+
const activeSessions = sessions.filter(s => !isSessionArchived(s));
|
|
546
|
+
|
|
547
|
+
if (activeSessions.length === 0) {
|
|
548
|
+
debug(`findReusableSession: all ${sessions.length} sessions are archived for ${directory}`);
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
debug(`findReusableSession: found ${activeSessions.length} active sessions for ${directory}`);
|
|
553
|
+
|
|
554
|
+
// Get statuses to prefer idle sessions
|
|
555
|
+
const statuses = await getSessionStatuses(serverUrl, { fetch: options.fetch });
|
|
556
|
+
|
|
557
|
+
// Select the best session
|
|
558
|
+
return selectBestSession(activeSessions, statuses);
|
|
559
|
+
}
|
|
560
|
+
|
|
309
561
|
/**
|
|
310
562
|
* Create a session via the OpenCode HTTP API
|
|
311
563
|
*
|
|
@@ -500,6 +752,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
500
752
|
worktree: worktreeMode,
|
|
501
753
|
// Expand worktree_name template with item fields (e.g., "issue-{number}")
|
|
502
754
|
worktreeName: config.worktree_name ? expandTemplate(config.worktree_name, item) : undefined,
|
|
755
|
+
// Config flag to control sandbox reuse (default true)
|
|
756
|
+
preferExistingSandbox: config.prefer_existing_sandbox,
|
|
503
757
|
};
|
|
504
758
|
|
|
505
759
|
const worktreeResult = await resolveWorktreeDirectory(
|
|
@@ -513,6 +767,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
513
767
|
|
|
514
768
|
if (worktreeResult.worktreeCreated) {
|
|
515
769
|
debug(`executeAction: created new worktree at ${cwd}`);
|
|
770
|
+
} else if (worktreeResult.worktreeReused) {
|
|
771
|
+
debug(`executeAction: reusing existing sandbox at ${cwd}`);
|
|
516
772
|
} else if (worktreeResult.error) {
|
|
517
773
|
debug(`executeAction: worktree resolution warning - ${worktreeResult.error}`);
|
|
518
774
|
}
|
|
@@ -527,6 +783,34 @@ export async function executeAction(item, config, options = {}) {
|
|
|
527
783
|
? buildSessionName(config.session.name, item)
|
|
528
784
|
: (item.title || `session-${Date.now()}`);
|
|
529
785
|
|
|
786
|
+
// Check if we should try to reuse an existing session
|
|
787
|
+
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
788
|
+
|
|
789
|
+
if (reuseActiveSession && !options.dryRun) {
|
|
790
|
+
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
791
|
+
|
|
792
|
+
if (existingSession) {
|
|
793
|
+
debug(`executeAction: found reusable session ${existingSession.id} for ${cwd}`);
|
|
794
|
+
|
|
795
|
+
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
796
|
+
|
|
797
|
+
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
798
|
+
title: sessionTitle,
|
|
799
|
+
agent: config.agent,
|
|
800
|
+
model: config.model,
|
|
801
|
+
fetch: options.fetch,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return {
|
|
805
|
+
command: reuseCommand,
|
|
806
|
+
success: result.success,
|
|
807
|
+
sessionId: result.sessionId,
|
|
808
|
+
sessionReused: true,
|
|
809
|
+
error: result.error,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
530
814
|
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
531
815
|
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
532
816
|
|
package/service/poller.js
CHANGED
|
@@ -379,9 +379,7 @@ export async function pollGenericSource(source, options = {}) {
|
|
|
379
379
|
/**
|
|
380
380
|
* Fetch issue comments using gh CLI
|
|
381
381
|
*
|
|
382
|
-
*
|
|
383
|
-
* so we use gh CLI directly. This fetches the conversation thread
|
|
384
|
-
* where bots like Linear post their comments.
|
|
382
|
+
* Fetches the conversation thread where bots like Linear post their comments.
|
|
385
383
|
*
|
|
386
384
|
* @param {string} owner - Repository owner
|
|
387
385
|
* @param {string} repo - Repository name
|
|
@@ -409,19 +407,50 @@ async function fetchIssueCommentsViaCli(owner, repo, number, timeout) {
|
|
|
409
407
|
}
|
|
410
408
|
}
|
|
411
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Fetch PR review comments using gh CLI
|
|
412
|
+
*
|
|
413
|
+
* Fetches inline code review comments on a PR.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} owner - Repository owner
|
|
416
|
+
* @param {string} repo - Repository name
|
|
417
|
+
* @param {number} number - PR number
|
|
418
|
+
* @param {number} timeout - Timeout in ms
|
|
419
|
+
* @returns {Promise<Array>} Array of comment objects
|
|
420
|
+
*/
|
|
421
|
+
async function fetchPrReviewCommentsViaCli(owner, repo, number, timeout) {
|
|
422
|
+
const { exec } = await import('child_process');
|
|
423
|
+
const { promisify } = await import('util');
|
|
424
|
+
const execAsync = promisify(exec);
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const { stdout } = await Promise.race([
|
|
428
|
+
execAsync(`gh api repos/${owner}/${repo}/pulls/${number}/comments`),
|
|
429
|
+
createTimeout(timeout, "gh api call for PR comments"),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
const comments = JSON.parse(stdout);
|
|
433
|
+
return Array.isArray(comments) ? comments : [];
|
|
434
|
+
} catch (err) {
|
|
435
|
+
console.error(`[poller] Error fetching PR review comments via gh: ${err.message}`);
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
412
440
|
/**
|
|
413
441
|
* Fetch comments for a GitHub issue/PR and enrich the item
|
|
414
442
|
*
|
|
415
|
-
* Fetches BOTH types of comments:
|
|
416
|
-
* 1. PR review comments (inline code comments) via
|
|
417
|
-
* 2. Issue comments (conversation thread) via gh
|
|
443
|
+
* Fetches BOTH types of comments using gh CLI:
|
|
444
|
+
* 1. PR review comments (inline code comments) via gh api pulls/{number}/comments
|
|
445
|
+
* 2. Issue comments (conversation thread) via gh api issues/{number}/comments
|
|
418
446
|
*
|
|
419
|
-
* This is necessary because
|
|
447
|
+
* This is necessary because:
|
|
448
|
+
* - Bots like Linear post to issue comments, not PR review comments
|
|
449
|
+
* - Human reviewers post inline feedback as PR review comments
|
|
420
450
|
*
|
|
421
|
-
* @param {object} item - Item with
|
|
451
|
+
* @param {object} item - Item with repository_full_name and number fields
|
|
422
452
|
* @param {object} [options] - Options
|
|
423
453
|
* @param {number} [options.timeout] - Timeout in ms (default: 30000)
|
|
424
|
-
* @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
|
|
425
454
|
* @returns {Promise<Array>} Array of comment objects (merged from both endpoints)
|
|
426
455
|
*/
|
|
427
456
|
export async function fetchGitHubComments(item, options = {}) {
|
|
@@ -443,61 +472,20 @@ export async function fetchGitHubComments(item, options = {}) {
|
|
|
443
472
|
return [];
|
|
444
473
|
}
|
|
445
474
|
|
|
446
|
-
let mcpConfig;
|
|
447
475
|
try {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const client = new Client({ name: "opencode-pilot", version: "1.0.0" });
|
|
455
|
-
|
|
456
|
-
try {
|
|
457
|
-
const transport = await createTransport(mcpConfig);
|
|
458
|
-
|
|
459
|
-
await Promise.race([
|
|
460
|
-
client.connect(transport),
|
|
461
|
-
createTimeout(timeout, "MCP connection for comments"),
|
|
462
|
-
]);
|
|
463
|
-
|
|
464
|
-
// Fetch both PR review comments (via MCP) AND issue comments (via gh CLI) in parallel
|
|
465
|
-
const [prCommentsResult, issueComments] = await Promise.all([
|
|
466
|
-
// PR review comments via MCP (may not be available on all MCP servers)
|
|
467
|
-
client.callTool({
|
|
468
|
-
name: "github_get_pull_request_comments",
|
|
469
|
-
arguments: { owner, repo, pull_number: number }
|
|
470
|
-
}).catch(() => null), // Gracefully handle if tool doesn't exist
|
|
471
|
-
// Issue comments via gh CLI (conversation thread where Linear bot posts)
|
|
476
|
+
// Fetch both PR review comments AND issue comments in parallel via gh CLI
|
|
477
|
+
const [prComments, issueComments] = await Promise.all([
|
|
478
|
+
// PR review comments (inline code comments from reviewers)
|
|
479
|
+
fetchPrReviewCommentsViaCli(owner, repo, number, timeout),
|
|
480
|
+
// Issue comments (conversation thread where Linear bot posts)
|
|
472
481
|
fetchIssueCommentsViaCli(owner, repo, number, timeout),
|
|
473
482
|
]);
|
|
474
483
|
|
|
475
|
-
// Parse PR review comments
|
|
476
|
-
let prComments = [];
|
|
477
|
-
const prText = prCommentsResult?.content?.[0]?.text;
|
|
478
|
-
if (prText) {
|
|
479
|
-
try {
|
|
480
|
-
const parsed = JSON.parse(prText);
|
|
481
|
-
prComments = Array.isArray(parsed) ? parsed : [];
|
|
482
|
-
} catch {
|
|
483
|
-
// Ignore parse errors
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
484
|
// Return merged comments from both sources
|
|
488
485
|
return [...prComments, ...issueComments];
|
|
489
486
|
} catch (err) {
|
|
490
487
|
console.error(`[poller] Error fetching comments: ${err.message}`);
|
|
491
488
|
return [];
|
|
492
|
-
} finally {
|
|
493
|
-
try {
|
|
494
|
-
await Promise.race([
|
|
495
|
-
client.close(),
|
|
496
|
-
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
497
|
-
]);
|
|
498
|
-
} catch {
|
|
499
|
-
// Ignore close errors
|
|
500
|
-
}
|
|
501
489
|
}
|
|
502
490
|
}
|
|
503
491
|
|
package/service/worktree.js
CHANGED
|
@@ -8,18 +8,26 @@
|
|
|
8
8
|
import { debug } from "./logger.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* List
|
|
11
|
+
* List existing worktrees from the OpenCode server
|
|
12
12
|
*
|
|
13
13
|
* @param {string} serverUrl - OpenCode server URL (e.g., "http://localhost:4096")
|
|
14
14
|
* @param {object} [options] - Options
|
|
15
|
+
* @param {string} [options.directory] - Project directory (required for global server)
|
|
15
16
|
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
16
|
-
* @returns {Promise<string[]>} Array of worktree
|
|
17
|
+
* @returns {Promise<string[]>} Array of worktree paths
|
|
17
18
|
*/
|
|
18
19
|
export async function listWorktrees(serverUrl, options = {}) {
|
|
19
20
|
const fetchFn = options.fetch || fetch;
|
|
20
21
|
|
|
21
22
|
try {
|
|
22
|
-
|
|
23
|
+
// Build URL with directory parameter if provided
|
|
24
|
+
// This tells the global server which project to list worktrees for
|
|
25
|
+
let url = `${serverUrl}/experimental/worktree`;
|
|
26
|
+
if (options.directory) {
|
|
27
|
+
url += `?directory=${encodeURIComponent(options.directory)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const response = await fetchFn(url);
|
|
23
31
|
|
|
24
32
|
if (!response.ok) {
|
|
25
33
|
debug(`listWorktrees: ${serverUrl} returned ${response.status}`);
|
|
@@ -162,6 +170,25 @@ export async function getProjectInfoForDirectory(serverUrl, directory, options =
|
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Find an existing worktree by exact name match
|
|
175
|
+
*
|
|
176
|
+
* @param {string[]} worktrees - List of worktree paths
|
|
177
|
+
* @param {string} name - Name to match (final path component)
|
|
178
|
+
* @returns {string|null} Matching worktree path or null
|
|
179
|
+
*/
|
|
180
|
+
function findWorktreeByName(worktrees, name) {
|
|
181
|
+
for (const wt of worktrees) {
|
|
182
|
+
// Match exact final path component
|
|
183
|
+
const parts = wt.split('/');
|
|
184
|
+
const finalComponent = parts[parts.length - 1];
|
|
185
|
+
if (finalComponent === name) {
|
|
186
|
+
return wt;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
165
192
|
/**
|
|
166
193
|
* Resolve the working directory based on worktree configuration
|
|
167
194
|
*
|
|
@@ -174,9 +201,10 @@ export async function getProjectInfoForDirectory(serverUrl, directory, options =
|
|
|
174
201
|
* @param {object} worktreeConfig - Worktree configuration
|
|
175
202
|
* @param {string} [worktreeConfig.worktree] - Worktree mode: "new" or worktree name
|
|
176
203
|
* @param {string} [worktreeConfig.worktreeName] - Name for new worktree (only with "new")
|
|
204
|
+
* @param {boolean} [worktreeConfig.preferExistingSandbox] - If true (default), reuse existing sandbox with matching name
|
|
177
205
|
* @param {object} [options] - Options
|
|
178
206
|
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
179
|
-
* @returns {Promise<object>} Result with { directory, worktreeCreated?, error? }
|
|
207
|
+
* @returns {Promise<object>} Result with { directory, worktreeCreated?, worktreeReused?, error? }
|
|
180
208
|
*/
|
|
181
209
|
export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfig, options = {}) {
|
|
182
210
|
// No worktree config - use base directory
|
|
@@ -193,9 +221,25 @@ export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfi
|
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
const worktreeValue = worktreeConfig.worktree;
|
|
224
|
+
const preferExisting = worktreeConfig.preferExistingSandbox !== false; // default true
|
|
196
225
|
|
|
197
|
-
// "new" - create a fresh worktree via OpenCode API
|
|
226
|
+
// "new" - create a fresh worktree via OpenCode API (or reuse if matching name exists)
|
|
198
227
|
if (worktreeValue === "new") {
|
|
228
|
+
// If worktreeName is provided and preferExisting is true, try to reuse existing
|
|
229
|
+
if (worktreeConfig.worktreeName && preferExisting) {
|
|
230
|
+
const worktrees = await listWorktrees(serverUrl, { ...options, directory: baseDir });
|
|
231
|
+
const existingMatch = findWorktreeByName(worktrees, worktreeConfig.worktreeName);
|
|
232
|
+
|
|
233
|
+
if (existingMatch) {
|
|
234
|
+
debug(`resolveWorktreeDirectory: reusing existing sandbox "${worktreeConfig.worktreeName}" at ${existingMatch}`);
|
|
235
|
+
return {
|
|
236
|
+
directory: existingMatch,
|
|
237
|
+
worktreeReused: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// No existing match found (or not looking), create new
|
|
199
243
|
const result = await createWorktree(serverUrl, {
|
|
200
244
|
directory: baseDir,
|
|
201
245
|
name: worktreeConfig.worktreeName,
|
|
@@ -217,7 +261,7 @@ export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfi
|
|
|
217
261
|
}
|
|
218
262
|
|
|
219
263
|
// Named worktree - look it up from available sandboxes via OpenCode API
|
|
220
|
-
const worktrees = await listWorktrees(serverUrl, options);
|
|
264
|
+
const worktrees = await listWorktrees(serverUrl, { ...options, directory: baseDir });
|
|
221
265
|
const match = worktrees.find(w => w.includes(worktreeValue));
|
|
222
266
|
if (match) {
|
|
223
267
|
return { directory: match };
|