opencode-pilot 0.18.3 → 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/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.3",
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
 
@@ -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 };
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Integration tests for session and sandbox reuse
3
+ *
4
+ * These tests verify the actual API interactions work correctly.
5
+ * They use a mock server that simulates OpenCode's behavior.
6
+ */
7
+ import { describe, it, beforeEach, afterEach } from "node:test";
8
+ import assert from "node:assert";
9
+ import { createServer } from "node:http";
10
+
11
+ import {
12
+ listSessions,
13
+ findReusableSession,
14
+ isSessionArchived,
15
+ sendMessageToSession,
16
+ executeAction,
17
+ } from "../../service/actions.js";
18
+
19
+ import {
20
+ listWorktrees,
21
+ resolveWorktreeDirectory,
22
+ } from "../../service/worktree.js";
23
+
24
+ /**
25
+ * Create a mock OpenCode server for testing
26
+ */
27
+ function createMockServer(handlers = {}) {
28
+ const server = createServer((req, res) => {
29
+ const url = new URL(req.url, `http://${req.headers.host}`);
30
+ const path = url.pathname;
31
+ const method = req.method;
32
+ const directory = url.searchParams.get("directory");
33
+
34
+ // Collect request body
35
+ let body = "";
36
+ req.on("data", (chunk) => (body += chunk));
37
+ req.on("end", () => {
38
+ const request = {
39
+ method,
40
+ path,
41
+ directory,
42
+ query: Object.fromEntries(url.searchParams),
43
+ body: body ? JSON.parse(body) : null,
44
+ };
45
+
46
+ // Find matching handler
47
+ const handlerKey = `${method} ${path}`;
48
+ const handler = handlers[handlerKey] || handlers.default;
49
+
50
+ if (handler) {
51
+ const result = handler(request);
52
+ res.writeHead(result.status || 200, { "Content-Type": "application/json" });
53
+ res.end(JSON.stringify(result.body));
54
+ } else {
55
+ res.writeHead(404, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify({ error: "Not found" }));
57
+ }
58
+ });
59
+ });
60
+
61
+ return new Promise((resolve) => {
62
+ server.listen(0, "127.0.0.1", () => {
63
+ const { port } = server.address();
64
+ resolve({
65
+ url: `http://127.0.0.1:${port}`,
66
+ server,
67
+ close: () => new Promise((r) => server.close(r)),
68
+ });
69
+ });
70
+ });
71
+ }
72
+
73
+ describe("integration: session reuse", () => {
74
+ let mockServer;
75
+
76
+ afterEach(async () => {
77
+ if (mockServer) {
78
+ await mockServer.close();
79
+ mockServer = null;
80
+ }
81
+ });
82
+
83
+ it("listSessions passes directory parameter to server", async () => {
84
+ let receivedDirectory = null;
85
+
86
+ mockServer = await createMockServer({
87
+ "GET /session": (req) => {
88
+ receivedDirectory = req.directory;
89
+ return {
90
+ body: [
91
+ { id: "ses_1", directory: "/path/to/project", time: { created: 1000, updated: 2000 } },
92
+ ],
93
+ };
94
+ },
95
+ });
96
+
97
+ const sessions = await listSessions(mockServer.url, { directory: "/path/to/project" });
98
+
99
+ assert.strictEqual(sessions.length, 1);
100
+ assert.strictEqual(receivedDirectory, "/path/to/project", "Server should receive directory parameter");
101
+ });
102
+
103
+ it("findReusableSession filters out archived sessions", async () => {
104
+ mockServer = await createMockServer({
105
+ "GET /session": () => ({
106
+ body: [
107
+ { id: "ses_archived", directory: "/proj", time: { created: 1000, updated: 3000, archived: 4000 } },
108
+ { id: "ses_active", directory: "/proj", time: { created: 2000, updated: 2500 } },
109
+ ],
110
+ }),
111
+ "GET /session/status": () => ({ body: {} }),
112
+ });
113
+
114
+ const session = await findReusableSession(mockServer.url, "/proj");
115
+
116
+ assert.ok(session, "Should find a session");
117
+ assert.strictEqual(session.id, "ses_active", "Should return the active session, not archived");
118
+ });
119
+
120
+ it("findReusableSession prefers idle sessions over busy", async () => {
121
+ mockServer = await createMockServer({
122
+ "GET /session": () => ({
123
+ body: [
124
+ { id: "ses_busy", directory: "/proj", time: { created: 1000, updated: 3000 } },
125
+ { id: "ses_idle", directory: "/proj", time: { created: 2000, updated: 2000 } },
126
+ ],
127
+ }),
128
+ "GET /session/status": () => ({
129
+ body: { ses_busy: { type: "busy" } },
130
+ }),
131
+ });
132
+
133
+ const session = await findReusableSession(mockServer.url, "/proj");
134
+
135
+ assert.strictEqual(session.id, "ses_idle", "Should prefer idle session even with older update time");
136
+ });
137
+
138
+ it("sendMessageToSession updates title and posts message", async () => {
139
+ let titleUpdated = false;
140
+ let messagePosted = false;
141
+ let postedBody = null;
142
+
143
+ mockServer = await createMockServer({
144
+ "PATCH /session/ses_123": (req) => {
145
+ titleUpdated = req.body?.title === "New Title";
146
+ return { body: {} };
147
+ },
148
+ "POST /session/ses_123/message": (req) => {
149
+ messagePosted = true;
150
+ postedBody = req.body;
151
+ return { body: { success: true } };
152
+ },
153
+ });
154
+
155
+ const result = await sendMessageToSession(
156
+ mockServer.url,
157
+ "ses_123",
158
+ "/proj",
159
+ "Hello world",
160
+ { title: "New Title", agent: "plan" }
161
+ );
162
+
163
+ assert.ok(result.success);
164
+ assert.strictEqual(result.reused, true);
165
+ assert.ok(titleUpdated, "Should update session title");
166
+ assert.ok(messagePosted, "Should post message");
167
+ assert.strictEqual(postedBody.parts[0].text, "Hello world");
168
+ assert.strictEqual(postedBody.agent, "plan");
169
+ });
170
+
171
+ it("executeAction reuses existing session when available", async () => {
172
+ let sessionCreated = false;
173
+ let messageSessionId = null;
174
+
175
+ mockServer = await createMockServer({
176
+ "GET /session": () => ({
177
+ body: [{ id: "ses_existing", directory: "/proj", time: { created: 1000, updated: 2000 } }],
178
+ }),
179
+ "GET /session/status": () => ({ body: {} }),
180
+ "POST /session": () => {
181
+ sessionCreated = true;
182
+ return { body: { id: "ses_new" } };
183
+ },
184
+ "PATCH /session/ses_existing": () => ({ body: {} }),
185
+ "POST /session/ses_existing/message": (req) => {
186
+ messageSessionId = "ses_existing";
187
+ return { body: { success: true } };
188
+ },
189
+ "GET /project/current": () => ({
190
+ body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
191
+ }),
192
+ });
193
+
194
+ const result = await executeAction(
195
+ { number: 123, title: "Test issue" },
196
+ { path: "/proj", prompt: "default" },
197
+ { discoverServer: async () => mockServer.url }
198
+ );
199
+
200
+ assert.ok(result.success);
201
+ assert.strictEqual(result.sessionReused, true, "Should indicate session was reused");
202
+ assert.strictEqual(sessionCreated, false, "Should NOT create new session");
203
+ assert.strictEqual(messageSessionId, "ses_existing", "Should post to existing session");
204
+ });
205
+
206
+ it("executeAction creates new session when all existing are archived", async () => {
207
+ let sessionCreated = false;
208
+
209
+ mockServer = await createMockServer({
210
+ "GET /session": () => ({
211
+ body: [{ id: "ses_archived", directory: "/proj", time: { created: 1000, archived: 2000 } }],
212
+ }),
213
+ "GET /session/status": () => ({ body: {} }),
214
+ "POST /session": () => {
215
+ sessionCreated = true;
216
+ return { body: { id: "ses_new" } };
217
+ },
218
+ "PATCH /session/ses_new": () => ({ body: {} }),
219
+ "POST /session/ses_new/message": () => ({ body: { success: true } }),
220
+ "GET /project/current": () => ({
221
+ body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
222
+ }),
223
+ });
224
+
225
+ const result = await executeAction(
226
+ { number: 456, title: "Test" },
227
+ { path: "/proj", prompt: "default" },
228
+ { discoverServer: async () => mockServer.url }
229
+ );
230
+
231
+ assert.ok(result.success);
232
+ assert.strictEqual(result.sessionReused, undefined, "Should NOT indicate session was reused");
233
+ assert.ok(sessionCreated, "Should create new session when existing is archived");
234
+ });
235
+ });
236
+
237
+ describe("integration: sandbox reuse", () => {
238
+ let mockServer;
239
+
240
+ afterEach(async () => {
241
+ if (mockServer) {
242
+ await mockServer.close();
243
+ mockServer = null;
244
+ }
245
+ });
246
+
247
+ it("listWorktrees passes directory parameter to server", async () => {
248
+ let receivedDirectory = null;
249
+
250
+ mockServer = await createMockServer({
251
+ "GET /experimental/worktree": (req) => {
252
+ receivedDirectory = req.directory;
253
+ return { body: ["/worktree/branch-1"] };
254
+ },
255
+ });
256
+
257
+ const worktrees = await listWorktrees(mockServer.url, { directory: "/path/to/project" });
258
+
259
+ assert.strictEqual(worktrees.length, 1);
260
+ assert.strictEqual(receivedDirectory, "/path/to/project", "Server should receive directory parameter");
261
+ });
262
+
263
+ it("resolveWorktreeDirectory reuses existing sandbox when name matches", async () => {
264
+ let postCalled = false;
265
+ let listDirectory = null;
266
+
267
+ mockServer = await createMockServer({
268
+ "GET /experimental/worktree": (req) => {
269
+ listDirectory = req.directory;
270
+ return {
271
+ body: [
272
+ "/worktree/other-branch",
273
+ "/worktree/my-feature",
274
+ ],
275
+ };
276
+ },
277
+ "POST /experimental/worktree": () => {
278
+ postCalled = true;
279
+ return { body: { name: "my-feature", directory: "/worktree/my-feature-new" } };
280
+ },
281
+ });
282
+
283
+ const result = await resolveWorktreeDirectory(
284
+ mockServer.url,
285
+ "/path/to/project",
286
+ { worktree: "new", worktreeName: "my-feature" }
287
+ );
288
+
289
+ assert.strictEqual(result.directory, "/worktree/my-feature");
290
+ assert.strictEqual(result.worktreeReused, true);
291
+ assert.strictEqual(postCalled, false, "Should NOT create new worktree");
292
+ assert.strictEqual(listDirectory, "/path/to/project", "Should pass directory to list worktrees");
293
+ });
294
+
295
+ it("resolveWorktreeDirectory creates new sandbox when name doesn't match", async () => {
296
+ let postCalled = false;
297
+ let postDirectory = null;
298
+
299
+ mockServer = await createMockServer({
300
+ "GET /experimental/worktree": () => ({
301
+ body: ["/worktree/other-branch"],
302
+ }),
303
+ "POST /experimental/worktree": (req) => {
304
+ postCalled = true;
305
+ postDirectory = req.directory;
306
+ return {
307
+ body: {
308
+ name: "new-feature",
309
+ branch: "opencode/new-feature",
310
+ directory: "/worktree/new-feature",
311
+ },
312
+ };
313
+ },
314
+ });
315
+
316
+ const result = await resolveWorktreeDirectory(
317
+ mockServer.url,
318
+ "/path/to/project",
319
+ { worktree: "new", worktreeName: "new-feature" }
320
+ );
321
+
322
+ assert.strictEqual(result.directory, "/worktree/new-feature");
323
+ assert.strictEqual(result.worktreeCreated, true);
324
+ assert.ok(postCalled, "Should create new worktree");
325
+ assert.strictEqual(postDirectory, "/path/to/project", "Should pass directory to create worktree");
326
+ });
327
+
328
+ it("resolveWorktreeDirectory passes directory when looking up named worktree", async () => {
329
+ let listDirectory = null;
330
+
331
+ mockServer = await createMockServer({
332
+ "GET /experimental/worktree": (req) => {
333
+ listDirectory = req.directory;
334
+ return { body: ["/worktree/my-branch"] };
335
+ },
336
+ });
337
+
338
+ const result = await resolveWorktreeDirectory(
339
+ mockServer.url,
340
+ "/path/to/project",
341
+ { worktree: "my-branch" }
342
+ );
343
+
344
+ assert.strictEqual(result.directory, "/worktree/my-branch");
345
+ assert.strictEqual(listDirectory, "/path/to/project", "Should pass directory when looking up named worktree");
346
+ });
347
+ });
@@ -567,9 +567,9 @@ describe('actions.js', () => {
567
567
  // Mock server discovery
568
568
  const mockDiscoverServer = async () => 'http://localhost:4096';
569
569
 
570
- // Mock worktree list lookup
570
+ // Mock worktree list lookup - now includes directory param
571
571
  const mockFetch = async (url) => {
572
- if (url === 'http://localhost:4096/experimental/worktree') {
572
+ if (url.includes('/experimental/worktree')) {
573
573
  return {
574
574
  ok: true,
575
575
  json: async () => [
@@ -904,4 +904,297 @@ describe('actions.js', () => {
904
904
  assert.ok(result.warning.includes('Failed to send message'), 'Warning should mention message failure');
905
905
  });
906
906
  });
907
+
908
+ describe('session reuse', () => {
909
+ test('isSessionArchived returns true when time.archived is set', async () => {
910
+ const { isSessionArchived } = await import('../../service/actions.js');
911
+
912
+ // Archived session (time.archived is a timestamp)
913
+ const archivedSession = { id: 'ses_1', time: { created: 1000, updated: 2000, archived: 3000 } };
914
+ assert.strictEqual(isSessionArchived(archivedSession), true);
915
+
916
+ // Active session (no time.archived)
917
+ const activeSession = { id: 'ses_2', time: { created: 1000, updated: 2000 } };
918
+ assert.strictEqual(isSessionArchived(activeSession), false);
919
+
920
+ // Handle edge cases
921
+ assert.strictEqual(isSessionArchived(null), false);
922
+ assert.strictEqual(isSessionArchived({}), false);
923
+ assert.strictEqual(isSessionArchived({ time: {} }), false);
924
+ });
925
+
926
+ test('selectBestSession prefers idle sessions', async () => {
927
+ const { selectBestSession } = await import('../../service/actions.js');
928
+
929
+ const sessions = [
930
+ { id: 'ses_busy', time: { updated: 3000 } },
931
+ { id: 'ses_idle', time: { updated: 2000 } },
932
+ { id: 'ses_retry', time: { updated: 1000 } },
933
+ ];
934
+
935
+ const statuses = {
936
+ 'ses_busy': { type: 'busy' },
937
+ 'ses_retry': { type: 'retry', attempt: 1, message: 'error', next: 5000 },
938
+ // ses_idle not in statuses = idle
939
+ };
940
+
941
+ const best = selectBestSession(sessions, statuses);
942
+ assert.strictEqual(best.id, 'ses_idle', 'Should prefer idle session even with older updated time');
943
+ });
944
+
945
+ test('selectBestSession falls back to most recently updated when all busy', async () => {
946
+ const { selectBestSession } = await import('../../service/actions.js');
947
+
948
+ const sessions = [
949
+ { id: 'ses_1', time: { updated: 1000 } },
950
+ { id: 'ses_2', time: { updated: 3000 } }, // most recent
951
+ { id: 'ses_3', time: { updated: 2000 } },
952
+ ];
953
+
954
+ const statuses = {
955
+ 'ses_1': { type: 'busy' },
956
+ 'ses_2': { type: 'busy' },
957
+ 'ses_3': { type: 'busy' },
958
+ };
959
+
960
+ const best = selectBestSession(sessions, statuses);
961
+ assert.strictEqual(best.id, 'ses_2', 'Should select most recently updated when all busy');
962
+ });
963
+
964
+ test('selectBestSession returns null for empty array', async () => {
965
+ const { selectBestSession } = await import('../../service/actions.js');
966
+
967
+ assert.strictEqual(selectBestSession([], {}), null);
968
+ assert.strictEqual(selectBestSession(null, {}), null);
969
+ });
970
+
971
+ test('listSessions fetches sessions filtered by directory', async () => {
972
+ const { listSessions } = await import('../../service/actions.js');
973
+
974
+ let calledUrl = null;
975
+ const mockFetch = async (url) => {
976
+ calledUrl = url;
977
+ return {
978
+ ok: true,
979
+ json: async () => [
980
+ { id: 'ses_1', directory: '/path/to/project', time: { created: 1000 } },
981
+ ],
982
+ };
983
+ };
984
+
985
+ const sessions = await listSessions('http://localhost:4096', {
986
+ directory: '/path/to/project',
987
+ fetch: mockFetch
988
+ });
989
+
990
+ assert.ok(calledUrl.includes('directory='), 'Should include directory param');
991
+ assert.ok(calledUrl.includes('roots=true'), 'Should only get root sessions');
992
+ assert.strictEqual(sessions.length, 1);
993
+ });
994
+
995
+ test('findReusableSession filters out archived sessions', async () => {
996
+ const { findReusableSession } = await import('../../service/actions.js');
997
+
998
+ const mockFetch = async (url) => {
999
+ if (url.includes('/session/status')) {
1000
+ return { ok: true, json: async () => ({}) };
1001
+ }
1002
+ // GET /session
1003
+ return {
1004
+ ok: true,
1005
+ json: async () => [
1006
+ { id: 'ses_archived', directory: '/path', time: { created: 1000, updated: 3000, archived: 4000 } },
1007
+ { id: 'ses_active', directory: '/path', time: { created: 2000, updated: 2500 } },
1008
+ ],
1009
+ };
1010
+ };
1011
+
1012
+ const session = await findReusableSession('http://localhost:4096', '/path', { fetch: mockFetch });
1013
+
1014
+ assert.ok(session, 'Should find a session');
1015
+ assert.strictEqual(session.id, 'ses_active', 'Should return the active session, not archived');
1016
+ });
1017
+
1018
+ test('findReusableSession returns null when all sessions are archived', async () => {
1019
+ const { findReusableSession } = await import('../../service/actions.js');
1020
+
1021
+ const mockFetch = async (url) => {
1022
+ if (url.includes('/session/status')) {
1023
+ return { ok: true, json: async () => ({}) };
1024
+ }
1025
+ return {
1026
+ ok: true,
1027
+ json: async () => [
1028
+ { id: 'ses_1', directory: '/path', time: { created: 1000, archived: 2000 } },
1029
+ { id: 'ses_2', directory: '/path', time: { created: 1500, archived: 2500 } },
1030
+ ],
1031
+ };
1032
+ };
1033
+
1034
+ const session = await findReusableSession('http://localhost:4096', '/path', { fetch: mockFetch });
1035
+
1036
+ assert.strictEqual(session, null, 'Should return null when all sessions are archived');
1037
+ });
1038
+
1039
+ test('executeAction reuses existing session instead of creating new', async () => {
1040
+ const { executeAction } = await import('../../service/actions.js');
1041
+
1042
+ const item = { number: 123, title: 'Fix bug' };
1043
+ const config = {
1044
+ path: tempDir,
1045
+ prompt: 'default',
1046
+ };
1047
+
1048
+ let sessionCreated = false;
1049
+ let messagePosted = false;
1050
+ let messageSessionId = null;
1051
+
1052
+ const mockFetch = async (url, opts) => {
1053
+ // GET /session - return existing active session
1054
+ if (url.includes('/session') && !url.includes('/message') && !url.includes('/status') && (!opts || opts.method !== 'POST' && opts.method !== 'PATCH')) {
1055
+ return {
1056
+ ok: true,
1057
+ json: async () => [
1058
+ { id: 'ses_existing', directory: tempDir, time: { created: 1000, updated: 2000 } },
1059
+ ],
1060
+ };
1061
+ }
1062
+ // GET /session/status
1063
+ if (url.includes('/session/status')) {
1064
+ return { ok: true, json: async () => ({}) }; // session is idle
1065
+ }
1066
+ // POST /session (create) - should NOT be called
1067
+ if (url.endsWith('/session') && opts?.method === 'POST') {
1068
+ sessionCreated = true;
1069
+ return { ok: true, json: async () => ({ id: 'ses_new' }) };
1070
+ }
1071
+ // PATCH /session/:id (update title)
1072
+ if (opts?.method === 'PATCH') {
1073
+ return { ok: true, json: async () => ({}) };
1074
+ }
1075
+ // POST /session/:id/message
1076
+ if (url.includes('/message') && opts?.method === 'POST') {
1077
+ messagePosted = true;
1078
+ messageSessionId = url.match(/session\/([^/]+)\/message/)?.[1];
1079
+ return { ok: true, json: async () => ({ success: true }) };
1080
+ }
1081
+ return { ok: false, text: async () => 'Not found' };
1082
+ };
1083
+
1084
+ const mockDiscoverServer = async () => 'http://localhost:4096';
1085
+
1086
+ const result = await executeAction(item, config, {
1087
+ discoverServer: mockDiscoverServer,
1088
+ fetch: mockFetch
1089
+ });
1090
+
1091
+ assert.ok(result.success, 'Should succeed');
1092
+ assert.strictEqual(result.sessionId, 'ses_existing', 'Should use existing session ID');
1093
+ assert.strictEqual(result.sessionReused, true, 'Should indicate session was reused');
1094
+ assert.strictEqual(sessionCreated, false, 'Should NOT create a new session');
1095
+ assert.strictEqual(messagePosted, true, 'Should post message to existing session');
1096
+ assert.strictEqual(messageSessionId, 'ses_existing', 'Should post to the existing session');
1097
+ });
1098
+
1099
+ test('executeAction creates new session when existing is archived', async () => {
1100
+ const { executeAction } = await import('../../service/actions.js');
1101
+
1102
+ const item = { number: 456, title: 'New feature' };
1103
+ const config = {
1104
+ path: tempDir,
1105
+ prompt: 'default',
1106
+ };
1107
+
1108
+ let sessionCreated = false;
1109
+
1110
+ const mockFetch = async (url, opts) => {
1111
+ // GET /session - return only archived session
1112
+ if (url.includes('/session') && !url.includes('/message') && !url.includes('/status') && (!opts || opts.method !== 'POST' && opts.method !== 'PATCH')) {
1113
+ return {
1114
+ ok: true,
1115
+ json: async () => [
1116
+ { id: 'ses_archived', directory: tempDir, time: { created: 1000, updated: 2000, archived: 3000 } },
1117
+ ],
1118
+ };
1119
+ }
1120
+ // GET /session/status
1121
+ if (url.includes('/session/status')) {
1122
+ return { ok: true, json: async () => ({}) };
1123
+ }
1124
+ // POST /session (create) - should be called since archived session can't be reused
1125
+ if (url.includes('/session') && !url.includes('/message') && opts?.method === 'POST') {
1126
+ sessionCreated = true;
1127
+ return { ok: true, json: async () => ({ id: 'ses_new' }) };
1128
+ }
1129
+ // PATCH /session/:id
1130
+ if (opts?.method === 'PATCH') {
1131
+ return { ok: true, json: async () => ({}) };
1132
+ }
1133
+ // POST /session/:id/message
1134
+ if (url.includes('/message') && opts?.method === 'POST') {
1135
+ return { ok: true, json: async () => ({ success: true }) };
1136
+ }
1137
+ return { ok: false, text: async () => 'Not found' };
1138
+ };
1139
+
1140
+ const mockDiscoverServer = async () => 'http://localhost:4096';
1141
+
1142
+ const result = await executeAction(item, config, {
1143
+ discoverServer: mockDiscoverServer,
1144
+ fetch: mockFetch
1145
+ });
1146
+
1147
+ assert.ok(result.success, 'Should succeed');
1148
+ assert.strictEqual(result.sessionId, 'ses_new', 'Should create new session');
1149
+ assert.strictEqual(result.sessionReused, undefined, 'Should NOT indicate session was reused');
1150
+ assert.strictEqual(sessionCreated, true, 'Should create a new session when existing is archived');
1151
+ });
1152
+
1153
+ test('executeAction skips session reuse when reuse_active_session is false', async () => {
1154
+ const { executeAction } = await import('../../service/actions.js');
1155
+
1156
+ const item = { number: 789, title: 'Forced new' };
1157
+ const config = {
1158
+ path: tempDir,
1159
+ prompt: 'default',
1160
+ reuse_active_session: false, // disable reuse
1161
+ };
1162
+
1163
+ let sessionListCalled = false;
1164
+ let sessionCreated = false;
1165
+
1166
+ const mockFetch = async (url, opts) => {
1167
+ // GET /session - should NOT be called
1168
+ if (url.includes('/session') && !url.includes('/message') && !url.includes('/status') && (!opts || opts.method !== 'POST' && opts.method !== 'PATCH')) {
1169
+ sessionListCalled = true;
1170
+ return { ok: true, json: async () => [] };
1171
+ }
1172
+ // POST /session
1173
+ if (url.includes('/session') && !url.includes('/message') && opts?.method === 'POST') {
1174
+ sessionCreated = true;
1175
+ return { ok: true, json: async () => ({ id: 'ses_forced_new' }) };
1176
+ }
1177
+ // PATCH
1178
+ if (opts?.method === 'PATCH') {
1179
+ return { ok: true, json: async () => ({}) };
1180
+ }
1181
+ // POST message
1182
+ if (url.includes('/message') && opts?.method === 'POST') {
1183
+ return { ok: true, json: async () => ({ success: true }) };
1184
+ }
1185
+ return { ok: false, text: async () => 'Not found' };
1186
+ };
1187
+
1188
+ const mockDiscoverServer = async () => 'http://localhost:4096';
1189
+
1190
+ const result = await executeAction(item, config, {
1191
+ discoverServer: mockDiscoverServer,
1192
+ fetch: mockFetch
1193
+ });
1194
+
1195
+ assert.ok(result.success, 'Should succeed');
1196
+ assert.strictEqual(sessionListCalled, false, 'Should NOT list sessions when reuse disabled');
1197
+ assert.strictEqual(sessionCreated, true, 'Should create new session directly');
1198
+ });
1199
+ });
907
1200
  });
@@ -22,6 +22,22 @@ describe("worktree", () => {
22
22
  assert.strictEqual(mockFetch.mock.calls[0].arguments[0], "http://localhost:4096/experimental/worktree");
23
23
  });
24
24
 
25
+ it("passes directory parameter when provided", async () => {
26
+ const mockFetch = mock.fn(async () => ({
27
+ ok: true,
28
+ json: async () => ["/path/to/worktree"],
29
+ }));
30
+
31
+ await listWorktrees("http://localhost:4096", {
32
+ fetch: mockFetch,
33
+ directory: "/path/to/project"
34
+ });
35
+
36
+ const calledUrl = mockFetch.mock.calls[0].arguments[0];
37
+ assert.ok(calledUrl.includes("directory="), "Should include directory param");
38
+ assert.ok(calledUrl.includes(encodeURIComponent("/path/to/project")), "Should encode directory path");
39
+ });
40
+
25
41
  it("returns empty array on error", async () => {
26
42
  const mockFetch = mock.fn(async () => ({
27
43
  ok: false,
@@ -189,14 +205,24 @@ describe("worktree", () => {
189
205
  });
190
206
 
191
207
  it("passes worktreeName when creating new worktree", async () => {
192
- const mockFetch = mock.fn(async () => ({
193
- ok: true,
194
- json: async () => ({
195
- name: "my-feature",
196
- branch: "opencode/my-feature",
197
- directory: "/data/worktree/abc123/my-feature",
198
- }),
199
- }));
208
+ const mockFetch = mock.fn(async (url, opts) => {
209
+ // First call: GET /experimental/worktree (list) - return empty to trigger creation
210
+ if (!opts || opts.method !== 'POST') {
211
+ return {
212
+ ok: true,
213
+ json: async () => [],
214
+ };
215
+ }
216
+ // Second call: POST /experimental/worktree (create)
217
+ return {
218
+ ok: true,
219
+ json: async () => ({
220
+ name: "my-feature",
221
+ branch: "opencode/my-feature",
222
+ directory: "/data/worktree/abc123/my-feature",
223
+ }),
224
+ };
225
+ });
200
226
 
201
227
  await resolveWorktreeDirectory(
202
228
  "http://localhost:4096",
@@ -205,7 +231,11 @@ describe("worktree", () => {
205
231
  { fetch: mockFetch }
206
232
  );
207
233
 
208
- const body = JSON.parse(mockFetch.mock.calls[0].arguments[1].body);
234
+ // Should have called both list (GET) and create (POST)
235
+ assert.strictEqual(mockFetch.mock.calls.length, 2);
236
+ // Second call should be POST with the worktree name
237
+ const postCall = mockFetch.mock.calls[1];
238
+ const body = JSON.parse(postCall.arguments[1].body);
209
239
  assert.strictEqual(body.name, "my-feature");
210
240
  });
211
241
 
@@ -250,6 +280,117 @@ describe("worktree", () => {
250
280
  assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature");
251
281
  });
252
282
 
283
+ it("reuses existing sandbox when worktree='new' and worktreeName matches", async () => {
284
+ let postCalled = false;
285
+ let listUrl = null;
286
+ const mockFetch = mock.fn(async (url, opts) => {
287
+ // GET /experimental/worktree (list) - returns existing sandbox with matching name
288
+ if (!opts || opts.method !== 'POST') {
289
+ listUrl = url;
290
+ return {
291
+ ok: true,
292
+ json: async () => [
293
+ "/data/worktree/abc123/other-branch",
294
+ "/data/worktree/abc123/my-feature", // matches worktreeName
295
+ ],
296
+ };
297
+ }
298
+ // POST /experimental/worktree (create) - should NOT be called
299
+ postCalled = true;
300
+ return { ok: true, json: async () => ({}) };
301
+ });
302
+
303
+ const result = await resolveWorktreeDirectory(
304
+ "http://localhost:4096",
305
+ "/path/to/project",
306
+ { worktree: "new", worktreeName: "my-feature" },
307
+ { fetch: mockFetch }
308
+ );
309
+
310
+ // Should reuse existing sandbox
311
+ assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature");
312
+ assert.strictEqual(result.worktreeReused, true);
313
+ assert.strictEqual(result.worktreeCreated, undefined);
314
+ // POST should NOT have been called
315
+ assert.strictEqual(postCalled, false, "Should not call POST when reusing existing sandbox");
316
+ // listWorktrees should be called with directory parameter
317
+ assert.ok(listUrl.includes("directory="), "Should pass directory to listWorktrees");
318
+ assert.ok(listUrl.includes(encodeURIComponent("/path/to/project")), "Should pass correct project directory");
319
+ });
320
+
321
+ it("creates new sandbox when worktree='new' and no matching name exists", async () => {
322
+ let getCalled = false;
323
+ let postCalled = false;
324
+ const mockFetch = mock.fn(async (url, opts) => {
325
+ // GET /experimental/worktree (list) - no matching name
326
+ if (!opts || opts.method !== 'POST') {
327
+ getCalled = true;
328
+ return {
329
+ ok: true,
330
+ json: async () => ["/data/worktree/abc123/other-branch"],
331
+ };
332
+ }
333
+ // POST /experimental/worktree (create)
334
+ postCalled = true;
335
+ return {
336
+ ok: true,
337
+ json: async () => ({
338
+ name: "new-feature",
339
+ branch: "opencode/new-feature",
340
+ directory: "/data/worktree/abc123/new-feature",
341
+ }),
342
+ };
343
+ });
344
+
345
+ const result = await resolveWorktreeDirectory(
346
+ "http://localhost:4096",
347
+ "/path/to/project",
348
+ { worktree: "new", worktreeName: "new-feature" },
349
+ { fetch: mockFetch }
350
+ );
351
+
352
+ // Should create new sandbox
353
+ assert.strictEqual(result.directory, "/data/worktree/abc123/new-feature");
354
+ assert.strictEqual(result.worktreeCreated, true);
355
+ assert.strictEqual(result.worktreeReused, undefined);
356
+ // Both GET and POST should have been called
357
+ assert.strictEqual(getCalled, true, "Should call GET to list existing worktrees");
358
+ assert.strictEqual(postCalled, true, "Should call POST to create new worktree");
359
+ });
360
+
361
+ it("skips sandbox reuse when preferExistingSandbox is false", async () => {
362
+ let getCalled = false;
363
+ const mockFetch = mock.fn(async (url, opts) => {
364
+ // GET /experimental/worktree - should NOT be called
365
+ if (!opts || opts.method !== 'POST') {
366
+ getCalled = true;
367
+ return { ok: true, json: async () => ["/data/worktree/abc123/my-feature"] };
368
+ }
369
+ // POST /experimental/worktree (create)
370
+ return {
371
+ ok: true,
372
+ json: async () => ({
373
+ name: "my-feature",
374
+ branch: "opencode/my-feature",
375
+ directory: "/data/worktree/abc123/my-feature-new",
376
+ }),
377
+ };
378
+ });
379
+
380
+ const result = await resolveWorktreeDirectory(
381
+ "http://localhost:4096",
382
+ "/path/to/project",
383
+ { worktree: "new", worktreeName: "my-feature", preferExistingSandbox: false },
384
+ { fetch: mockFetch }
385
+ );
386
+
387
+ // Should create new sandbox, not reuse
388
+ assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature-new");
389
+ assert.strictEqual(result.worktreeCreated, true);
390
+ // GET should NOT have been called (skipped reuse check)
391
+ assert.strictEqual(getCalled, false, "Should skip GET when preferExistingSandbox is false");
392
+ });
393
+
253
394
  it("returns error when named worktree not found", async () => {
254
395
  const mockFetch = mock.fn(async () => ({
255
396
  ok: true,