opencode-pilot 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,12 +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
- None currently! Previous issues have been resolved:
98
-
99
- - ~~Sessions attached to global server run in wrong directory~~ - Fixed in v0.14.0 by using the HTTP API with `?directory=` parameter instead of `opencode run --attach`
100
-
101
116
  ## Related
102
117
 
103
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.1",
3
+ "version": "0.15.1",
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
 
@@ -560,15 +561,40 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
560
561
  * @returns {Promise<object>} Result with command, stdout, stderr, exitCode
561
562
  */
562
563
  export async function executeAction(item, config, options = {}) {
563
- // Get working directory first to determine which server to attach to
564
+ // Get base working directory first to determine which server to attach to
564
565
  const workingDir = config.working_dir || config.path || config.repo_path || "~";
565
- const cwd = expandPath(workingDir);
566
+ const baseCwd = expandPath(workingDir);
566
567
 
567
568
  // Discover running opencode server for this directory
568
569
  const discoverFn = options.discoverServer || discoverOpencodeServer;
569
- const serverUrl = await discoverFn(cwd);
570
+ const serverUrl = await discoverFn(baseCwd);
571
+
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
+ }
570
596
 
571
- debug(`executeAction: discovered server=${serverUrl} for cwd=${cwd}`);
597
+ debug(`executeAction: using cwd=${cwd}`);
572
598
 
573
599
  // If a server is running, use the HTTP API to create the session
574
600
  // This is a workaround for the known issue where --attach doesn't support --dir
@@ -22,11 +22,12 @@ const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
22
22
 
23
23
  /**
24
24
  * Check if a source has tool configuration
25
+ * @see getToolConfig in poller.js for actual tool config resolution
25
26
  * @param {object} source - Source configuration
26
- * @returns {boolean} True if source has tool.mcp and tool.name
27
+ * @returns {boolean} True if source has tool.command or (tool.mcp and tool.name)
27
28
  */
28
29
  export function hasToolConfig(source) {
29
- return !!(source.tool && source.tool.mcp && source.tool.name);
30
+ return !!(source.tool && (source.tool.command || (source.tool.mcp && source.tool.name)));
30
31
  }
31
32
 
32
33
  /**
@@ -113,7 +114,7 @@ export async function pollOnce(options = {}) {
113
114
  const sourceName = source.name || 'unknown';
114
115
 
115
116
  if (!hasToolConfig(source)) {
116
- console.error(`[poll] Source '${sourceName}' missing tool configuration (requires tool.mcp and tool.name)`);
117
+ console.error(`[poll] Source '${sourceName}' missing tool configuration (requires tool.command or tool.mcp and tool.name)`);
117
118
  continue;
118
119
  }
119
120
 
@@ -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
+ }
@@ -571,6 +571,128 @@ describe('actions.js', () => {
571
571
  assert.ok(!result.command.includes('--attach'), 'Command should not include --attach flag');
572
572
  assert.ok(result.command.includes('opencode run'), 'Command should include opencode run');
573
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
+ });
574
696
  });
575
697
 
576
698
  describe('createSessionViaApi', () => {
@@ -50,23 +50,39 @@ sources:
50
50
  test('hasToolConfig validates source configuration', async () => {
51
51
  const { hasToolConfig } = await import('../../service/poll-service.js');
52
52
 
53
- // Valid config
54
- const valid = {
53
+ // Valid MCP config
54
+ const validMcp = {
55
55
  name: 'test',
56
56
  tool: { mcp: 'github', name: 'search_issues' },
57
57
  args: {}
58
58
  };
59
- assert.strictEqual(hasToolConfig(valid), true);
59
+ assert.strictEqual(hasToolConfig(validMcp), true);
60
+
61
+ // Valid CLI command config
62
+ const validCli = {
63
+ name: 'test',
64
+ tool: { command: ['gh', 'search', 'issues'] },
65
+ args: {}
66
+ };
67
+ assert.strictEqual(hasToolConfig(validCli), true);
68
+
69
+ // Valid CLI command config (string form)
70
+ const validCliString = {
71
+ name: 'test',
72
+ tool: { command: 'gh search issues' },
73
+ args: {}
74
+ };
75
+ assert.strictEqual(hasToolConfig(validCliString), true);
60
76
 
61
77
  // Missing tool
62
78
  const missingTool = { name: 'test' };
63
79
  assert.strictEqual(hasToolConfig(missingTool), false);
64
80
 
65
- // Missing mcp
81
+ // Missing mcp (and no command)
66
82
  const missingMcp = { name: 'test', tool: { name: 'search_issues' } };
67
83
  assert.strictEqual(hasToolConfig(missingMcp), false);
68
84
 
69
- // Missing tool.name
85
+ // Missing tool.name (and no command)
70
86
  const missingName = { name: 'test', tool: { mcp: 'github' } };
71
87
  assert.strictEqual(hasToolConfig(missingName), false);
72
88
  });
@@ -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
+ });