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 CHANGED
@@ -61,13 +61,14 @@ npx opencode-pilot status
61
61
 
62
62
  ## Configuration
63
63
 
64
- Config file: `~/.config/opencode-pilot/config.yaml`
64
+ Config file: `~/.config/opencode/pilot/config.yaml`
65
65
 
66
66
  Configuration has these sections:
67
- - `tools` - field mappings for MCP servers
68
- - `sources` - polling sources with generic MCP tool references
69
- - `repos` - per-repository settings (use YAML anchors to share config)
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-pilot/templates/*.md`
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
 
@@ -1,5 +1,5 @@
1
1
  # Example config.yaml for opencode-pilot
2
- # Copy to ~/.config/opencode-pilot/config.yaml
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.18.2",
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",
@@ -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
- * The GitHub MCP server doesn't have a tool to list issue comments,
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 MCP github_get_pull_request_comments
417
- * 2. Issue comments (conversation thread) via gh CLI
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 bots like Linear post to issue comments, not PR review comments.
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 owner, repo_short, and number fields
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
- mcpConfig = getMcpConfig("github", options.opencodeConfigPath);
449
- } catch {
450
- console.error("[poller] GitHub MCP not configured, cannot fetch comments");
451
- return [];
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
 
@@ -8,18 +8,26 @@
8
8
  import { debug } from "./logger.js";
9
9
 
10
10
  /**
11
- * List available worktrees/sandboxes for a project
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 directory paths
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
- const response = await fetchFn(`${serverUrl}/experimental/worktree`);
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 };