opencode-pilot 0.16.5 → 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 +3 -1
- package/examples/config.yaml +12 -1
- package/package.json +1 -1
- package/service/actions.js +54 -260
- package/service/poll-service.js +29 -7
- package/service/repo-config.js +28 -13
- package/service/server.js +14 -0
- package/test/unit/actions.test.js +57 -126
- package/test/unit/repo-config.test.js +23 -0
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`** -
|
|
54
|
+
- **`repos`** - Explicit repository paths (overrides auto-discovery from `repos_dir`)
|
|
53
55
|
|
|
54
56
|
### Source Syntax
|
|
55
57
|
|
package/examples/config.yaml
CHANGED
|
@@ -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
|
-
#
|
|
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
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";
|
|
@@ -290,164 +290,20 @@ export function getActionConfig(source, repoConfig, defaults) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
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 -
|
|
296
|
-
* @
|
|
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
|
|
301
|
-
|
|
302
|
-
const
|
|
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
|
-
|
|
417
|
-
return cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
|
|
418
|
-
}
|
|
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
|
-
});
|
|
306
|
+
return `[API] POST /session?directory=${cwd} (title: "${sessionName}")`;
|
|
451
307
|
}
|
|
452
308
|
|
|
453
309
|
/**
|
|
@@ -595,8 +451,19 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
595
451
|
* @returns {Promise<object>} Result with command, stdout, stderr, exitCode
|
|
596
452
|
*/
|
|
597
453
|
export async function executeAction(item, config, options = {}) {
|
|
598
|
-
// Get base working directory
|
|
599
|
-
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
|
+
|
|
600
467
|
const baseCwd = expandPath(workingDir);
|
|
601
468
|
|
|
602
469
|
// Discover running opencode server for this directory
|
|
@@ -605,13 +472,22 @@ export async function executeAction(item, config, options = {}) {
|
|
|
605
472
|
|
|
606
473
|
debug(`executeAction: discovered server=${serverUrl} for baseCwd=${baseCwd}`);
|
|
607
474
|
|
|
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
|
|
477
|
+
if (!serverUrl) {
|
|
478
|
+
return {
|
|
479
|
+
success: false,
|
|
480
|
+
skipped: true,
|
|
481
|
+
error: 'No OpenCode server found. Will retry on next poll.',
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
608
485
|
// Resolve worktree directory if configured
|
|
609
486
|
// This allows creating sessions in isolated worktrees instead of the main project
|
|
610
487
|
let worktreeMode = config.worktree;
|
|
611
488
|
|
|
612
|
-
// Auto-detect worktree support: if
|
|
613
|
-
|
|
614
|
-
if (!worktreeMode && serverUrl) {
|
|
489
|
+
// Auto-detect worktree support: check if the project has sandboxes
|
|
490
|
+
if (!worktreeMode) {
|
|
615
491
|
// Look up project info for this specific directory (not just /project/current)
|
|
616
492
|
const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
|
|
617
493
|
if (projectInfo?.sandboxes?.length > 0) {
|
|
@@ -643,117 +519,35 @@ export async function executeAction(item, config, options = {}) {
|
|
|
643
519
|
|
|
644
520
|
debug(`executeAction: using cwd=${cwd}`);
|
|
645
521
|
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
// See: https://github.com/anomalyco/opencode/issues/7376
|
|
649
|
-
if (serverUrl) {
|
|
650
|
-
// Build prompt from template
|
|
651
|
-
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
652
|
-
|
|
653
|
-
// Build session title
|
|
654
|
-
const sessionTitle = config.session?.name
|
|
655
|
-
? buildSessionName(config.session.name, item)
|
|
656
|
-
: (item.title || `session-${Date.now()}`);
|
|
657
|
-
|
|
658
|
-
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
659
|
-
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
660
|
-
|
|
661
|
-
if (options.dryRun) {
|
|
662
|
-
return {
|
|
663
|
-
command: apiCommand,
|
|
664
|
-
dryRun: true,
|
|
665
|
-
method: 'api',
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const result = await createSessionViaApi(serverUrl, cwd, prompt, {
|
|
670
|
-
title: sessionTitle,
|
|
671
|
-
agent: config.agent,
|
|
672
|
-
model: config.model,
|
|
673
|
-
fetch: options.fetch,
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
return {
|
|
677
|
-
command: apiCommand,
|
|
678
|
-
success: result.success,
|
|
679
|
-
sessionId: result.sessionId,
|
|
680
|
-
error: result.error,
|
|
681
|
-
method: 'api',
|
|
682
|
-
};
|
|
683
|
-
}
|
|
522
|
+
// Build prompt from template
|
|
523
|
+
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
684
524
|
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
525
|
+
// Build session title
|
|
526
|
+
const sessionTitle = config.session?.name
|
|
527
|
+
? buildSessionName(config.session.name, item)
|
|
528
|
+
: (item.title || `session-${Date.now()}`);
|
|
529
|
+
|
|
530
|
+
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
531
|
+
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
688
532
|
|
|
689
|
-
// Build command string for display
|
|
690
|
-
const quoteArgs = (args) => args.map(a =>
|
|
691
|
-
a.includes(" ") || a.includes("\n") ? `"${a.replace(/"/g, '\\"')}"` : a
|
|
692
|
-
).join(" ");
|
|
693
|
-
const cmdStr = quoteArgs(cmdInfo.args);
|
|
694
|
-
const command = cmdInfo.cwd ? `(cd ${cmdInfo.cwd} && ${cmdStr})` : cmdStr;
|
|
695
|
-
|
|
696
|
-
debug(`executeAction: command=${command}`);
|
|
697
|
-
debug(`executeAction: args=${JSON.stringify(cmdInfo.args)}, cwd=${cmdInfo.cwd}`);
|
|
698
|
-
|
|
699
533
|
if (options.dryRun) {
|
|
700
534
|
return {
|
|
701
|
-
command,
|
|
535
|
+
command: apiCommand,
|
|
702
536
|
dryRun: true,
|
|
703
|
-
method: 'spawn',
|
|
704
537
|
};
|
|
705
538
|
}
|
|
706
|
-
|
|
707
|
-
// Execute opencode run in background (detached)
|
|
708
|
-
// We don't wait for completion since sessions can run for a long time
|
|
709
|
-
debug(`executeAction: spawning opencode run (detached)`);
|
|
710
|
-
const [cmd, ...cmdArgs] = cmdInfo.args;
|
|
711
|
-
const child = spawn(cmd, cmdArgs, {
|
|
712
|
-
cwd: cmdInfo.cwd,
|
|
713
|
-
detached: true,
|
|
714
|
-
stdio: 'ignore',
|
|
715
|
-
});
|
|
716
|
-
child.unref();
|
|
717
539
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
method: 'spawn',
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Check if opencode is available
|
|
729
|
-
* @returns {Promise<boolean>}
|
|
730
|
-
*/
|
|
731
|
-
export async function checkOpencode() {
|
|
732
|
-
return new Promise((resolve) => {
|
|
733
|
-
const child = spawn("which", ["opencode"]);
|
|
734
|
-
child.on("close", (code) => {
|
|
735
|
-
resolve(code === 0);
|
|
736
|
-
});
|
|
737
|
-
child.on("error", () => {
|
|
738
|
-
resolve(false);
|
|
739
|
-
});
|
|
540
|
+
const result = await createSessionViaApi(serverUrl, cwd, prompt, {
|
|
541
|
+
title: sessionTitle,
|
|
542
|
+
agent: config.agent,
|
|
543
|
+
model: config.model,
|
|
544
|
+
fetch: options.fetch,
|
|
740
545
|
});
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Validate that required tools are available
|
|
745
|
-
* @returns {Promise<object>} { valid: boolean, missing?: string[] }
|
|
746
|
-
*/
|
|
747
|
-
export async function validateTools() {
|
|
748
|
-
const missing = [];
|
|
749
|
-
|
|
750
|
-
const hasOpencode = await checkOpencode();
|
|
751
|
-
if (!hasOpencode) {
|
|
752
|
-
missing.push("opencode");
|
|
753
|
-
}
|
|
754
|
-
|
|
546
|
+
|
|
755
547
|
return {
|
|
756
|
-
|
|
757
|
-
|
|
548
|
+
command: apiCommand,
|
|
549
|
+
success: result.success,
|
|
550
|
+
sessionId: result.sessionId,
|
|
551
|
+
error: result.error,
|
|
758
552
|
};
|
|
759
553
|
}
|
package/service/poll-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
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);
|
package/service/repo-config.js
CHANGED
|
@@ -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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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('
|
|
164
|
-
test('builds
|
|
165
|
-
|
|
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'
|
|
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
|
|
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.
|
|
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('
|
|
203
|
-
const {
|
|
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
|
|
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');
|
|
228
|
-
|
|
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');
|
|
186
|
+
const result = buildCommand(item, config);
|
|
282
187
|
|
|
283
|
-
|
|
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
|
|
|
@@ -543,13 +438,12 @@ describe('actions.js', () => {
|
|
|
543
438
|
});
|
|
544
439
|
|
|
545
440
|
assert.ok(result.dryRun);
|
|
546
|
-
assert.strictEqual(result.method, 'api', 'Should use API method when server found');
|
|
547
441
|
assert.ok(result.command.includes('POST'), 'Command should show POST request');
|
|
548
442
|
assert.ok(result.command.includes('http://localhost:4096'), 'Command should include server URL');
|
|
549
443
|
assert.ok(result.command.includes('directory='), 'Command should include directory param');
|
|
550
444
|
});
|
|
551
445
|
|
|
552
|
-
test('
|
|
446
|
+
test('returns error when no server discovered', async () => {
|
|
553
447
|
const { executeAction } = await import('../../service/actions.js');
|
|
554
448
|
|
|
555
449
|
const item = { number: 123, title: 'Fix bug' };
|
|
@@ -566,10 +460,50 @@ describe('actions.js', () => {
|
|
|
566
460
|
discoverServer: mockDiscoverServer
|
|
567
461
|
});
|
|
568
462
|
|
|
569
|
-
assert.
|
|
570
|
-
assert.
|
|
571
|
-
|
|
572
|
-
|
|
463
|
+
assert.strictEqual(result.success, false, 'Should fail when no server');
|
|
464
|
+
assert.ok(result.error.includes('No OpenCode server'), 'Should have descriptive error');
|
|
465
|
+
});
|
|
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');
|
|
573
507
|
});
|
|
574
508
|
|
|
575
509
|
test('creates new worktree when worktree: "new" is configured (dry run)', async () => {
|
|
@@ -615,7 +549,6 @@ describe('actions.js', () => {
|
|
|
615
549
|
});
|
|
616
550
|
|
|
617
551
|
assert.ok(result.dryRun);
|
|
618
|
-
assert.strictEqual(result.method, 'api', 'Should use API method');
|
|
619
552
|
// The directory in the command should be the worktree directory
|
|
620
553
|
assert.ok(result.command.includes('/data/worktree/proj123/feature-branch'),
|
|
621
554
|
'Should use worktree directory in command');
|
|
@@ -655,7 +588,6 @@ describe('actions.js', () => {
|
|
|
655
588
|
});
|
|
656
589
|
|
|
657
590
|
assert.ok(result.dryRun);
|
|
658
|
-
assert.strictEqual(result.method, 'api', 'Should use API method');
|
|
659
591
|
assert.ok(result.command.includes('/data/worktree/proj123/my-feature'),
|
|
660
592
|
'Should use looked up worktree path in command');
|
|
661
593
|
});
|
|
@@ -692,7 +624,6 @@ describe('actions.js', () => {
|
|
|
692
624
|
});
|
|
693
625
|
|
|
694
626
|
assert.ok(result.dryRun);
|
|
695
|
-
assert.strictEqual(result.method, 'api', 'Should still use API method');
|
|
696
627
|
// Should fall back to base directory
|
|
697
628
|
assert.ok(result.command.includes(tempDir),
|
|
698
629
|
'Should fall back to base directory when worktree creation fails');
|
|
@@ -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', () => {
|