opencode-pilot 0.24.6 → 0.24.8

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.
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.5.tar.gz"
5
- sha256 "6a191feaa9f7ec7421272a0c9533ebbd734a45483ca7116135b62e4ba11e780d"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.7.tar.gz"
5
+ sha256 "3d97d488ca8695f9785171e767b8a7a3eb65d2a8c5aca9eab3862b70cf6fc144"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.24.6",
3
+ "version": "0.24.8",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -639,17 +639,26 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
639
639
  * @param {string} directory - Working directory to match
640
640
  * @param {object} [options] - Options
641
641
  * @param {function} [options.fetch] - Custom fetch function (for testing)
642
+ * @param {string} [options.projectDirectory] - Project directory for session lookup
643
+ * (used instead of directory when provided, so sessions created with the project
644
+ * directory are found instead of old worktree-scoped sessions)
642
645
  * @returns {Promise<object|null>} Session to reuse, or null
643
646
  */
644
647
  export async function findReusableSession(serverUrl, directory, options = {}) {
648
+ // Use project directory for session lookup when available.
649
+ // Sessions created with v0.24.7+ are scoped to the project directory,
650
+ // while messages use the worktree directory. Query the project directory
651
+ // to find correctly-scoped sessions.
652
+ const lookupDirectory = options.projectDirectory || directory;
653
+
645
654
  // Get sessions for this directory
646
655
  const sessions = await listSessions(serverUrl, {
647
- directory,
656
+ directory: lookupDirectory,
648
657
  fetch: options.fetch
649
658
  });
650
659
 
651
660
  if (sessions.length === 0) {
652
- debug(`findReusableSession: no sessions found for ${directory}`);
661
+ debug(`findReusableSession: no sessions found for ${lookupDirectory}`);
653
662
  return null;
654
663
  }
655
664
 
@@ -657,11 +666,11 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
657
666
  const activeSessions = sessions.filter(s => !isSessionArchived(s));
658
667
 
659
668
  if (activeSessions.length === 0) {
660
- debug(`findReusableSession: all ${sessions.length} sessions are archived for ${directory}`);
669
+ debug(`findReusableSession: all ${sessions.length} sessions are archived for ${lookupDirectory}`);
661
670
  return null;
662
671
  }
663
672
 
664
- debug(`findReusableSession: found ${activeSessions.length} active sessions for ${directory}`);
673
+ debug(`findReusableSession: found ${activeSessions.length} active sessions for ${lookupDirectory}`);
665
674
 
666
675
  // Get statuses to prefer idle sessions
667
676
  const statuses = await getSessionStatuses(serverUrl, { fetch: options.fetch });
@@ -673,14 +682,11 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
673
682
  /**
674
683
  * Create a session via the OpenCode HTTP API
675
684
  *
676
- * This is a workaround for the known issue where `opencode run --attach`
677
- * doesn't support a --dir flag, causing sessions to run in the wrong directory
678
- * when attached to a global server.
679
- *
680
685
  * @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
681
- * @param {string} directory - Working directory for the session
686
+ * @param {string} directory - Working directory for file operations (may be a worktree)
682
687
  * @param {string} prompt - The prompt/message to send
683
688
  * @param {object} [options] - Options
689
+ * @param {string} [options.projectDirectory] - Project directory for session scoping (defaults to directory)
684
690
  * @param {string} [options.title] - Session title
685
691
  * @param {string} [options.agent] - Agent to use
686
692
  * @param {string} [options.model] - Model to use
@@ -690,13 +696,16 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
690
696
  export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
691
697
  const fetchFn = options.fetch || fetch;
692
698
  const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
699
+ // Use project directory for session creation (determines projectID in OpenCode).
700
+ // The working directory (which may be a worktree) is used for messages/commands.
701
+ const projectDir = options.projectDirectory || directory;
693
702
 
694
703
  let session = null;
695
704
 
696
705
  try {
697
- // Step 1: Create a new session with the directory parameter
706
+ // Step 1: Create session scoped to the project directory
698
707
  const sessionUrl = new URL('/session', serverUrl);
699
- sessionUrl.searchParams.set('directory', directory);
708
+ sessionUrl.searchParams.set('directory', projectDir);
700
709
 
701
710
  const createResponse = await fetchFn(sessionUrl.toString(), {
702
711
  method: 'POST',
@@ -715,7 +724,7 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
715
724
  // Step 2: Update session title if provided
716
725
  if (options.title) {
717
726
  const updateUrl = new URL(`/session/${session.id}`, serverUrl);
718
- updateUrl.searchParams.set('directory', directory);
727
+ updateUrl.searchParams.set('directory', projectDir);
719
728
  await fetchFn(updateUrl.toString(), {
720
729
  method: 'PATCH',
721
730
  headers: { 'Content-Type': 'application/json' },
@@ -842,7 +851,7 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
842
851
  * @param {object} [options] - Execution options
843
852
  * @returns {Promise<object>} Result with command, success, sessionId, etc.
844
853
  */
845
- async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
854
+ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, projectDirectory = null) {
846
855
  // Build prompt from template
847
856
  const prompt = buildPromptFromTemplate(config.prompt || "default", item);
848
857
 
@@ -886,7 +895,10 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
886
895
  const reuseActiveSession = config.reuse_active_session !== false; // default true
887
896
 
888
897
  if (reuseActiveSession && !options.dryRun) {
889
- const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
898
+ const existingSession = await findReusableSession(serverUrl, cwd, {
899
+ fetch: options.fetch,
900
+ projectDirectory: projectDirectory,
901
+ });
890
902
 
891
903
  if (existingSession) {
892
904
  debug(`executeInDirectory: found reusable session ${existingSession.id} for ${cwd}`);
@@ -923,6 +935,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
923
935
  }
924
936
 
925
937
  const result = await createSessionViaApi(serverUrl, cwd, prompt, {
938
+ projectDirectory: projectDirectory || cwd,
926
939
  title: sessionTitle,
927
940
  agent: config.agent,
928
941
  model: config.model,
@@ -986,7 +999,7 @@ export async function executeAction(item, config, options = {}) {
986
999
  if (config.existing_directory) {
987
1000
  debug(`executeAction: using existing_directory=${config.existing_directory}`);
988
1001
  const cwd = expandPath(config.existing_directory);
989
- return await executeInDirectory(serverUrl, cwd, item, config, options);
1002
+ return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
990
1003
  }
991
1004
 
992
1005
  // Resolve worktree directory if configured
@@ -1038,5 +1051,5 @@ export async function executeAction(item, config, options = {}) {
1038
1051
 
1039
1052
  debug(`executeAction: using cwd=${cwd}`);
1040
1053
 
1041
- return await executeInDirectory(serverUrl, cwd, item, config, options);
1054
+ return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1042
1055
  }
@@ -564,7 +564,9 @@ describe("integration: worktree creation with worktree_name", () => {
564
564
  assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
565
565
  assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
566
566
  assert.ok(sessionCreated, "Should create session");
567
- assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be in worktree directory");
567
+ // Session creation uses the project directory (for correct projectID scoping)
568
+ // The worktree path is used for messages/commands (file operations)
569
+ assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project directory");
568
570
  });
569
571
 
570
572
  it("reuses stored directory when reprocessing same item", async () => {
@@ -616,8 +618,73 @@ describe("integration: worktree creation with worktree_name", () => {
616
618
  assert.ok(result.success, "Action should succeed");
617
619
  // Should NOT create a new worktree since we have existing_directory
618
620
  assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
619
- // Session should be created in the existing directory
620
- assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should use existing directory");
621
+ // Session creation uses the project directory (for correct projectID scoping)
622
+ assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project directory");
623
+ });
624
+
625
+ it("session reuse queries by project directory, not worktree directory", async () => {
626
+ // This tests the key fix: when reprocessing an item with a worktree,
627
+ // findReusableSession should query by the project directory (e.g., /proj)
628
+ // so it finds sessions created with correct scoping (v0.24.7+), rather
629
+ // than finding old sessions created with the worktree directory (projectID "global").
630
+
631
+ let sessionQueryDirectory = null;
632
+ let sessionCreated = false;
633
+
634
+ const existingWorktreeDir = "/worktree/calm-wizard";
635
+
636
+ mockServer = await createMockServer({
637
+ "GET /project": () => ({
638
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
639
+ }),
640
+ "GET /project/current": () => ({
641
+ body: { id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } },
642
+ }),
643
+ "GET /experimental/worktree": () => ({
644
+ body: [existingWorktreeDir],
645
+ }),
646
+ "GET /session": (req) => {
647
+ sessionQueryDirectory = req.directory;
648
+ // Return a session ONLY if queried by project directory
649
+ if (req.directory === "/proj") {
650
+ return {
651
+ body: [{ id: "ses_proj_scoped", directory: "/proj", time: { created: 1000, updated: 2000 } }],
652
+ };
653
+ }
654
+ // Old worktree-scoped sessions should NOT be found when querying by project dir
655
+ return { body: [] };
656
+ },
657
+ "GET /session/status": () => ({ body: {} }),
658
+ "POST /session": (req) => {
659
+ sessionCreated = true;
660
+ return { body: { id: "ses_new" } };
661
+ },
662
+ "PATCH /session/ses_proj_scoped": () => ({ body: {} }),
663
+ "POST /session/ses_proj_scoped/message": () => ({ body: { success: true } }),
664
+ "POST /session/ses_proj_scoped/command": () => ({ body: { success: true } }),
665
+ "PATCH /session/ses_new": () => ({ body: {} }),
666
+ "POST /session/ses_new/message": () => ({ body: { success: true } }),
667
+ "POST /session/ses_new/command": () => ({ body: { success: true } }),
668
+ });
669
+
670
+ const result = await executeAction(
671
+ { number: 42, title: "Review PR" },
672
+ {
673
+ path: "/proj",
674
+ prompt: "review",
675
+ worktree_name: "pr-{number}",
676
+ existing_directory: existingWorktreeDir,
677
+ },
678
+ { discoverServer: async () => mockServer.url }
679
+ );
680
+
681
+ assert.ok(result.success, "Action should succeed");
682
+ // The session lookup should use the PROJECT directory, not the worktree directory
683
+ assert.strictEqual(sessionQueryDirectory, "/proj",
684
+ "findReusableSession should query by project directory, not worktree");
685
+ // Should reuse the project-scoped session, NOT create a new one
686
+ assert.strictEqual(result.sessionReused, true, "Should reuse the project-scoped session");
687
+ assert.strictEqual(sessionCreated, false, "Should NOT create a new session when project-scoped session exists");
621
688
  });
622
689
  });
623
690
 
@@ -981,11 +981,12 @@ Check for bugs and security issues.`;
981
981
  // Should NOT call worktree endpoints when existing_directory is provided
982
982
  assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
983
983
  assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
984
- // Should use the existing directory
985
- assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
986
- 'Should use existing_directory for session');
984
+ // Session creation uses project directory (for correct projectID scoping)
985
+ assert.strictEqual(sessionDirectory, '/data/proj',
986
+ 'Session creation should use project directory, not worktree');
987
+ // Result directory is the worktree (where file operations happen)
987
988
  assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
988
- 'Result should include directory');
989
+ 'Result should include worktree directory');
989
990
  });
990
991
  });
991
992
 
@@ -1041,6 +1042,52 @@ Check for bugs and security issues.`;
1041
1042
  assert.ok(messageUrl.includes('%2Fpath%2Fto%2Fproject'), 'Message URL should include encoded directory path');
1042
1043
  });
1043
1044
 
1045
+ test('uses projectDirectory for session creation, working directory for messages', async () => {
1046
+ const { createSessionViaApi } = await import('../../service/actions.js');
1047
+
1048
+ const mockSessionId = 'ses_test_proj';
1049
+ let createUrl = null;
1050
+ let messageUrl = null;
1051
+
1052
+ const mockFetch = async (url, opts) => {
1053
+ const urlObj = new URL(url);
1054
+
1055
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
1056
+ createUrl = url;
1057
+ return { ok: true, json: async () => ({ id: mockSessionId }) };
1058
+ }
1059
+
1060
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1061
+ messageUrl = url;
1062
+ return { ok: true, json: async () => ({ success: true }) };
1063
+ }
1064
+
1065
+ return { ok: false, text: async () => 'Not found' };
1066
+ };
1067
+
1068
+ await createSessionViaApi(
1069
+ 'http://localhost:4096',
1070
+ '/home/user/.local/share/opencode/worktree/abc123/pr-415',
1071
+ 'Fix the bug',
1072
+ {
1073
+ fetch: mockFetch,
1074
+ projectDirectory: '/home/user/code/odin',
1075
+ }
1076
+ );
1077
+
1078
+ // Session creation should use the project directory (for correct projectID scoping)
1079
+ assert.ok(createUrl.includes('%2Fhome%2Fuser%2Fcode%2Fodin'),
1080
+ 'Session creation should use projectDirectory');
1081
+ assert.ok(!createUrl.includes('worktree'),
1082
+ 'Session creation should NOT use the worktree path');
1083
+
1084
+ // Message should use the working directory (for file operations in the worktree)
1085
+ assert.ok(messageUrl.includes('worktree'),
1086
+ 'Message should use the worktree working directory');
1087
+ assert.ok(messageUrl.includes('pr-415'),
1088
+ 'Message should use the worktree working directory');
1089
+ });
1090
+
1044
1091
  test('handles session creation failure', async () => {
1045
1092
  const { createSessionViaApi } = await import('../../service/actions.js');
1046
1093
 
@@ -2100,6 +2147,60 @@ Check for bugs and security issues.`;
2100
2147
  assert.strictEqual(result.id, 'ses_new');
2101
2148
  });
2102
2149
 
2150
+ test('uses projectDirectory for session lookup when provided', async () => {
2151
+ const { findReusableSession } = await import('../../service/actions.js');
2152
+
2153
+ let queriedDirectory = null;
2154
+ const mockFetch = async (url) => {
2155
+ if (url.includes('/session/status')) {
2156
+ return { ok: true, json: async () => ({}) };
2157
+ }
2158
+ // Capture the directory used in the session query
2159
+ const urlObj = new URL(url);
2160
+ queriedDirectory = urlObj.searchParams.get('directory');
2161
+ return {
2162
+ ok: true,
2163
+ json: async () => [
2164
+ { id: 'ses_proj', time: { created: 1000, updated: 2000 } },
2165
+ ],
2166
+ };
2167
+ };
2168
+
2169
+ const result = await findReusableSession('http://localhost:4096', '/worktree/pr-415', {
2170
+ fetch: mockFetch,
2171
+ projectDirectory: '/home/user/code/odin',
2172
+ });
2173
+
2174
+ assert.strictEqual(result.id, 'ses_proj');
2175
+ assert.strictEqual(queriedDirectory, '/home/user/code/odin',
2176
+ 'Should query sessions using projectDirectory, not the worktree directory');
2177
+ });
2178
+
2179
+ test('falls back to directory when projectDirectory not provided', async () => {
2180
+ const { findReusableSession } = await import('../../service/actions.js');
2181
+
2182
+ let queriedDirectory = null;
2183
+ const mockFetch = async (url) => {
2184
+ if (url.includes('/session/status')) {
2185
+ return { ok: true, json: async () => ({}) };
2186
+ }
2187
+ const urlObj = new URL(url);
2188
+ queriedDirectory = urlObj.searchParams.get('directory');
2189
+ return {
2190
+ ok: true,
2191
+ json: async () => [
2192
+ { id: 'ses_1', time: { created: 1000, updated: 2000 } },
2193
+ ],
2194
+ };
2195
+ };
2196
+
2197
+ const result = await findReusableSession('http://localhost:4096', '/test/dir', { fetch: mockFetch });
2198
+
2199
+ assert.strictEqual(result.id, 'ses_1');
2200
+ assert.strictEqual(queriedDirectory, '/test/dir',
2201
+ 'Should query sessions using directory when projectDirectory not provided');
2202
+ });
2203
+
2103
2204
  test('falls back to busy session when no idle available', async () => {
2104
2205
  const { findReusableSession } = await import('../../service/actions.js');
2105
2206