opencode-pilot 0.16.6 → 0.17.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
@@ -46,10 +46,12 @@ See [examples/config.yaml](examples/config.yaml) for a complete example with all
46
46
  ### Key Sections
47
47
 
48
48
  - **`server_port`** - Preferred OpenCode server port (e.g., `4096`). When multiple OpenCode instances are running, pilot attaches sessions to this port.
49
+ - **`startup_delay`** - Milliseconds to wait before first poll (default: `10000`). Allows OpenCode server time to fully initialize after restart.
50
+ - **`repos_dir`** - Directory containing git repos (e.g., `~/code`). Pilot auto-discovers repos by scanning git remotes (both `origin` and `upstream` for fork support).
49
51
  - **`defaults`** - Default values applied to all sources
50
52
  - **`sources`** - What to poll (presets, shorthand, or full config)
51
53
  - **`tools`** - Field mappings to normalize different MCP APIs
52
- - **`repos`** - Repository paths and settings (use YAML anchors to share config)
54
+ - **`repos`** - Explicit repository paths (overrides auto-discovery from `repos_dir`)
53
55
 
54
56
  ### Source Syntax
55
57
 
@@ -6,6 +6,16 @@
6
6
  # to this port. If not set, pilot discovers servers automatically.
7
7
  # server_port: 4096
8
8
 
9
+ # Startup delay in milliseconds before first poll (default: 10000)
10
+ # Allows OpenCode server time to fully initialize after restart.
11
+ # Set to 0 for immediate polling.
12
+ # startup_delay: 10000
13
+
14
+ # Directory containing your git repos - enables auto-discovery
15
+ # Pilot scans for repos by checking git remotes (origin and upstream)
16
+ # This means PRs from upstream forks will match your local clones
17
+ repos_dir: ~/code
18
+
9
19
  defaults:
10
20
  agent: plan
11
21
  prompt: default
@@ -68,7 +78,8 @@ sources:
68
78
  # title: name
69
79
  # body: notes
70
80
 
71
- # Map repos to local paths
81
+ # Explicit repo mappings (overrides repos_dir auto-discovery)
82
+ # Only needed if a repo isn't in repos_dir or needs custom settings
72
83
  # repos:
73
84
  # myorg/backend:
74
85
  # path: ~/code/backend
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.16.6",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -290,131 +290,20 @@ export function getActionConfig(source, repoConfig, defaults) {
290
290
  }
291
291
 
292
292
  /**
293
- * Get command info using new config format
293
+ * Build a display string for dry-run logging
294
+ * Shows what API call would be made
294
295
  * @param {object} item - Item to create session for
295
- * @param {object} config - Merged action config
296
- * @param {string} [templatesDir] - Templates directory path (for testing)
297
- * @param {string} [serverUrl] - URL of running opencode server to attach to
298
- * @returns {object} { args: string[], cwd: string }
296
+ * @param {object} config - Repo config with action settings
297
+ * @returns {string} Display string for logging
299
298
  */
300
- export function getCommandInfoNew(item, config, templatesDir, serverUrl) {
301
- // Determine working directory: working_dir > path > repo_path > home
302
- const workingDir = config.working_dir || config.path || config.repo_path || "~";
303
- const cwd = expandPath(workingDir);
304
-
305
- // Build session name
299
+ export function buildCommand(item, config) {
300
+ const workingDir = config.working_dir || config.path || config.repo_path;
301
+ const cwd = workingDir ? expandPath(workingDir) : '(no path)';
306
302
  const sessionName = config.session?.name
307
303
  ? buildSessionName(config.session.name, item)
308
304
  : (item.title || `session-${Date.now()}`);
309
-
310
- // Build command args
311
- const args = ["opencode", "run"];
312
-
313
- // Add --attach if server URL is provided
314
- if (serverUrl) {
315
- args.push("--attach", serverUrl);
316
- }
317
-
318
- // Add session title
319
- args.push("--title", sessionName);
320
-
321
- // Add agent if specified
322
- if (config.agent) {
323
- args.push("--agent", config.agent);
324
- }
325
-
326
- // Add model if specified
327
- if (config.model) {
328
- args.push("--model", config.model);
329
- }
330
-
331
- // Build prompt from template
332
- const prompt = buildPromptFromTemplate(config.prompt || "default", item, templatesDir);
333
- if (prompt) {
334
- args.push(prompt);
335
- }
336
-
337
- return { args, cwd };
338
- }
339
-
340
- /**
341
- * Build the prompt from item and config
342
- * Uses prompt_template if provided, otherwise combines title and body
343
- * @param {object} item - Item with title, body, etc.
344
- * @param {object} config - Config with optional session.prompt_template
345
- * @returns {string} The prompt to send to opencode
346
- */
347
- function buildPrompt(item, config) {
348
- // If prompt_template is provided, expand it
349
- if (config.session?.prompt_template) {
350
- return expandTemplate(config.session.prompt_template, item);
351
- }
352
-
353
- // Default: combine title and body
354
- const parts = [];
355
- if (item.title) parts.push(item.title);
356
- if (item.body) parts.push(item.body);
357
- return parts.join("\n\n");
358
- }
359
-
360
- /**
361
- * Build command args for action
362
- * Uses "opencode run" for non-interactive execution
363
- * @deprecated Legacy function - not currently used. See getCommandInfoNew instead.
364
- * @returns {object} { args: string[], cwd: string }
365
- */
366
- function buildCommandArgs(item, config) {
367
- const repoPath = expandPath(config.repo_path || ".");
368
- const sessionTitle = config.session?.name_template
369
- ? buildSessionName(config.session.name_template, item)
370
- : (item.title || `session-${Date.now()}`);
371
-
372
- // Build opencode run command args array (non-interactive)
373
- // Note: --title sets session title (--session is for continuing existing sessions)
374
- const args = ["opencode", "run"];
375
-
376
- // Add title for the session (helps identify it later)
377
- args.push("--title", sessionTitle);
378
-
379
- // Add agent if specified
380
- if (config.session?.agent) {
381
- args.push("--agent", config.session.agent);
382
- }
383
-
384
- // Add prompt (must be last for "run" command)
385
- const prompt = buildPrompt(item, config);
386
- if (prompt) {
387
- args.push(prompt);
388
- }
389
-
390
- return { args, cwd: repoPath };
391
- }
392
-
393
- /**
394
- * Get command info for an action
395
- * @param {object} item - Item to create session for
396
- * @param {object} config - Repo config with action settings
397
- * @returns {object} { args: string[], cwd: string }
398
- */
399
- export function getCommandInfo(item, config) {
400
- return getCommandInfoNew(item, config);
401
- }
402
-
403
- /**
404
- * Build command string for display/logging
405
- * @param {object} item - Item to create session for
406
- * @param {object} config - Repo config with action settings
407
- * @returns {string} Command string (for display only)
408
- */
409
- export function buildCommand(item, config) {
410
- const cmdInfo = getCommandInfo(item, config);
411
-
412
- const quoteArgs = (args) => args.map(a =>
413
- a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
414
- ).join(" ");
415
305
 
416
- const cmdStr = quoteArgs(cmdInfo.args);
417
- return cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
306
+ return `[API] POST /session?directory=${cwd} (title: "${sessionName}")`;
418
307
  }
419
308
 
420
309
  /**
@@ -562,8 +451,19 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
562
451
  * @returns {Promise<object>} Result with command, stdout, stderr, exitCode
563
452
  */
564
453
  export async function executeAction(item, config, options = {}) {
565
- // Get base working directory first to determine which server to attach to
566
- const workingDir = config.working_dir || config.path || config.repo_path || "~";
454
+ // Get base working directory - require explicit config, don't default to home
455
+ const workingDir = config.working_dir || config.path || config.repo_path;
456
+
457
+ // Fail-safe: require a valid local path to be configured
458
+ if (!workingDir) {
459
+ debug(`executeAction: skipping item - no local path configured`);
460
+ return {
461
+ success: false,
462
+ skipped: true,
463
+ error: 'No local path configured for this repository. Configure repos_dir or add explicit repo config.',
464
+ };
465
+ }
466
+
567
467
  const baseCwd = expandPath(workingDir);
568
468
 
569
469
  // Discover running opencode server for this directory
@@ -573,10 +473,12 @@ export async function executeAction(item, config, options = {}) {
573
473
  debug(`executeAction: discovered server=${serverUrl} for baseCwd=${baseCwd}`);
574
474
 
575
475
  // Require OpenCode server - pilot runs as a plugin, so server should always be available
476
+ // Mark as skipped (retriable) rather than hard error - server may still be initializing
576
477
  if (!serverUrl) {
577
478
  return {
578
479
  success: false,
579
- error: 'No OpenCode server found. Pilot requires OpenCode to be running.',
480
+ skipped: true,
481
+ error: 'No OpenCode server found. Will retry on next poll.',
580
482
  };
581
483
  }
582
484
 
@@ -9,7 +9,7 @@
9
9
  * 5. Track processed items to avoid duplicates
10
10
  */
11
11
 
12
- import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays } from "./repo-config.js";
12
+ import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig, resolveRepoForItem, getCleanupTtlDays, getStartupDelay } from "./repo-config.js";
13
13
  import { createPoller, pollGenericSource, enrichItemsWithComments } from "./poller.js";
14
14
  import { evaluateReadiness, sortByPriority } from "./readiness.js";
15
15
  import { executeAction, buildCommand } from "./actions.js";
@@ -198,6 +198,14 @@ export async function pollOnce(options = {}) {
198
198
  // Build action config from source and item (resolves repo from item fields)
199
199
  const actionConfig = buildActionConfigForItem(source, item);
200
200
 
201
+ // Skip items with no valid local path (prevents sessions in home directory)
202
+ const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
203
+ if (!hasLocalPath) {
204
+ debug(`Skipping ${item.id} - no local path configured for repository`);
205
+ console.warn(`[poll] Skipping ${item.id} - no local path configured (repo not in repos_dir or explicit config)`);
206
+ continue;
207
+ }
208
+
201
209
  // Execute or dry-run
202
210
  if (dryRun) {
203
211
  const command = buildCommand(item, actionConfig);
@@ -232,8 +240,13 @@ export async function pollOnce(options = {}) {
232
240
  } else {
233
241
  console.log(`[poll] Started session for ${item.id}`);
234
242
  }
243
+ } else if (result.skipped) {
244
+ // Item was skipped (e.g., no local path configured) - use debug level
245
+ // This will retry on next poll, but doesn't spam logs
246
+ debug(`Skipped ${item.id}: ${result.error}`);
235
247
  } else {
236
- console.error(`[poll] Failed to start session: ${result.error || result.stderr || 'unknown error'}`);
248
+ // Real failure - log as error
249
+ console.error(`[poll] Failed to start session for ${item.id}: ${result.error || result.stderr || 'unknown error'}`);
237
250
  }
238
251
  } catch (err) {
239
252
  console.error(`[poll] Error executing action: ${err.message}`);
@@ -287,12 +300,21 @@ export function startPolling(options = {}) {
287
300
  console.log(`[poll] Cleaned up ${expiredRemoved} expired state entries (older than ${ttlDays} days)`);
288
301
  }
289
302
 
290
- // Run first poll immediately
291
- pollOnce({ configPath }).catch((err) => {
292
- console.error("[poll] Error in poll cycle:", err.message);
293
- });
303
+ // Delay first poll to allow OpenCode server to fully initialize
304
+ // This prevents race conditions on startup where projects/sandboxes aren't loaded yet
305
+ const startupDelay = getStartupDelay();
306
+ if (startupDelay > 0) {
307
+ console.log(`[poll] Waiting ${startupDelay / 1000}s for server to initialize...`);
308
+ }
309
+
310
+ // Schedule first poll after startup delay (or immediately if delay is 0)
311
+ setTimeout(() => {
312
+ pollOnce({ configPath }).catch((err) => {
313
+ console.error("[poll] Error in poll cycle:", err.message);
314
+ });
315
+ }, startupDelay);
294
316
 
295
- // Start interval
317
+ // Start interval (runs after startup delay + interval for first scheduled poll)
296
318
  pollingInterval = setInterval(() => {
297
319
  pollOnce({ configPath }).catch((err) => {
298
320
  console.error("[poll] Error in poll cycle:", err.message);
@@ -62,6 +62,7 @@ function parseGitHubRepo(url) {
62
62
 
63
63
  /**
64
64
  * Discover repos from a repos_dir by scanning git remotes
65
+ * Checks both 'origin' and 'upstream' remotes to support fork workflows
65
66
  * @param {string} reposDir - Directory containing git repositories
66
67
  * @returns {Map<string, object>} Map of "owner/repo" -> { path }
67
68
  */
@@ -90,20 +91,24 @@ function discoverRepos(reposDir) {
90
91
  // Skip if not a git repo
91
92
  if (!fs.existsSync(gitDir)) continue;
92
93
 
93
- // Get remote origin URL via git API
94
- try {
95
- const remoteUrl = execSync('git remote get-url origin', {
96
- cwd: repoPath,
97
- encoding: 'utf-8',
98
- stdio: ['pipe', 'pipe', 'pipe']
99
- }).trim();
100
-
101
- const repoKey = parseGitHubRepo(remoteUrl);
102
- if (repoKey) {
103
- discovered.set(repoKey, { path: repoPath });
94
+ // Check both origin and upstream remotes to support fork workflows
95
+ // e.g., origin = athal7/opencode (fork), upstream = anomalyco/opencode (original)
96
+ // Both should resolve to the same local path
97
+ for (const remote of ['origin', 'upstream']) {
98
+ try {
99
+ const remoteUrl = execSync(`git remote get-url ${remote}`, {
100
+ cwd: repoPath,
101
+ encoding: 'utf-8',
102
+ stdio: ['pipe', 'pipe', 'pipe']
103
+ }).trim();
104
+
105
+ const repoKey = parseGitHubRepo(remoteUrl);
106
+ if (repoKey) {
107
+ discovered.set(repoKey, { path: repoPath });
108
+ }
109
+ } catch {
110
+ // Skip if remote doesn't exist or git errors
104
111
  }
105
- } catch {
106
- // Skip repos without origin or git errors
107
112
  }
108
113
  }
109
114
  } catch {
@@ -426,6 +431,16 @@ export function getServerPort() {
426
431
  return config?.server_port ?? null;
427
432
  }
428
433
 
434
+ /**
435
+ * Get startup delay from config (ms to wait before first poll)
436
+ * This allows OpenCode server time to fully initialize after restart
437
+ * @returns {number} Startup delay in ms (default: 10000 = 10 seconds)
438
+ */
439
+ export function getStartupDelay() {
440
+ const config = getRawConfig();
441
+ return config?.startup_delay ?? 10000;
442
+ }
443
+
429
444
  /**
430
445
  * Clear config cache (for testing)
431
446
  */
package/service/server.js CHANGED
@@ -166,6 +166,20 @@ if (isMainModule()) {
166
166
 
167
167
  console.log('[opencode-pilot] Starting service...')
168
168
 
169
+ // Handle uncaught exceptions - log and exit to prevent silent crashes
170
+ process.on('uncaughtException', (err) => {
171
+ console.error('[opencode-pilot] Uncaught exception:', err.message)
172
+ console.error(err.stack)
173
+ process.exit(1)
174
+ })
175
+
176
+ // Handle unhandled promise rejections - log and exit to prevent silent crashes
177
+ process.on('unhandledRejection', (reason, promise) => {
178
+ console.error('[opencode-pilot] Unhandled rejection at:', promise)
179
+ console.error('[opencode-pilot] Reason:', reason)
180
+ process.exit(1)
181
+ })
182
+
169
183
  startService(config).then((service) => {
170
184
  // Handle graceful shutdown
171
185
  process.on('SIGTERM', async () => {
@@ -160,137 +160,32 @@ describe('actions.js', () => {
160
160
 
161
161
  });
162
162
 
163
- describe('getCommandInfoNew', () => {
164
- test('builds command with all options', async () => {
165
- writeFileSync(join(templatesDir, 'default.md'), '{title}\n\n{body}');
166
-
167
- const { getCommandInfoNew } = await import('../../service/actions.js');
163
+ describe('buildCommand', () => {
164
+ test('builds display string for API call', async () => {
165
+ const { buildCommand } = await import('../../service/actions.js');
168
166
 
169
- const item = { number: 123, title: 'Fix bug', body: 'Details' };
167
+ const item = { number: 123, title: 'Fix bug' };
170
168
  const config = {
171
169
  path: '~/code/backend',
172
- prompt: 'default',
173
- agent: 'coder',
174
170
  session: { name: 'issue-{number}' }
175
171
  };
176
172
 
177
- const cmdInfo = getCommandInfoNew(item, config, templatesDir);
178
-
179
- assert.strictEqual(cmdInfo.cwd, join(homedir(), 'code/backend'));
180
- assert.ok(cmdInfo.args.includes('opencode'));
181
- assert.ok(cmdInfo.args.includes('run'));
182
- assert.ok(cmdInfo.args.includes('--title'));
183
- assert.ok(cmdInfo.args.includes('issue-123'));
184
- assert.ok(cmdInfo.args.includes('--agent'));
185
- assert.ok(cmdInfo.args.includes('coder'));
186
- });
187
-
188
- test('uses working_dir when no path', async () => {
189
- const { getCommandInfoNew } = await import('../../service/actions.js');
190
-
191
- const item = { id: 'reminder-1', title: 'Do something' };
192
- const config = {
193
- working_dir: '~/scratch',
194
- prompt: 'default'
195
- };
196
-
197
- const cmdInfo = getCommandInfoNew(item, config, templatesDir);
173
+ const result = buildCommand(item, config);
198
174
 
199
- assert.strictEqual(cmdInfo.cwd, join(homedir(), 'scratch'));
175
+ assert.ok(result.includes('[API]'), 'Should indicate API call');
176
+ assert.ok(result.includes('/session'), 'Should include session endpoint');
177
+ assert.ok(result.includes('issue-123'), 'Should include expanded session name');
200
178
  });
201
179
 
202
- test('defaults to home dir when no path or working_dir', async () => {
203
- const { getCommandInfoNew } = await import('../../service/actions.js');
180
+ test('shows (no path) when path not configured', async () => {
181
+ const { buildCommand } = await import('../../service/actions.js');
204
182
 
205
183
  const item = { title: 'Do something' };
206
184
  const config = { prompt: 'default' };
207
185
 
208
- const cmdInfo = getCommandInfoNew(item, config, templatesDir);
209
-
210
- assert.strictEqual(cmdInfo.cwd, homedir());
211
- });
212
-
213
- test('includes prompt from template as message', async () => {
214
- writeFileSync(join(templatesDir, 'devcontainer.md'), '/devcontainer issue-{number}\n\n{title}\n\n{body}');
215
-
216
- const { getCommandInfoNew } = await import('../../service/actions.js');
217
-
218
- const item = { number: 66, title: 'Fix bug', body: 'Details' };
219
- const config = {
220
- path: '~/code/backend',
221
- prompt: 'devcontainer'
222
- };
223
-
224
- const cmdInfo = getCommandInfoNew(item, config, templatesDir);
225
-
226
- // Should NOT have --command flag (slash command is in template)
227
- assert.ok(!cmdInfo.args.includes('--command'), 'Should not include --command flag');
186
+ const result = buildCommand(item, config);
228
187
 
229
- // Prompt should include the /devcontainer command
230
- const lastArg = cmdInfo.args[cmdInfo.args.length - 1];
231
- assert.ok(lastArg.includes('/devcontainer issue-66'), 'Prompt should include /devcontainer command');
232
- assert.ok(lastArg.includes('Fix bug'), 'Prompt should include title');
233
- });
234
-
235
- test('includes --attach when serverUrl is provided', async () => {
236
- const { getCommandInfoNew } = await import('../../service/actions.js');
237
-
238
- const item = { number: 123, title: 'Fix bug' };
239
- const config = {
240
- path: '~/code/backend',
241
- prompt: 'default'
242
- };
243
-
244
- const cmdInfo = getCommandInfoNew(item, config, templatesDir, 'http://localhost:4096');
245
-
246
- assert.ok(cmdInfo.args.includes('--attach'), 'Should include --attach flag');
247
- assert.ok(cmdInfo.args.includes('http://localhost:4096'), 'Should include server URL');
248
- });
249
-
250
- test('does not include --attach when serverUrl is null', async () => {
251
- const { getCommandInfoNew } = await import('../../service/actions.js');
252
-
253
- const item = { number: 123, title: 'Fix bug' };
254
- const config = {
255
- path: '~/code/backend',
256
- prompt: 'default'
257
- };
258
-
259
- const cmdInfo = getCommandInfoNew(item, config, templatesDir, null);
260
-
261
- assert.ok(!cmdInfo.args.includes('--attach'), 'Should not include --attach flag');
262
- });
263
-
264
- test('uses item title as session name when no session.name configured', async () => {
265
- const { getCommandInfoNew } = await import('../../service/actions.js');
266
-
267
- const item = { id: 'reminder-123', title: 'Review quarterly reports' };
268
- const config = {
269
- path: '~/code/backend',
270
- prompt: 'default'
271
- };
272
-
273
- const cmdInfo = getCommandInfoNew(item, config, templatesDir);
274
-
275
- const titleIndex = cmdInfo.args.indexOf('--title');
276
- assert.ok(titleIndex !== -1, 'Should have --title flag');
277
- assert.strictEqual(cmdInfo.args[titleIndex + 1], 'Review quarterly reports', 'Should use item title as session name');
278
- });
279
-
280
- test('falls back to timestamp when no session.name and no title', async () => {
281
- const { getCommandInfoNew } = await import('../../service/actions.js');
282
-
283
- const item = { id: 'item-123' };
284
- const config = {
285
- path: '~/code/backend',
286
- prompt: 'default'
287
- };
288
-
289
- const cmdInfo = getCommandInfoNew(item, config, templatesDir);
290
-
291
- const titleIndex = cmdInfo.args.indexOf('--title');
292
- assert.ok(titleIndex !== -1, 'Should have --title flag');
293
- assert.ok(cmdInfo.args[titleIndex + 1].startsWith('session-'), 'Should fall back to session-{timestamp}');
188
+ assert.ok(result.includes('(no path)'), 'Should indicate missing path');
294
189
  });
295
190
  });
296
191
 
@@ -569,6 +464,48 @@ describe('actions.js', () => {
569
464
  assert.ok(result.error.includes('No OpenCode server'), 'Should have descriptive error');
570
465
  });
571
466
 
467
+ test('skips item when no local path is configured', async () => {
468
+ const { executeAction } = await import('../../service/actions.js');
469
+
470
+ const item = { number: 123, title: 'PR from unknown fork' };
471
+ // Config with no path/working_dir - simulates unknown repo
472
+ const config = {
473
+ prompt: 'default'
474
+ // Note: no path, working_dir, or repo_path - simulates unknown repo
475
+ };
476
+
477
+ const result = await executeAction(item, config, { dryRun: true });
478
+
479
+ assert.strictEqual(result.success, false, 'Should fail when no path configured');
480
+ assert.strictEqual(result.skipped, true, 'Should mark as skipped');
481
+ assert.ok(result.error.includes('No local path configured'), 'Should have descriptive error');
482
+ });
483
+
484
+ test('allows any path when working_dir is explicitly set', async () => {
485
+ const { executeAction } = await import('../../service/actions.js');
486
+ const os = await import('os');
487
+
488
+ const item = { number: 123, title: 'Global task' };
489
+ // Explicit working_dir to home - user intentionally wants this
490
+ const config = {
491
+ working_dir: os.homedir(),
492
+ prompt: 'default'
493
+ };
494
+
495
+ // Mock server discovery
496
+ const mockDiscoverServer = async () => 'http://localhost:4096';
497
+
498
+ // Should not skip because working_dir is explicitly set
499
+ const result = await executeAction(item, config, {
500
+ dryRun: true,
501
+ discoverServer: mockDiscoverServer,
502
+ fetch: async () => ({ ok: false, text: async () => 'Not found' })
503
+ });
504
+
505
+ // It won't succeed (no valid session endpoint mock) but it shouldn't be skipped
506
+ assert.notStrictEqual(result.skipped, true, 'Should NOT skip when working_dir is explicit');
507
+ });
508
+
572
509
  test('creates new worktree when worktree: "new" is configured (dry run)', async () => {
573
510
  const { executeAction } = await import('../../service/actions.js');
574
511
 
@@ -216,6 +216,29 @@ repos_dir: ${reposDir}
216
216
  assert.deepStrictEqual(config, {});
217
217
  });
218
218
 
219
+ test('discovers repos from upstream remote for fork workflows', async () => {
220
+ // Create a repo with both origin (fork) and upstream (original) remotes
221
+ const repoPath = join(reposDir, 'opencode');
222
+ mkdirSync(repoPath);
223
+ execSync('git init', { cwd: repoPath, stdio: 'ignore' });
224
+ execSync('git remote add origin https://github.com/athal7/opencode.git', { cwd: repoPath, stdio: 'ignore' });
225
+ execSync('git remote add upstream https://github.com/anomalyco/opencode.git', { cwd: repoPath, stdio: 'ignore' });
226
+
227
+ writeFileSync(configPath, `
228
+ repos_dir: ${reposDir}
229
+ `);
230
+
231
+ const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
232
+ loadRepoConfig(configPath);
233
+
234
+ // Both the fork (origin) and original (upstream) should resolve to the same local path
235
+ const forkConfig = getRepoConfig('athal7/opencode');
236
+ const upstreamConfig = getRepoConfig('anomalyco/opencode');
237
+
238
+ assert.strictEqual(forkConfig.path, repoPath, 'fork (origin) should resolve');
239
+ assert.strictEqual(upstreamConfig.path, repoPath, 'upstream should also resolve');
240
+ });
241
+
219
242
  });
220
243
 
221
244
  describe('sources', () => {