opencode-pilot 0.20.4 → 0.20.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -799,6 +799,82 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
799
799
  }
800
800
  }
801
801
 
802
+ /**
803
+ * Execute session creation/reuse in a specific directory
804
+ * Internal helper for executeAction - handles prompt building, session reuse, and API calls
805
+ *
806
+ * @param {string} serverUrl - OpenCode server URL
807
+ * @param {string} cwd - Working directory for the session
808
+ * @param {object} item - Item to create session for
809
+ * @param {object} config - Repo config with action settings
810
+ * @param {object} [options] - Execution options
811
+ * @returns {Promise<object>} Result with command, success, sessionId, etc.
812
+ */
813
+ async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
814
+ // Build prompt from template
815
+ const prompt = buildPromptFromTemplate(config.prompt || "default", item);
816
+
817
+ // Build session title
818
+ const sessionTitle = config.session?.name
819
+ ? buildSessionName(config.session.name, item)
820
+ : (item.title || `session-${Date.now()}`);
821
+
822
+ // Check if we should try to reuse an existing session
823
+ const reuseActiveSession = config.reuse_active_session !== false; // default true
824
+
825
+ if (reuseActiveSession && !options.dryRun) {
826
+ const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
827
+
828
+ if (existingSession) {
829
+ debug(`executeInDirectory: found reusable session ${existingSession.id} for ${cwd}`);
830
+
831
+ const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
832
+
833
+ const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
834
+ title: sessionTitle,
835
+ agent: config.agent,
836
+ model: config.model,
837
+ fetch: options.fetch,
838
+ });
839
+
840
+ return {
841
+ command: reuseCommand,
842
+ success: result.success,
843
+ sessionId: result.sessionId,
844
+ directory: cwd,
845
+ sessionReused: true,
846
+ error: result.error,
847
+ };
848
+ }
849
+ }
850
+
851
+ const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
852
+ debug(`executeInDirectory: using HTTP API - ${apiCommand}`);
853
+
854
+ if (options.dryRun) {
855
+ return {
856
+ command: apiCommand,
857
+ directory: cwd,
858
+ dryRun: true,
859
+ };
860
+ }
861
+
862
+ const result = await createSessionViaApi(serverUrl, cwd, prompt, {
863
+ title: sessionTitle,
864
+ agent: config.agent,
865
+ model: config.model,
866
+ fetch: options.fetch,
867
+ });
868
+
869
+ return {
870
+ command: apiCommand,
871
+ success: result.success,
872
+ sessionId: result.sessionId,
873
+ directory: cwd,
874
+ error: result.error,
875
+ };
876
+ }
877
+
802
878
  /**
803
879
  * Execute an action
804
880
  * @param {object} item - Item to create session for
@@ -841,6 +917,14 @@ export async function executeAction(item, config, options = {}) {
841
917
  };
842
918
  }
843
919
 
920
+ // If existing_directory is provided (reprocessing same item), use it directly
921
+ // This preserves the worktree from the previous run even if its name doesn't match the template
922
+ if (config.existing_directory) {
923
+ debug(`executeAction: using existing_directory=${config.existing_directory}`);
924
+ const cwd = expandPath(config.existing_directory);
925
+ return await executeInDirectory(serverUrl, cwd, item, config, options);
926
+ }
927
+
844
928
  // Resolve worktree directory if configured
845
929
  // This allows creating sessions in isolated worktrees instead of the main project
846
930
  let worktreeMode = config.worktree;
@@ -890,63 +974,5 @@ export async function executeAction(item, config, options = {}) {
890
974
 
891
975
  debug(`executeAction: using cwd=${cwd}`);
892
976
 
893
- // Build prompt from template
894
- const prompt = buildPromptFromTemplate(config.prompt || "default", item);
895
-
896
- // Build session title
897
- const sessionTitle = config.session?.name
898
- ? buildSessionName(config.session.name, item)
899
- : (item.title || `session-${Date.now()}`);
900
-
901
- // Check if we should try to reuse an existing session
902
- const reuseActiveSession = config.reuse_active_session !== false; // default true
903
-
904
- if (reuseActiveSession && !options.dryRun) {
905
- const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
906
-
907
- if (existingSession) {
908
- debug(`executeAction: found reusable session ${existingSession.id} for ${cwd}`);
909
-
910
- const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
911
-
912
- const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
913
- title: sessionTitle,
914
- agent: config.agent,
915
- model: config.model,
916
- fetch: options.fetch,
917
- });
918
-
919
- return {
920
- command: reuseCommand,
921
- success: result.success,
922
- sessionId: result.sessionId,
923
- sessionReused: true,
924
- error: result.error,
925
- };
926
- }
927
- }
928
-
929
- const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
930
- debug(`executeAction: using HTTP API - ${apiCommand}`);
931
-
932
- if (options.dryRun) {
933
- return {
934
- command: apiCommand,
935
- dryRun: true,
936
- };
937
- }
938
-
939
- const result = await createSessionViaApi(serverUrl, cwd, prompt, {
940
- title: sessionTitle,
941
- agent: config.agent,
942
- model: config.model,
943
- fetch: options.fetch,
944
- });
945
-
946
- return {
947
- command: apiCommand,
948
- success: result.success,
949
- sessionId: result.sessionId,
950
- error: result.error,
951
- };
977
+ return await executeInDirectory(serverUrl, cwd, item, config, options);
952
978
  }
@@ -200,10 +200,14 @@ export async function pollOnce(options = {}) {
200
200
  debug(`Processing ${sortedItems.length} sorted items`);
201
201
  for (const item of sortedItems) {
202
202
  // Check if already processed
203
+ let existingDirectory = null;
203
204
  if (pollerInstance && pollerInstance.isProcessed(item.id)) {
204
205
  // Check if item should be reprocessed (reopened, status changed, etc.)
205
206
  if (pollerInstance.shouldReprocess(item, { reprocessOn })) {
206
207
  debug(`Reprocessing ${item.id} - state changed`);
208
+ // Get the stored directory before clearing state (for worktree reuse)
209
+ const prevMeta = pollerInstance.getProcessedMeta(item.id);
210
+ existingDirectory = prevMeta?.directory || null;
207
211
  pollerInstance.clearProcessed(item.id);
208
212
  console.log(`[poll] Reprocessing ${item.id} (reopened or updated)`);
209
213
  } else {
@@ -215,6 +219,12 @@ export async function pollOnce(options = {}) {
215
219
  debug(`Executing action for ${item.id}`);
216
220
  // Build action config from source and item (resolves repo from item fields)
217
221
  const actionConfig = buildActionConfigForItem(source, item);
222
+
223
+ // Pass existing directory for worktree reuse when reprocessing
224
+ if (existingDirectory) {
225
+ actionConfig.existing_directory = existingDirectory;
226
+ debug(`Reusing existing directory: ${existingDirectory}`);
227
+ }
218
228
 
219
229
  // Skip items with no valid local path (prevents sessions in home directory)
220
230
  const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
@@ -244,11 +254,13 @@ export async function pollOnce(options = {}) {
244
254
  if (result.success) {
245
255
  // Mark as processed to avoid re-triggering
246
256
  // Store item state for detecting reopened/updated items
257
+ // Store directory for worktree reuse when reprocessing
247
258
  if (pollerInstance) {
248
259
  pollerInstance.markProcessed(item.id, {
249
260
  repoKey: item.repo_key,
250
261
  command: result.command,
251
262
  source: sourceName,
263
+ directory: result.directory || null,
252
264
  itemState: item.state || item.status || null,
253
265
  itemUpdatedAt: item.updated_at || null,
254
266
  });
package/service/poller.js CHANGED
@@ -722,6 +722,15 @@ export function createPoller(options = {}) {
722
722
  return processedItems.has(itemId);
723
723
  },
724
724
 
725
+ /**
726
+ * Get metadata for a processed item
727
+ * @param {string} itemId - Item ID
728
+ * @returns {object|null} Metadata or null if not processed
729
+ */
730
+ getProcessedMeta(itemId) {
731
+ return processedItems.get(itemId) || null;
732
+ },
733
+
725
734
  /**
726
735
  * Mark an item as processed
727
736
  */
@@ -566,4 +566,57 @@ describe("integration: worktree creation with worktree_name", () => {
566
566
  assert.ok(sessionCreated, "Should create session");
567
567
  assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be in worktree directory");
568
568
  });
569
+
570
+ it("reuses stored directory when reprocessing same item", async () => {
571
+ // This tests the scenario where:
572
+ // 1. Item was processed before, worktree created with random name (e.g., "calm-wizard")
573
+ // 2. Item triggers again (e.g., new feedback)
574
+ // 3. We should reuse the stored directory, not create a new worktree
575
+
576
+ let worktreeListCalled = false;
577
+ let worktreeCreateCalled = false;
578
+ let sessionDirectory = null;
579
+
580
+ // Existing worktree has a random name, not "pr-42"
581
+ const existingWorktreeDir = "/worktree/calm-wizard";
582
+
583
+ const mockServer = await createMockServer({
584
+ "GET /experimental/worktree": () => {
585
+ worktreeListCalled = true;
586
+ // Return existing worktree with random name
587
+ return { body: [existingWorktreeDir] };
588
+ },
589
+ "POST /experimental/worktree": () => {
590
+ worktreeCreateCalled = true;
591
+ return { body: { name: "pr-42", directory: "/worktree/pr-42" } };
592
+ },
593
+ "GET /session": () => ({ body: [] }),
594
+ "GET /session/status": () => ({ body: {} }),
595
+ "POST /session": (req) => {
596
+ sessionDirectory = req.query?.directory;
597
+ return { body: { id: "ses_reprocess" } };
598
+ },
599
+ "PATCH /session/ses_reprocess": () => ({ body: {} }),
600
+ "POST /session/ses_reprocess/message": () => ({ body: { success: true } }),
601
+ });
602
+
603
+ // Simulate reprocessing with a stored directory from previous run
604
+ const result = await executeAction(
605
+ { number: 42, title: "Review PR" },
606
+ {
607
+ path: "/proj",
608
+ prompt: "review",
609
+ worktree_name: "pr-{number}",
610
+ // This is the key: pass the directory we used last time
611
+ existing_directory: existingWorktreeDir,
612
+ },
613
+ { discoverServer: async () => mockServer.url }
614
+ );
615
+
616
+ assert.ok(result.success, "Action should succeed");
617
+ // Should NOT create a new worktree since we have existing_directory
618
+ 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
+ });
569
622
  });
@@ -888,6 +888,75 @@ Check for bugs and security issues.`;
888
888
  assert.ok(result.command.includes('/data/worktree/proj/pr-123'),
889
889
  'Should use new worktree directory');
890
890
  });
891
+
892
+ test('uses existing_directory without creating new worktree', async () => {
893
+ const { executeAction } = await import('../../service/actions.js');
894
+
895
+ const item = { number: 123, title: 'Review PR #123' };
896
+ const config = {
897
+ path: '/data/proj',
898
+ prompt: 'review',
899
+ worktree_name: 'pr-{number}',
900
+ // This is the key - pass an existing directory from a previous run
901
+ existing_directory: '/data/worktree/calm-wizard',
902
+ };
903
+
904
+ const mockDiscoverServer = async () => 'http://localhost:4096';
905
+
906
+ let worktreeListCalled = false;
907
+ let worktreeCreateCalled = false;
908
+ let sessionDirectory = null;
909
+
910
+ const mockFetch = async (url, opts) => {
911
+ const urlObj = new URL(url);
912
+
913
+ // Track if worktree endpoints are called (they shouldn't be)
914
+ if (urlObj.pathname === '/experimental/worktree') {
915
+ if (opts?.method === 'POST') {
916
+ worktreeCreateCalled = true;
917
+ } else {
918
+ worktreeListCalled = true;
919
+ }
920
+ return { ok: true, json: async () => [] };
921
+ }
922
+
923
+ // No existing sessions
924
+ if (urlObj.pathname === '/session' && !opts?.method) {
925
+ return { ok: true, json: async () => [] };
926
+ }
927
+ if (urlObj.pathname === '/session/status') {
928
+ return { ok: true, json: async () => ({}) };
929
+ }
930
+
931
+ // Session creation - capture the directory
932
+ if (urlObj.pathname === '/session' && opts?.method === 'POST') {
933
+ sessionDirectory = urlObj.searchParams.get('directory');
934
+ return { ok: true, json: async () => ({ id: 'ses_test' }) };
935
+ }
936
+
937
+ // Other session endpoints
938
+ if (urlObj.pathname.includes('/session/')) {
939
+ return { ok: true, json: async () => ({}) };
940
+ }
941
+
942
+ return { ok: false, text: async () => 'Not found' };
943
+ };
944
+
945
+ const result = await executeAction(item, config, {
946
+ discoverServer: mockDiscoverServer,
947
+ fetch: mockFetch
948
+ });
949
+
950
+ assert.ok(result.success);
951
+ // Should NOT call worktree endpoints when existing_directory is provided
952
+ assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
953
+ assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
954
+ // Should use the existing directory
955
+ assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
956
+ 'Should use existing_directory for session');
957
+ assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
958
+ 'Result should include directory');
959
+ });
891
960
  });
892
961
 
893
962
  describe('createSessionViaApi', () => {
@@ -136,6 +136,29 @@ describe('poller.js', () => {
136
136
  assert.strictEqual(poller.getProcessedIds().length, 1);
137
137
  });
138
138
 
139
+ test('getProcessedMeta returns stored metadata', async () => {
140
+ const { createPoller } = await import('../../service/poller.js');
141
+
142
+ const poller = createPoller({ stateFile });
143
+
144
+ // Not processed yet
145
+ assert.strictEqual(poller.getProcessedMeta('item-1'), null);
146
+
147
+ // Mark as processed with metadata including directory
148
+ poller.markProcessed('item-1', {
149
+ source: 'test',
150
+ directory: '/worktree/pr-123',
151
+ itemState: 'open',
152
+ });
153
+
154
+ const meta = poller.getProcessedMeta('item-1');
155
+ assert.ok(meta);
156
+ assert.strictEqual(meta.source, 'test');
157
+ assert.strictEqual(meta.directory, '/worktree/pr-123');
158
+ assert.strictEqual(meta.itemState, 'open');
159
+ assert.ok(meta.processedAt); // Should have timestamp
160
+ });
161
+
139
162
  test('persists state across instances', async () => {
140
163
  const { createPoller } = await import('../../service/poller.js');
141
164