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 +1 -1
- package/service/actions.js +62 -149
- package/service/repo-config.js +6 -1
- package/test/unit/actions.test.js +3 -9
- package/test/unit/repo-config.test.js +40 -0
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
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
|
|
592
|
-
|
|
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
|
-
//
|
|
626
|
-
|
|
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
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
736
|
-
|
|
646
|
+
command: apiCommand,
|
|
647
|
+
success: result.success,
|
|
648
|
+
sessionId: result.sessionId,
|
|
649
|
+
error: result.error,
|
|
737
650
|
};
|
|
738
651
|
}
|
package/service/repo-config.js
CHANGED
|
@@ -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 -
|
|
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('
|
|
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.
|
|
570
|
-
assert.
|
|
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:
|