opencode-pilot 0.16.4 → 0.16.6

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.16.4",
3
+ "version": "0.16.6",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -5,7 +5,7 @@
5
5
  * Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
6
6
  */
7
7
 
8
- import { spawn, execSync } from "child_process";
8
+ import { execSync } from "child_process";
9
9
  import { readFileSync, existsSync } from "fs";
10
10
  import { debug } from "./logger.js";
11
11
  import { getNestedValue } from "./utils.js";
@@ -417,39 +417,6 @@ export function buildCommand(item, config) {
417
417
  return cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
418
418
  }
419
419
 
420
- /**
421
- * Execute a spawn command and return a promise
422
- */
423
- function runSpawn(args, options = {}) {
424
- return new Promise((resolve, reject) => {
425
- const [cmd, ...cmdArgs] = args;
426
- const spawnOpts = {
427
- stdio: ["ignore", "pipe", "pipe"],
428
- ...options,
429
- };
430
- const child = spawn(cmd, cmdArgs, spawnOpts);
431
-
432
- let stdout = "";
433
- let stderr = "";
434
-
435
- child.stdout.on("data", (data) => {
436
- stdout += data.toString();
437
- });
438
-
439
- child.stderr.on("data", (data) => {
440
- stderr += data.toString();
441
- });
442
-
443
- child.on("close", (code) => {
444
- resolve({ stdout, stderr, exitCode: code, success: code === 0 });
445
- });
446
-
447
- child.on("error", (err) => {
448
- reject(err);
449
- });
450
- });
451
- }
452
-
453
420
  /**
454
421
  * Create a session via the OpenCode HTTP API
455
422
  *
@@ -525,19 +492,40 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
525
492
  messageBody.modelID = modelID;
526
493
  }
527
494
 
528
- const messageResponse = await fetchFn(messageUrl.toString(), {
529
- method: 'POST',
530
- headers: { 'Content-Type': 'application/json' },
531
- body: JSON.stringify(messageBody),
532
- });
495
+ // Use AbortController with timeout for the message POST
496
+ // The /session/{id}/message endpoint returns a chunked/streaming response
497
+ // that stays open until the agent completes. We only need to verify the
498
+ // request was accepted (2xx status), not wait for the full response.
499
+ const controller = new AbortController();
500
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
533
501
 
534
- if (!messageResponse.ok) {
535
- const errorText = await messageResponse.text();
536
- throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
502
+ try {
503
+ const messageResponse = await fetchFn(messageUrl.toString(), {
504
+ method: 'POST',
505
+ headers: { 'Content-Type': 'application/json' },
506
+ body: JSON.stringify(messageBody),
507
+ signal: controller.signal,
508
+ });
509
+
510
+ clearTimeout(timeoutId);
511
+
512
+ if (!messageResponse.ok) {
513
+ const errorText = await messageResponse.text();
514
+ throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
515
+ }
516
+
517
+ debug(`createSessionViaApi: sent message to session ${session.id}`);
518
+ } catch (abortErr) {
519
+ clearTimeout(timeoutId);
520
+ // AbortError is expected - we intentionally abort after verifying the request started
521
+ // The server accepted our message, we just don't need to wait for the response
522
+ if (abortErr.name === 'AbortError') {
523
+ debug(`createSessionViaApi: message request started for session ${session.id} (response aborted as expected)`);
524
+ } else {
525
+ throw abortErr;
526
+ }
537
527
  }
538
528
 
539
- debug(`createSessionViaApi: sent message to session ${session.id}`);
540
-
541
529
  return {
542
530
  success: true,
543
531
  sessionId: session.id,
@@ -584,13 +572,20 @@ export async function executeAction(item, config, options = {}) {
584
572
 
585
573
  debug(`executeAction: discovered server=${serverUrl} for baseCwd=${baseCwd}`);
586
574
 
575
+ // Require OpenCode server - pilot runs as a plugin, so server should always be available
576
+ if (!serverUrl) {
577
+ return {
578
+ success: false,
579
+ error: 'No OpenCode server found. Pilot requires OpenCode to be running.',
580
+ };
581
+ }
582
+
587
583
  // Resolve worktree directory if configured
588
584
  // This allows creating sessions in isolated worktrees instead of the main project
589
585
  let worktreeMode = config.worktree;
590
586
 
591
- // Auto-detect worktree support: if not explicitly configured and server is running,
592
- // check if the project has sandboxes (indicating worktree workflow is set up)
593
- if (!worktreeMode && serverUrl) {
587
+ // Auto-detect worktree support: check if the project has sandboxes
588
+ if (!worktreeMode) {
594
589
  // Look up project info for this specific directory (not just /project/current)
595
590
  const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
596
591
  if (projectInfo?.sandboxes?.length > 0) {
@@ -622,117 +617,35 @@ export async function executeAction(item, config, options = {}) {
622
617
 
623
618
  debug(`executeAction: using cwd=${cwd}`);
624
619
 
625
- // If a server is running, use the HTTP API to create the session
626
- // This is a workaround for the known issue where --attach doesn't support --dir
627
- // See: https://github.com/anomalyco/opencode/issues/7376
628
- if (serverUrl) {
629
- // Build prompt from template
630
- const prompt = buildPromptFromTemplate(config.prompt || "default", item);
631
-
632
- // Build session title
633
- const sessionTitle = config.session?.name
634
- ? buildSessionName(config.session.name, item)
635
- : (item.title || `session-${Date.now()}`);
636
-
637
- const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
638
- debug(`executeAction: using HTTP API - ${apiCommand}`);
639
-
640
- if (options.dryRun) {
641
- return {
642
- command: apiCommand,
643
- dryRun: true,
644
- method: 'api',
645
- };
646
- }
647
-
648
- const result = await createSessionViaApi(serverUrl, cwd, prompt, {
649
- title: sessionTitle,
650
- agent: config.agent,
651
- model: config.model,
652
- fetch: options.fetch,
653
- });
654
-
655
- return {
656
- command: apiCommand,
657
- success: result.success,
658
- sessionId: result.sessionId,
659
- error: result.error,
660
- method: 'api',
661
- };
662
- }
620
+ // Build prompt from template
621
+ const prompt = buildPromptFromTemplate(config.prompt || "default", item);
663
622
 
664
- // No server running - fall back to spawning opencode run
665
- // This works correctly because we set cwd on the spawn
666
- const cmdInfo = getCommandInfoNew(item, config, undefined, null);
623
+ // Build session title
624
+ const sessionTitle = config.session?.name
625
+ ? buildSessionName(config.session.name, item)
626
+ : (item.title || `session-${Date.now()}`);
627
+
628
+ const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
629
+ debug(`executeAction: using HTTP API - ${apiCommand}`);
667
630
 
668
- // Build command string for display
669
- const quoteArgs = (args) => args.map(a =>
670
- a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
671
- ).join(" ");
672
- const cmdStr = quoteArgs(cmdInfo.args);
673
- const command = cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
674
-
675
- debug(`executeAction: command=${command}`);
676
- debug(`executeAction: args=${JSON.stringify(cmdInfo.args)}, cwd=${cmdInfo.cwd}`);
677
-
678
631
  if (options.dryRun) {
679
632
  return {
680
- command,
633
+ command: apiCommand,
681
634
  dryRun: true,
682
- method: 'spawn',
683
635
  };
684
636
  }
685
-
686
- // Execute opencode run in background (detached)
687
- // We don't wait for completion since sessions can run for a long time
688
- debug(`executeAction: spawning opencode run (detached)`);
689
- const [cmd, ...cmdArgs] = cmdInfo.args;
690
- const child = spawn(cmd, cmdArgs, {
691
- cwd: cmdInfo.cwd,
692
- detached: true,
693
- stdio: 'ignore',
694
- });
695
- child.unref();
696
637
 
697
- debug(`executeAction: spawned pid=${child.pid}`);
698
- return {
699
- command,
700
- success: true,
701
- pid: child.pid,
702
- method: 'spawn',
703
- };
704
- }
705
-
706
- /**
707
- * Check if opencode is available
708
- * @returns {Promise<boolean>}
709
- */
710
- export async function checkOpencode() {
711
- return new Promise((resolve) => {
712
- const child = spawn("which", ["opencode"]);
713
- child.on("close", (code) => {
714
- resolve(code === 0);
715
- });
716
- child.on("error", () => {
717
- resolve(false);
718
- });
638
+ const result = await createSessionViaApi(serverUrl, cwd, prompt, {
639
+ title: sessionTitle,
640
+ agent: config.agent,
641
+ model: config.model,
642
+ fetch: options.fetch,
719
643
  });
720
- }
721
-
722
- /**
723
- * Validate that required tools are available
724
- * @returns {Promise<object>} { valid: boolean, missing?: string[] }
725
- */
726
- export async function validateTools() {
727
- const missing = [];
728
-
729
- const hasOpencode = await checkOpencode();
730
- if (!hasOpencode) {
731
- missing.push("opencode");
732
- }
733
-
644
+
734
645
  return {
735
- valid: missing.length === 0,
736
- missing,
646
+ command: apiCommand,
647
+ success: result.success,
648
+ sessionId: result.sessionId,
649
+ error: result.error,
737
650
  };
738
651
  }
@@ -352,7 +352,12 @@ export function resolveRepoForItem(source, item) {
352
352
  if (resolvedRepo) {
353
353
  return source.repos.includes(resolvedRepo) ? [resolvedRepo] : [];
354
354
  }
355
- // No repo template - return empty (can't match without item context)
355
+ // No repo template - if exactly one repo, use it as default
356
+ // (e.g., Linear issues don't have repo context, user explicitly configures one repo)
357
+ if (source.repos.length === 1) {
358
+ return source.repos;
359
+ }
360
+ // Multiple repos but can't match without item context
356
361
  return [];
357
362
  }
358
363
 
@@ -543,13 +543,12 @@ describe('actions.js', () => {
543
543
  });
544
544
 
545
545
  assert.ok(result.dryRun);
546
- assert.strictEqual(result.method, 'api', 'Should use API method when server found');
547
546
  assert.ok(result.command.includes('POST'), 'Command should show POST request');
548
547
  assert.ok(result.command.includes('http://localhost:4096'), 'Command should include server URL');
549
548
  assert.ok(result.command.includes('directory='), 'Command should include directory param');
550
549
  });
551
550
 
552
- test('falls back to spawn when no server discovered (dry run)', async () => {
551
+ test('returns error when no server discovered', async () => {
553
552
  const { executeAction } = await import('../../service/actions.js');
554
553
 
555
554
  const item = { number: 123, title: 'Fix bug' };
@@ -566,10 +565,8 @@ describe('actions.js', () => {
566
565
  discoverServer: mockDiscoverServer
567
566
  });
568
567
 
569
- assert.ok(result.dryRun);
570
- assert.strictEqual(result.method, 'spawn', 'Should use spawn method when no server');
571
- assert.ok(!result.command.includes('--attach'), 'Command should not include --attach flag');
572
- assert.ok(result.command.includes('opencode run'), 'Command should include opencode run');
568
+ assert.strictEqual(result.success, false, 'Should fail when no server');
569
+ assert.ok(result.error.includes('No OpenCode server'), 'Should have descriptive error');
573
570
  });
574
571
 
575
572
  test('creates new worktree when worktree: "new" is configured (dry run)', async () => {
@@ -615,7 +612,6 @@ describe('actions.js', () => {
615
612
  });
616
613
 
617
614
  assert.ok(result.dryRun);
618
- assert.strictEqual(result.method, 'api', 'Should use API method');
619
615
  // The directory in the command should be the worktree directory
620
616
  assert.ok(result.command.includes('/data/worktree/proj123/feature-branch'),
621
617
  'Should use worktree directory in command');
@@ -655,7 +651,6 @@ describe('actions.js', () => {
655
651
  });
656
652
 
657
653
  assert.ok(result.dryRun);
658
- assert.strictEqual(result.method, 'api', 'Should use API method');
659
654
  assert.ok(result.command.includes('/data/worktree/proj123/my-feature'),
660
655
  'Should use looked up worktree path in command');
661
656
  });
@@ -692,7 +687,6 @@ describe('actions.js', () => {
692
687
  });
693
688
 
694
689
  assert.ok(result.dryRun);
695
- assert.strictEqual(result.method, 'api', 'Should still use API method');
696
690
  // Should fall back to base directory
697
691
  assert.ok(result.command.includes(tempDir),
698
692
  'Should fall back to base directory when worktree creation fails');
@@ -769,6 +769,46 @@ sources:
769
769
  assert.deepStrictEqual(resolveRepoForItem(source, filteredItem), []);
770
770
  });
771
771
 
772
+ test('single-repo allowlist uses repo as default when no template', async () => {
773
+ // Linear issues don't have repository context - when exactly one repo is configured,
774
+ // use it as the default for all items from that source
775
+ writeFileSync(configPath, `
776
+ sources:
777
+ - preset: linear/my-issues
778
+ repos:
779
+ - 0din-ai/odin
780
+ `);
781
+
782
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
783
+ loadRepoConfig(configPath);
784
+ const source = getSources()[0];
785
+
786
+ // Linear items don't have repository field
787
+ const linearItem = { id: 'linear:abc123', title: 'Fix bug', state: { name: 'In Progress' } };
788
+ assert.deepStrictEqual(resolveRepoForItem(source, linearItem), ['0din-ai/odin'],
789
+ 'single-repo allowlist should use repo as default');
790
+ });
791
+
792
+ test('multi-repo allowlist returns empty when no template match', async () => {
793
+ // With multiple repos and no way to determine which one, return empty
794
+ writeFileSync(configPath, `
795
+ sources:
796
+ - preset: linear/my-issues
797
+ repos:
798
+ - org/repo-a
799
+ - org/repo-b
800
+ `);
801
+
802
+ const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
803
+ loadRepoConfig(configPath);
804
+ const source = getSources()[0];
805
+
806
+ // Can't determine which of the 2 repos to use
807
+ const linearItem = { id: 'linear:abc123', title: 'Fix bug' };
808
+ assert.deepStrictEqual(resolveRepoForItem(source, linearItem), [],
809
+ 'multi-repo allowlist should return empty when no template');
810
+ });
811
+
772
812
  test('github presets include semantic session names', async () => {
773
813
  writeFileSync(configPath, `
774
814
  sources: