opencode-pilot 0.14.0 → 0.15.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/README.md CHANGED
@@ -70,6 +70,27 @@ Three ways to configure sources, from simplest to most flexible:
70
70
 
71
71
  Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
72
72
 
73
+ ### Worktree Support
74
+
75
+ 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.
76
+
77
+ ```yaml
78
+ sources:
79
+ - preset: github/my-issues
80
+ # Create a fresh worktree for each session
81
+ worktree: "new"
82
+ worktree_name: "issue-{number}" # Optional: name template
83
+
84
+ - preset: linear/my-issues
85
+ # Use an existing worktree by name
86
+ worktree: "my-feature-branch"
87
+ ```
88
+
89
+ **Options:**
90
+ - `worktree: "new"` - Create a new worktree via OpenCode's API
91
+ - `worktree: "name"` - Look up existing worktree by name from project sandboxes
92
+ - `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
93
+
73
94
  ## CLI Commands
74
95
 
75
96
  ```bash
@@ -92,20 +113,6 @@ opencode-pilot test-mapping MCP # Test field mappings
92
113
  3. **Spawn sessions** - Start `opencode run` with the appropriate prompt template
93
114
  4. **Track state** - Remember which items have been processed
94
115
 
95
- ## Known Issues
96
-
97
- ### Sessions attached to global server run in wrong directory
98
-
99
- When using `server_port` to attach sessions to a global OpenCode server (e.g., OpenCode Desktop with worktree="/"), sessions are created in the server's working directory (typically home) instead of the project directory. This means:
100
-
101
- - File tools resolve paths relative to home, not the project
102
- - The agent sees the wrong `Working directory` in system prompt
103
- - Git operations may target the wrong repository
104
-
105
- **Workaround**: Don't set `server_port` in your config. Sessions will run in the correct directory but won't appear in OpenCode Desktop.
106
-
107
- **Upstream issue**: [anomalyco/opencode#7376](https://github.com/anomalyco/opencode/issues/7376)
108
-
109
116
  ## Related
110
117
 
111
118
  - [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) - Run multiple devcontainer instances for OpenCode
@@ -33,6 +33,16 @@ sources:
33
33
  working_dir: ~/code/myproject
34
34
  prompt: worktree
35
35
 
36
+ # Example with worktree support - run each issue in a fresh git worktree
37
+ # - preset: github/my-issues
38
+ # worktree: "new" # Create a new worktree per session
39
+ # worktree_name: "issue-{number}" # Name template for new worktrees
40
+ # prompt: worktree
41
+
42
+ # Use an existing worktree by name
43
+ # - preset: github/review-requests
44
+ # worktree: "review-worktree" # Must exist in project sandboxes
45
+
36
46
  # GitHub shorthand syntax
37
47
  # - name: urgent-issues
38
48
  # github: "is:issue assignee:@me label:urgent state:open"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from "fs";
10
10
  import { debug } from "./logger.js";
11
11
  import { getNestedValue } from "./utils.js";
12
12
  import { getServerPort } from "./repo-config.js";
13
+ import { resolveWorktreeDirectory } from "./worktree.js";
13
14
  import path from "path";
14
15
  import os from "os";
15
16
 
@@ -449,6 +450,106 @@ function runSpawn(args, options = {}) {
449
450
  });
450
451
  }
451
452
 
453
+ /**
454
+ * Create a session via the OpenCode HTTP API
455
+ *
456
+ * This is a workaround for the known issue where `opencode run --attach`
457
+ * doesn't support a --dir flag, causing sessions to run in the wrong directory
458
+ * when attached to a global server.
459
+ *
460
+ * @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
461
+ * @param {string} directory - Working directory for the session
462
+ * @param {string} prompt - The prompt/message to send
463
+ * @param {object} [options] - Options
464
+ * @param {string} [options.title] - Session title
465
+ * @param {string} [options.agent] - Agent to use
466
+ * @param {string} [options.model] - Model to use
467
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
468
+ * @returns {Promise<object>} Result with sessionId, success, error
469
+ */
470
+ export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
471
+ const fetchFn = options.fetch || fetch;
472
+
473
+ try {
474
+ // Step 1: Create a new session with the directory parameter
475
+ const sessionUrl = new URL('/session', serverUrl);
476
+ sessionUrl.searchParams.set('directory', directory);
477
+
478
+ const createResponse = await fetchFn(sessionUrl.toString(), {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({}),
482
+ });
483
+
484
+ if (!createResponse.ok) {
485
+ const errorText = await createResponse.text();
486
+ throw new Error(`Failed to create session: ${createResponse.status} ${errorText}`);
487
+ }
488
+
489
+ const session = await createResponse.json();
490
+ debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
491
+
492
+ // Step 2: Update session title if provided
493
+ if (options.title) {
494
+ const updateUrl = new URL(`/session/${session.id}`, serverUrl);
495
+ updateUrl.searchParams.set('directory', directory);
496
+ await fetchFn(updateUrl.toString(), {
497
+ method: 'PATCH',
498
+ headers: { 'Content-Type': 'application/json' },
499
+ body: JSON.stringify({ title: options.title }),
500
+ });
501
+ }
502
+
503
+ // Step 3: Send the initial message
504
+ const messageUrl = new URL(`/session/${session.id}/message`, serverUrl);
505
+ messageUrl.searchParams.set('directory', directory);
506
+
507
+ // Build message body
508
+ const messageBody = {
509
+ parts: [{ type: 'text', text: prompt }],
510
+ };
511
+
512
+ // Add agent if specified
513
+ if (options.agent) {
514
+ messageBody.agent = options.agent;
515
+ }
516
+
517
+ // Add model if specified (format: provider/model)
518
+ if (options.model) {
519
+ const [providerID, modelID] = options.model.includes('/')
520
+ ? options.model.split('/', 2)
521
+ : ['anthropic', options.model];
522
+ messageBody.providerID = providerID;
523
+ messageBody.modelID = modelID;
524
+ }
525
+
526
+ const messageResponse = await fetchFn(messageUrl.toString(), {
527
+ method: 'POST',
528
+ headers: { 'Content-Type': 'application/json' },
529
+ body: JSON.stringify(messageBody),
530
+ });
531
+
532
+ if (!messageResponse.ok) {
533
+ const errorText = await messageResponse.text();
534
+ throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
535
+ }
536
+
537
+ debug(`createSessionViaApi: sent message to session ${session.id}`);
538
+
539
+ return {
540
+ success: true,
541
+ sessionId: session.id,
542
+ directory,
543
+ };
544
+ } catch (err) {
545
+ debug(`createSessionViaApi: error - ${err.message}`);
546
+ return {
547
+ success: false,
548
+ error: err.message,
549
+ };
550
+ }
551
+ }
552
+
452
553
  /**
453
554
  * Execute an action
454
555
  * @param {object} item - Item to create session for
@@ -456,21 +557,87 @@ function runSpawn(args, options = {}) {
456
557
  * @param {object} [options] - Execution options
457
558
  * @param {boolean} [options.dryRun] - If true, return command without executing
458
559
  * @param {function} [options.discoverServer] - Custom server discovery function (for testing)
560
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
459
561
  * @returns {Promise<object>} Result with command, stdout, stderr, exitCode
460
562
  */
461
563
  export async function executeAction(item, config, options = {}) {
462
- // Get working directory first to determine which server to attach to
564
+ // Get base working directory first to determine which server to attach to
463
565
  const workingDir = config.working_dir || config.path || config.repo_path || "~";
464
- const cwd = expandPath(workingDir);
566
+ const baseCwd = expandPath(workingDir);
465
567
 
466
568
  // Discover running opencode server for this directory
467
569
  const discoverFn = options.discoverServer || discoverOpencodeServer;
468
- const serverUrl = await discoverFn(cwd);
570
+ const serverUrl = await discoverFn(baseCwd);
469
571
 
470
- debug(`executeAction: discovered server=${serverUrl} for cwd=${cwd}`);
572
+ debug(`executeAction: discovered server=${serverUrl} for baseCwd=${baseCwd}`);
573
+
574
+ // Resolve worktree directory if configured
575
+ // This allows creating sessions in isolated worktrees instead of the main project
576
+ const worktreeConfig = {
577
+ worktree: config.worktree,
578
+ // Expand worktree_name template with item fields (e.g., "issue-{number}")
579
+ worktreeName: config.worktree_name ? expandTemplate(config.worktree_name, item) : undefined,
580
+ };
581
+
582
+ const worktreeResult = await resolveWorktreeDirectory(
583
+ serverUrl,
584
+ baseCwd,
585
+ worktreeConfig,
586
+ { fetch: options.fetch }
587
+ );
588
+
589
+ const cwd = expandPath(worktreeResult.directory);
590
+
591
+ if (worktreeResult.worktreeCreated) {
592
+ debug(`executeAction: created new worktree at ${cwd}`);
593
+ } else if (worktreeResult.error) {
594
+ debug(`executeAction: worktree resolution warning - ${worktreeResult.error}`);
595
+ }
596
+
597
+ debug(`executeAction: using cwd=${cwd}`);
598
+
599
+ // If a server is running, use the HTTP API to create the session
600
+ // This is a workaround for the known issue where --attach doesn't support --dir
601
+ // See: https://github.com/anomalyco/opencode/issues/7376
602
+ if (serverUrl) {
603
+ // Build prompt from template
604
+ const prompt = buildPromptFromTemplate(config.prompt || "default", item);
605
+
606
+ // Build session title
607
+ const sessionTitle = config.session?.name
608
+ ? buildSessionName(config.session.name, item)
609
+ : (item.title || `session-${Date.now()}`);
610
+
611
+ const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
612
+ debug(`executeAction: using HTTP API - ${apiCommand}`);
613
+
614
+ if (options.dryRun) {
615
+ return {
616
+ command: apiCommand,
617
+ dryRun: true,
618
+ method: 'api',
619
+ };
620
+ }
621
+
622
+ const result = await createSessionViaApi(serverUrl, cwd, prompt, {
623
+ title: sessionTitle,
624
+ agent: config.agent,
625
+ model: config.model,
626
+ fetch: options.fetch,
627
+ });
628
+
629
+ return {
630
+ command: apiCommand,
631
+ success: result.success,
632
+ sessionId: result.sessionId,
633
+ error: result.error,
634
+ method: 'api',
635
+ };
636
+ }
471
637
 
472
- // Build command info with server URL for --attach flag
473
- const cmdInfo = getCommandInfoNew(item, config, undefined, serverUrl);
638
+ // No server running - fall back to spawning opencode run
639
+ // This works correctly because we set cwd on the spawn
640
+ const cmdInfo = getCommandInfoNew(item, config, undefined, null);
474
641
 
475
642
  // Build command string for display
476
643
  const quoteArgs = (args) => args.map(a =>
@@ -486,6 +653,7 @@ export async function executeAction(item, config, options = {}) {
486
653
  return {
487
654
  command,
488
655
  dryRun: true,
656
+ method: 'spawn',
489
657
  };
490
658
  }
491
659
 
@@ -505,6 +673,7 @@ export async function executeAction(item, config, options = {}) {
505
673
  command,
506
674
  success: true,
507
675
  pid: child.pid,
676
+ method: 'spawn',
508
677
  };
509
678
  }
510
679
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * worktree.js - Worktree management for OpenCode sessions
3
+ *
4
+ * Interacts with OpenCode server to list and create worktrees (sandboxes).
5
+ * Worktrees allow running sessions in isolated git branches/directories.
6
+ */
7
+
8
+ import { debug } from "./logger.js";
9
+
10
+ /**
11
+ * List available worktrees/sandboxes for a project
12
+ *
13
+ * @param {string} serverUrl - OpenCode server URL (e.g., "http://localhost:4096")
14
+ * @param {object} [options] - Options
15
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
16
+ * @returns {Promise<string[]>} Array of worktree directory paths
17
+ */
18
+ export async function listWorktrees(serverUrl, options = {}) {
19
+ const fetchFn = options.fetch || fetch;
20
+
21
+ try {
22
+ const response = await fetchFn(`${serverUrl}/experimental/worktree`);
23
+
24
+ if (!response.ok) {
25
+ debug(`listWorktrees: ${serverUrl} returned ${response.status}`);
26
+ return [];
27
+ }
28
+
29
+ const worktrees = await response.json();
30
+ debug(`listWorktrees: found ${worktrees.length} worktrees`);
31
+ return Array.isArray(worktrees) ? worktrees : [];
32
+ } catch (err) {
33
+ debug(`listWorktrees: error - ${err.message}`);
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Create a new worktree for a project
40
+ *
41
+ * @param {string} serverUrl - OpenCode server URL (e.g., "http://localhost:4096")
42
+ * @param {object} [options] - Options
43
+ * @param {string} [options.name] - Optional name for the worktree
44
+ * @param {string} [options.startCommand] - Optional startup script to run after creation
45
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
46
+ * @returns {Promise<object>} Result with { success, worktree?, error? }
47
+ */
48
+ export async function createWorktree(serverUrl, options = {}) {
49
+ const fetchFn = options.fetch || fetch;
50
+
51
+ try {
52
+ const body = {};
53
+ if (options.name) body.name = options.name;
54
+ if (options.startCommand) body.startCommand = options.startCommand;
55
+
56
+ const response = await fetchFn(`${serverUrl}/experimental/worktree`, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify(body),
60
+ });
61
+
62
+ if (!response.ok) {
63
+ const errorText = await response.text();
64
+ debug(`createWorktree: ${serverUrl} returned ${response.status} - ${errorText}`);
65
+ return {
66
+ success: false,
67
+ error: `Failed to create worktree: ${response.status} ${errorText}`,
68
+ };
69
+ }
70
+
71
+ const worktree = await response.json();
72
+ debug(`createWorktree: created worktree ${worktree.name} at ${worktree.directory}`);
73
+
74
+ return {
75
+ success: true,
76
+ worktree,
77
+ };
78
+ } catch (err) {
79
+ debug(`createWorktree: error - ${err.message}`);
80
+ return {
81
+ success: false,
82
+ error: err.message,
83
+ };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get project info including sandboxes from the server
89
+ *
90
+ * @param {string} serverUrl - OpenCode server URL
91
+ * @param {object} [options] - Options
92
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
93
+ * @returns {Promise<object|null>} Project info or null if unavailable
94
+ */
95
+ export async function getProjectInfo(serverUrl, options = {}) {
96
+ const fetchFn = options.fetch || fetch;
97
+
98
+ try {
99
+ const response = await fetchFn(`${serverUrl}/project/current`);
100
+
101
+ if (!response.ok) {
102
+ debug(`getProjectInfo: ${serverUrl} returned ${response.status}`);
103
+ return null;
104
+ }
105
+
106
+ const project = await response.json();
107
+ debug(`getProjectInfo: project ${project.id} with ${project.sandboxes?.length || 0} sandboxes`);
108
+ return project;
109
+ } catch (err) {
110
+ debug(`getProjectInfo: error - ${err.message}`);
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Resolve the working directory based on worktree configuration
117
+ *
118
+ * Uses OpenCode's experimental worktree API:
119
+ * - GET /experimental/worktree - List existing worktrees
120
+ * - POST /experimental/worktree - Create new worktree
121
+ *
122
+ * @param {string} serverUrl - OpenCode server URL
123
+ * @param {string} baseDir - Base working directory from config
124
+ * @param {object} worktreeConfig - Worktree configuration
125
+ * @param {string} [worktreeConfig.worktree] - Worktree mode: "new" or worktree name
126
+ * @param {string} [worktreeConfig.worktreeName] - Name for new worktree (only with "new")
127
+ * @param {object} [options] - Options
128
+ * @param {function} [options.fetch] - Custom fetch function (for testing)
129
+ * @returns {Promise<object>} Result with { directory, worktreeCreated?, error? }
130
+ */
131
+ export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfig, options = {}) {
132
+ // No worktree config - use base directory
133
+ if (!worktreeConfig?.worktree) {
134
+ return { directory: baseDir };
135
+ }
136
+
137
+ // Require server for any worktree operation
138
+ if (!serverUrl) {
139
+ return {
140
+ directory: baseDir,
141
+ error: "Cannot use worktree: no server running",
142
+ };
143
+ }
144
+
145
+ const worktreeValue = worktreeConfig.worktree;
146
+
147
+ // "new" - create a fresh worktree via OpenCode API
148
+ if (worktreeValue === "new") {
149
+ const result = await createWorktree(serverUrl, {
150
+ name: worktreeConfig.worktreeName,
151
+ fetch: options.fetch,
152
+ });
153
+
154
+ if (!result.success) {
155
+ return {
156
+ directory: baseDir,
157
+ error: result.error,
158
+ };
159
+ }
160
+
161
+ return {
162
+ directory: result.worktree.directory,
163
+ worktreeCreated: true,
164
+ worktree: result.worktree,
165
+ };
166
+ }
167
+
168
+ // Named worktree - look it up from available sandboxes via OpenCode API
169
+ const worktrees = await listWorktrees(serverUrl, options);
170
+ const match = worktrees.find(w => w.includes(worktreeValue));
171
+ if (match) {
172
+ return { directory: match };
173
+ }
174
+
175
+ debug(`resolveWorktreeDirectory: worktree "${worktreeValue}" not found in available sandboxes`);
176
+
177
+ // Fallback to base directory
178
+ return {
179
+ directory: baseDir,
180
+ error: `Worktree "${worktreeValue}" not found in project sandboxes`,
181
+ };
182
+ }
@@ -525,7 +525,7 @@ describe('actions.js', () => {
525
525
  });
526
526
 
527
527
  describe('executeAction', () => {
528
- test('discovers server and includes --attach in dry run', async () => {
528
+ test('uses HTTP API when server is discovered (dry run)', async () => {
529
529
  const { executeAction } = await import('../../service/actions.js');
530
530
 
531
531
  const item = { number: 123, title: 'Fix bug' };
@@ -543,11 +543,13 @@ describe('actions.js', () => {
543
543
  });
544
544
 
545
545
  assert.ok(result.dryRun);
546
- assert.ok(result.command.includes('--attach'), 'Command should include --attach flag');
546
+ assert.strictEqual(result.method, 'api', 'Should use API method when server found');
547
+ assert.ok(result.command.includes('POST'), 'Command should show POST request');
547
548
  assert.ok(result.command.includes('http://localhost:4096'), 'Command should include server URL');
549
+ assert.ok(result.command.includes('directory='), 'Command should include directory param');
548
550
  });
549
551
 
550
- test('does not include --attach when no server discovered', async () => {
552
+ test('falls back to spawn when no server discovered (dry run)', async () => {
551
553
  const { executeAction } = await import('../../service/actions.js');
552
554
 
553
555
  const item = { number: 123, title: 'Fix bug' };
@@ -565,7 +567,252 @@ describe('actions.js', () => {
565
567
  });
566
568
 
567
569
  assert.ok(result.dryRun);
570
+ assert.strictEqual(result.method, 'spawn', 'Should use spawn method when no server');
568
571
  assert.ok(!result.command.includes('--attach'), 'Command should not include --attach flag');
572
+ assert.ok(result.command.includes('opencode run'), 'Command should include opencode run');
573
+ });
574
+
575
+ test('creates new worktree when worktree: "new" is configured (dry run)', async () => {
576
+ const { executeAction } = await import('../../service/actions.js');
577
+
578
+ const item = { number: 123, title: 'Fix bug' };
579
+ const config = {
580
+ path: tempDir,
581
+ prompt: 'default',
582
+ worktree: 'new',
583
+ worktree_name: 'feature-branch'
584
+ };
585
+
586
+ // Mock server discovery
587
+ const mockDiscoverServer = async () => 'http://localhost:4096';
588
+
589
+ // Mock worktree creation via fetch
590
+ const mockFetch = async (url, opts) => {
591
+ // Worktree creation endpoint
592
+ if (url === 'http://localhost:4096/experimental/worktree' && opts?.method === 'POST') {
593
+ const body = JSON.parse(opts.body);
594
+ assert.strictEqual(body.name, 'feature-branch', 'Should pass worktree name');
595
+ return {
596
+ ok: true,
597
+ json: async () => ({
598
+ name: 'feature-branch',
599
+ branch: 'opencode/feature-branch',
600
+ directory: '/data/worktree/proj123/feature-branch'
601
+ })
602
+ };
603
+ }
604
+ return { ok: false, text: async () => 'Not found' };
605
+ };
606
+
607
+ const result = await executeAction(item, config, {
608
+ dryRun: true,
609
+ discoverServer: mockDiscoverServer,
610
+ fetch: mockFetch
611
+ });
612
+
613
+ assert.ok(result.dryRun);
614
+ assert.strictEqual(result.method, 'api', 'Should use API method');
615
+ // The directory in the command should be the worktree directory
616
+ assert.ok(result.command.includes('/data/worktree/proj123/feature-branch'),
617
+ 'Should use worktree directory in command');
618
+ });
619
+
620
+ test('uses existing worktree by name (dry run)', async () => {
621
+ const { executeAction } = await import('../../service/actions.js');
622
+
623
+ const item = { number: 123, title: 'Fix bug' };
624
+ const config = {
625
+ path: tempDir,
626
+ prompt: 'default',
627
+ worktree: 'my-feature'
628
+ };
629
+
630
+ // Mock server discovery
631
+ const mockDiscoverServer = async () => 'http://localhost:4096';
632
+
633
+ // Mock worktree list lookup
634
+ const mockFetch = async (url) => {
635
+ if (url === 'http://localhost:4096/experimental/worktree') {
636
+ return {
637
+ ok: true,
638
+ json: async () => [
639
+ '/data/worktree/proj123/other-branch',
640
+ '/data/worktree/proj123/my-feature'
641
+ ]
642
+ };
643
+ }
644
+ return { ok: false, text: async () => 'Not found' };
645
+ };
646
+
647
+ const result = await executeAction(item, config, {
648
+ dryRun: true,
649
+ discoverServer: mockDiscoverServer,
650
+ fetch: mockFetch
651
+ });
652
+
653
+ assert.ok(result.dryRun);
654
+ assert.strictEqual(result.method, 'api', 'Should use API method');
655
+ assert.ok(result.command.includes('/data/worktree/proj123/my-feature'),
656
+ 'Should use looked up worktree path in command');
657
+ });
658
+
659
+ test('falls back to base directory when worktree creation fails (dry run)', async () => {
660
+ const { executeAction } = await import('../../service/actions.js');
661
+
662
+ const item = { number: 123, title: 'Fix bug' };
663
+ const config = {
664
+ path: tempDir,
665
+ prompt: 'default',
666
+ worktree: 'new'
667
+ };
668
+
669
+ // Mock server discovery
670
+ const mockDiscoverServer = async () => 'http://localhost:4096';
671
+
672
+ // Mock worktree creation failure
673
+ const mockFetch = async (url, opts) => {
674
+ if (url === 'http://localhost:4096/experimental/worktree' && opts?.method === 'POST') {
675
+ return {
676
+ ok: false,
677
+ status: 500,
678
+ text: async () => 'Internal server error'
679
+ };
680
+ }
681
+ return { ok: false, text: async () => 'Not found' };
682
+ };
683
+
684
+ const result = await executeAction(item, config, {
685
+ dryRun: true,
686
+ discoverServer: mockDiscoverServer,
687
+ fetch: mockFetch
688
+ });
689
+
690
+ assert.ok(result.dryRun);
691
+ assert.strictEqual(result.method, 'api', 'Should still use API method');
692
+ // Should fall back to base directory
693
+ assert.ok(result.command.includes(tempDir),
694
+ 'Should fall back to base directory when worktree creation fails');
695
+ });
696
+ });
697
+
698
+ describe('createSessionViaApi', () => {
699
+ test('creates session and sends message with directory param', async () => {
700
+ const { createSessionViaApi } = await import('../../service/actions.js');
701
+
702
+ const mockSessionId = 'ses_test123';
703
+ let createCalled = false;
704
+ let messageCalled = false;
705
+ let createUrl = null;
706
+ let messageUrl = null;
707
+
708
+ const mockFetch = async (url, opts) => {
709
+ const urlObj = new URL(url);
710
+
711
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
712
+ createCalled = true;
713
+ createUrl = url;
714
+ return {
715
+ ok: true,
716
+ json: async () => ({ id: mockSessionId }),
717
+ };
718
+ }
719
+
720
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
721
+ messageCalled = true;
722
+ messageUrl = url;
723
+ return {
724
+ ok: true,
725
+ json: async () => ({ success: true }),
726
+ };
727
+ }
728
+
729
+ return { ok: false, text: async () => 'Not found' };
730
+ };
731
+
732
+ const result = await createSessionViaApi(
733
+ 'http://localhost:4096',
734
+ '/path/to/project',
735
+ 'Fix the bug',
736
+ { fetch: mockFetch }
737
+ );
738
+
739
+ assert.ok(result.success, 'Should succeed');
740
+ assert.strictEqual(result.sessionId, mockSessionId, 'Should return session ID');
741
+ assert.ok(createCalled, 'Should call create session endpoint');
742
+ assert.ok(messageCalled, 'Should call message endpoint');
743
+ // URL encodes slashes as %2F
744
+ assert.ok(createUrl.includes('directory='), 'Create URL should include directory param');
745
+ assert.ok(createUrl.includes('%2Fpath%2Fto%2Fproject'), 'Create URL should include encoded directory path');
746
+ assert.ok(messageUrl.includes('directory='), 'Message URL should include directory param');
747
+ assert.ok(messageUrl.includes('%2Fpath%2Fto%2Fproject'), 'Message URL should include encoded directory path');
748
+ });
749
+
750
+ test('handles session creation failure', async () => {
751
+ const { createSessionViaApi } = await import('../../service/actions.js');
752
+
753
+ const mockFetch = async () => ({
754
+ ok: false,
755
+ status: 500,
756
+ text: async () => 'Internal server error',
757
+ });
758
+
759
+ const result = await createSessionViaApi(
760
+ 'http://localhost:4096',
761
+ '/path/to/project',
762
+ 'Fix the bug',
763
+ { fetch: mockFetch }
764
+ );
765
+
766
+ assert.ok(!result.success, 'Should fail');
767
+ assert.ok(result.error.includes('Failed to create session'), 'Should include error message');
768
+ });
769
+
770
+ test('passes agent and model options', async () => {
771
+ const { createSessionViaApi } = await import('../../service/actions.js');
772
+
773
+ let messageBody = null;
774
+
775
+ const mockFetch = async (url, opts) => {
776
+ const urlObj = new URL(url);
777
+
778
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
779
+ return {
780
+ ok: true,
781
+ json: async () => ({ id: 'ses_test' }),
782
+ };
783
+ }
784
+
785
+ if (urlObj.pathname.includes('/message')) {
786
+ messageBody = JSON.parse(opts.body);
787
+ return {
788
+ ok: true,
789
+ json: async () => ({ success: true }),
790
+ };
791
+ }
792
+
793
+ // PATCH for title update
794
+ if (opts?.method === 'PATCH') {
795
+ return { ok: true, json: async () => ({}) };
796
+ }
797
+
798
+ return { ok: false, text: async () => 'Not found' };
799
+ };
800
+
801
+ await createSessionViaApi(
802
+ 'http://localhost:4096',
803
+ '/path/to/project',
804
+ 'Fix the bug',
805
+ {
806
+ fetch: mockFetch,
807
+ agent: 'code',
808
+ model: 'anthropic/claude-sonnet-4-20250514',
809
+ title: 'Test Session',
810
+ }
811
+ );
812
+
813
+ assert.strictEqual(messageBody.agent, 'code', 'Should pass agent');
814
+ assert.strictEqual(messageBody.providerID, 'anthropic', 'Should parse provider from model');
815
+ assert.strictEqual(messageBody.modelID, 'claude-sonnet-4-20250514', 'Should parse model ID');
569
816
  });
570
817
  });
571
818
  });
@@ -0,0 +1,250 @@
1
+ import { describe, it, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import {
4
+ listWorktrees,
5
+ createWorktree,
6
+ getProjectInfo,
7
+ resolveWorktreeDirectory,
8
+ } from "../../service/worktree.js";
9
+
10
+ describe("worktree", () => {
11
+ describe("listWorktrees", () => {
12
+ it("returns worktrees from server", async () => {
13
+ const mockFetch = mock.fn(async () => ({
14
+ ok: true,
15
+ json: async () => ["/path/to/worktree1", "/path/to/worktree2"],
16
+ }));
17
+
18
+ const result = await listWorktrees("http://localhost:4096", { fetch: mockFetch });
19
+
20
+ assert.deepStrictEqual(result, ["/path/to/worktree1", "/path/to/worktree2"]);
21
+ assert.strictEqual(mockFetch.mock.calls.length, 1);
22
+ assert.strictEqual(mockFetch.mock.calls[0].arguments[0], "http://localhost:4096/experimental/worktree");
23
+ });
24
+
25
+ it("returns empty array on error", async () => {
26
+ const mockFetch = mock.fn(async () => ({
27
+ ok: false,
28
+ status: 500,
29
+ }));
30
+
31
+ const result = await listWorktrees("http://localhost:4096", { fetch: mockFetch });
32
+
33
+ assert.deepStrictEqual(result, []);
34
+ });
35
+
36
+ it("returns empty array on network error", async () => {
37
+ const mockFetch = mock.fn(async () => {
38
+ throw new Error("Network error");
39
+ });
40
+
41
+ const result = await listWorktrees("http://localhost:4096", { fetch: mockFetch });
42
+
43
+ assert.deepStrictEqual(result, []);
44
+ });
45
+ });
46
+
47
+ describe("createWorktree", () => {
48
+ it("creates a worktree successfully", async () => {
49
+ const worktreeResponse = {
50
+ name: "brave-falcon",
51
+ branch: "opencode/brave-falcon",
52
+ directory: "/data/worktree/abc123/brave-falcon",
53
+ };
54
+
55
+ const mockFetch = mock.fn(async () => ({
56
+ ok: true,
57
+ json: async () => worktreeResponse,
58
+ }));
59
+
60
+ const result = await createWorktree("http://localhost:4096", { fetch: mockFetch });
61
+
62
+ assert.strictEqual(result.success, true);
63
+ assert.deepStrictEqual(result.worktree, worktreeResponse);
64
+ });
65
+
66
+ it("passes name option to server", async () => {
67
+ const mockFetch = mock.fn(async (url, options) => ({
68
+ ok: true,
69
+ json: async () => ({
70
+ name: "my-feature",
71
+ branch: "opencode/my-feature",
72
+ directory: "/data/worktree/abc123/my-feature",
73
+ }),
74
+ }));
75
+
76
+ await createWorktree("http://localhost:4096", {
77
+ name: "my-feature",
78
+ fetch: mockFetch,
79
+ });
80
+
81
+ const body = JSON.parse(mockFetch.mock.calls[0].arguments[1].body);
82
+ assert.strictEqual(body.name, "my-feature");
83
+ });
84
+
85
+ it("returns error on failure", async () => {
86
+ const mockFetch = mock.fn(async () => ({
87
+ ok: false,
88
+ status: 400,
89
+ text: async () => "Invalid request",
90
+ }));
91
+
92
+ const result = await createWorktree("http://localhost:4096", { fetch: mockFetch });
93
+
94
+ assert.strictEqual(result.success, false);
95
+ assert.ok(result.error.includes("400"));
96
+ });
97
+ });
98
+
99
+ describe("getProjectInfo", () => {
100
+ it("returns project info", async () => {
101
+ const projectInfo = {
102
+ id: "abc123",
103
+ worktree: "/path/to/project",
104
+ sandboxes: ["/path/to/sandbox1", "/path/to/sandbox2"],
105
+ };
106
+
107
+ const mockFetch = mock.fn(async () => ({
108
+ ok: true,
109
+ json: async () => projectInfo,
110
+ }));
111
+
112
+ const result = await getProjectInfo("http://localhost:4096", { fetch: mockFetch });
113
+
114
+ assert.deepStrictEqual(result, projectInfo);
115
+ });
116
+
117
+ it("returns null on error", async () => {
118
+ const mockFetch = mock.fn(async () => ({
119
+ ok: false,
120
+ status: 404,
121
+ }));
122
+
123
+ const result = await getProjectInfo("http://localhost:4096", { fetch: mockFetch });
124
+
125
+ assert.strictEqual(result, null);
126
+ });
127
+ });
128
+
129
+ describe("resolveWorktreeDirectory", () => {
130
+ it("returns base directory when no worktree config", async () => {
131
+ const result = await resolveWorktreeDirectory(
132
+ "http://localhost:4096",
133
+ "/path/to/project",
134
+ {}
135
+ );
136
+
137
+ assert.strictEqual(result.directory, "/path/to/project");
138
+ });
139
+
140
+ it("returns base directory when worktree is undefined", async () => {
141
+ const result = await resolveWorktreeDirectory(
142
+ "http://localhost:4096",
143
+ "/path/to/project",
144
+ { worktree: undefined }
145
+ );
146
+
147
+ assert.strictEqual(result.directory, "/path/to/project");
148
+ });
149
+
150
+ it("creates new worktree when worktree is 'new'", async () => {
151
+ const mockFetch = mock.fn(async () => ({
152
+ ok: true,
153
+ json: async () => ({
154
+ name: "test-worktree",
155
+ branch: "opencode/test-worktree",
156
+ directory: "/data/worktree/abc123/test-worktree",
157
+ }),
158
+ }));
159
+
160
+ const result = await resolveWorktreeDirectory(
161
+ "http://localhost:4096",
162
+ "/path/to/project",
163
+ { worktree: "new" },
164
+ { fetch: mockFetch }
165
+ );
166
+
167
+ assert.strictEqual(result.directory, "/data/worktree/abc123/test-worktree");
168
+ assert.strictEqual(result.worktreeCreated, true);
169
+ });
170
+
171
+ it("passes worktreeName when creating new worktree", async () => {
172
+ const mockFetch = mock.fn(async () => ({
173
+ ok: true,
174
+ json: async () => ({
175
+ name: "my-feature",
176
+ branch: "opencode/my-feature",
177
+ directory: "/data/worktree/abc123/my-feature",
178
+ }),
179
+ }));
180
+
181
+ await resolveWorktreeDirectory(
182
+ "http://localhost:4096",
183
+ "/path/to/project",
184
+ { worktree: "new", worktreeName: "my-feature" },
185
+ { fetch: mockFetch }
186
+ );
187
+
188
+ const body = JSON.parse(mockFetch.mock.calls[0].arguments[1].body);
189
+ assert.strictEqual(body.name, "my-feature");
190
+ });
191
+
192
+ it("returns error when no server running", async () => {
193
+ const result = await resolveWorktreeDirectory(
194
+ null,
195
+ "/path/to/project",
196
+ { worktree: "new" }
197
+ );
198
+
199
+ assert.strictEqual(result.directory, "/path/to/project");
200
+ assert.ok(result.error.includes("no server"));
201
+ });
202
+
203
+ it("returns error when no server running for named worktree", async () => {
204
+ const result = await resolveWorktreeDirectory(
205
+ null,
206
+ "/path/to/project",
207
+ { worktree: "my-feature" }
208
+ );
209
+
210
+ assert.strictEqual(result.directory, "/path/to/project");
211
+ assert.ok(result.error.includes("no server"));
212
+ });
213
+
214
+ it("looks up named worktree from sandboxes", async () => {
215
+ const mockFetch = mock.fn(async () => ({
216
+ ok: true,
217
+ json: async () => [
218
+ "/data/worktree/abc123/brave-falcon",
219
+ "/data/worktree/abc123/my-feature",
220
+ ],
221
+ }));
222
+
223
+ const result = await resolveWorktreeDirectory(
224
+ "http://localhost:4096",
225
+ "/path/to/project",
226
+ { worktree: "my-feature" },
227
+ { fetch: mockFetch }
228
+ );
229
+
230
+ assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature");
231
+ });
232
+
233
+ it("returns error when named worktree not found", async () => {
234
+ const mockFetch = mock.fn(async () => ({
235
+ ok: true,
236
+ json: async () => ["/data/worktree/abc123/other-worktree"],
237
+ }));
238
+
239
+ const result = await resolveWorktreeDirectory(
240
+ "http://localhost:4096",
241
+ "/path/to/project",
242
+ { worktree: "nonexistent" },
243
+ { fetch: mockFetch }
244
+ );
245
+
246
+ assert.strictEqual(result.directory, "/path/to/project");
247
+ assert.ok(result.error.includes("not found"));
248
+ });
249
+ });
250
+ });