opencode-pilot 0.22.0 → 0.23.1

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.
@@ -5,7 +5,7 @@
5
5
  "ghcr.io/devcontainers/features/git:1": {},
6
6
  "ghcr.io/devcontainers/features/github-cli:1": {}
7
7
  },
8
- "postCreateCommand": "sudo apt-get update && sudo apt-get install -y bats ripgrep && npm install -g opencode-ai@latest",
8
+ "postCreateCommand": "sudo apt-get update && sudo apt-get install -y ripgrep && npm install -g opencode-ai@latest",
9
9
  "customizations": {
10
10
  "vscode": {
11
11
  "extensions": [
@@ -29,7 +29,7 @@ jobs:
29
29
 
30
30
  - name: Verify JavaScript syntax
31
31
  run: |
32
- for file in plugin/*.js; do
32
+ for file in plugin/*.js service/*.js; do
33
33
  echo "Checking $file..."
34
34
  node --check "$file"
35
35
  done
package/CONTRIBUTING.md CHANGED
@@ -1,4 +1,4 @@
1
- # Contributing to opencode-ntfy
1
+ # Contributing to opencode-pilot
2
2
 
3
3
  Thanks for your interest in contributing!
4
4
 
@@ -6,84 +6,75 @@ Thanks for your interest in contributing!
6
6
 
7
7
  1. Clone the repository:
8
8
  ```bash
9
- git clone https://github.com/athal7/opencode-ntfy.git
10
- cd opencode-ntfy
9
+ git clone https://github.com/athal7/opencode-pilot.git
10
+ cd opencode-pilot
11
11
  ```
12
12
 
13
- 2. Install the plugin locally for testing:
13
+ 2. Install dependencies:
14
14
  ```bash
15
- ./install.sh
16
- ```
17
-
18
- 3. Set required environment variables:
19
- ```bash
20
- export NTFY_TOPIC=your-test-topic
15
+ npm install
21
16
  ```
22
17
 
23
18
  ## Running Tests
24
19
 
25
- Run the full test suite:
26
20
  ```bash
27
- ./test/run_tests.bash
21
+ npm test # Unit tests
22
+ npm run test:integration # Integration tests
23
+ npm run test:all # All tests
28
24
  ```
29
25
 
30
- The test suite includes:
31
- - **File structure tests** - Verify all plugin files exist
32
- - **Syntax validation** - Run `node --check` on all JS files
33
- - **Export structure tests** - Verify expected functions are exported
34
- - **Integration tests** - Test plugin loads in OpenCode without hanging (requires opencode CLI)
26
+ Tests use the Node.js built-in test runner (`node:test`) with `node:assert`.
35
27
 
36
28
  ## Writing Tests
37
29
 
38
- Tests live in `test/` using bash test helpers from `test_helper.bash`.
30
+ Tests live in `test/unit/` and `test/integration/`. Each test file follows this pattern:
39
31
 
40
- Example test:
41
- ```bash
42
- test_my_feature() {
43
- # Use assertions from test_helper.bash
44
- assert_file_exists "$PLUGIN_DIR/myfile.js"
45
- assert_contains "$output" "expected string"
46
- }
47
-
48
- # Register and run
49
- run_test "my_feature" "test_my_feature"
50
- ```
32
+ ```js
33
+ import { test, describe } from 'node:test';
34
+ import assert from 'node:assert';
51
35
 
52
- For JavaScript unit tests, use Node.js inline:
53
- ```bash
54
- test_function_works() {
55
- node --input-type=module -e "
56
- import { myFunction } from '../plugin/module.js';
57
- if (myFunction() !== 'expected') throw new Error('Failed');
58
- console.log('PASS');
59
- " || return 1
60
- }
36
+ describe('myModule', () => {
37
+ test('does the thing', () => {
38
+ assert.strictEqual(actual, expected);
39
+ });
40
+ });
61
41
  ```
62
42
 
63
43
  ## Code Style
64
44
 
65
45
  - Use ES modules (`import`/`export`)
66
46
  - Use `async`/`await` for async operations
67
- - Log with `[opencode-ntfy]` prefix
47
+ - Log with `[opencode-pilot]` prefix
68
48
  - Handle errors gracefully (log, don't crash OpenCode)
69
- - No external dependencies (use Node.js built-ins only)
49
+ - No external dependencies beyond what's in `package.json`
70
50
 
71
- ## Plugin Architecture
51
+ ## Project Architecture
72
52
 
73
53
  ```
74
54
  plugin/
75
- ├── index.js # Main entry point, event handlers
76
- ├── notifier.js # ntfy HTTP client
77
- ├── callback.js # HTTP callback server for interactive responses
78
- ├── hostname.js # Callback host discovery (Tailscale, env, localhost)
79
- └── nonces.js # Single-use nonces for callback authentication
55
+ └── index.js # OpenCode plugin entry point (auto-starts daemon)
56
+ service/
57
+ ├── server.js # HTTP server and polling orchestration
58
+ ├── poll-service.js # Polling lifecycle management
59
+ ├── poller.js # MCP tool polling
60
+ ├── actions.js # Session creation and template expansion
61
+ ├── readiness.js # Evaluate item readiness (labels, deps, priority)
62
+ ├── worktree.js # Git worktree management
63
+ ├── repo-config.js # Repository discovery and config
64
+ ├── logger.js # Debug logging
65
+ ├── utils.js # Shared utilities
66
+ ├── version.js # Package version detection
67
+ └── presets/
68
+ ├── index.js # Preset loader
69
+ ├── github.yaml # GitHub source presets
70
+ └── linear.yaml # Linear source presets
80
71
  ```
81
72
 
82
73
  ## Submitting Changes
83
74
 
84
75
  1. Create a feature branch: `git checkout -b my-feature`
85
76
  2. Make your changes
86
- 3. Run tests: `./test/run_tests.bash`
77
+ 3. Run tests: `npm test`
87
78
  4. Commit with a clear message following conventional commits:
88
79
  - `feat(#1): add idle notifications`
89
80
  - `fix(#3): handle network timeout`
@@ -91,12 +82,10 @@ plugin/
91
82
 
92
83
  ## Releasing
93
84
 
94
- Releases are automated via GitHub Actions. To create a release:
95
-
96
- 1. Tag the commit: `git tag v1.0.0`
97
- 2. Push the tag: `git push origin v1.0.0`
85
+ Releases are automated via [semantic-release](https://github.com/semantic-release/semantic-release) on merge to `main`. The CI pipeline will:
98
86
 
99
- The release workflow will:
100
87
  1. Run tests
101
- 2. Create a tarball
102
- 3. Create a GitHub release with release notes
88
+ 2. Determine the next version from commit messages
89
+ 3. Publish to npm
90
+ 4. Create a GitHub release
91
+ 5. Update the Homebrew formula
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.21.4.tar.gz"
5
- sha256 "7d21ed495bfb2b737f6a519dc9bfcdb2ef341636b138c6450e1848fd6819c924"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.23.0.tar.gz"
5
+ sha256 "1d245b1ea0ec5db353b5558fe2dbb59de727983775464ea61d807cdbd8061087"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/README.md CHANGED
@@ -57,7 +57,7 @@ See [examples/config.yaml](examples/config.yaml) for a complete example with all
57
57
  - **`server_port`** - Preferred OpenCode server port (e.g., `4096`). When multiple OpenCode instances are running, pilot attaches sessions to this port.
58
58
  - **`startup_delay`** - Milliseconds to wait before first poll (default: `10000`). Allows OpenCode server time to fully initialize after restart.
59
59
  - **`repos_dir`** - Directory containing git repos (e.g., `~/code`). Pilot auto-discovers repos by scanning git remotes (both `origin` and `upstream` for fork support).
60
- - **`defaults`** - Default values applied to all sources
60
+ - **`defaults`** - Default values applied to all sources (`agent`, `model`, `prompt`, etc.)
61
61
  - **`sources`** - What to poll (presets, shorthand, or full config)
62
62
  - **`tools`** - Field mappings to normalize different MCP APIs
63
63
  - **`repos`** - Explicit repository paths (overrides auto-discovery from `repos_dir`)
@@ -83,6 +83,24 @@ Session names for `my-prs-attention` indicate the condition: "Conflicts: {title}
83
83
 
84
84
  Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
85
85
 
86
+ ### Model Selection
87
+
88
+ Override the default model used by the agent for pilot sessions. This avoids creating a separate agent just to use a different model.
89
+
90
+ ```yaml
91
+ defaults:
92
+ agent: plan
93
+ model: anthropic/claude-sonnet-4-20250514 # Applied to all sources
94
+
95
+ sources:
96
+ - preset: github/review-requests
97
+ model: anthropic/claude-haiku-3.5 # Override for this source only
98
+ ```
99
+
100
+ Format: `provider/model-id` (e.g., `anthropic/claude-sonnet-4-20250514`). If no provider prefix, defaults to `anthropic`.
101
+
102
+ Priority: source `model` > defaults `model` > agent's built-in default.
103
+
86
104
  ### Session and Sandbox Reuse
87
105
 
88
106
  By default, pilot reuses existing sessions and sandboxes to avoid duplicates:
@@ -19,6 +19,10 @@ repos_dir: ~/code
19
19
  defaults:
20
20
  agent: plan
21
21
  prompt: default
22
+ # Model selection: override the agent's default model
23
+ # Format: provider/model-id (e.g., anthropic/claude-sonnet-4-20250514)
24
+ # If no provider prefix, defaults to "anthropic"
25
+ # model: anthropic/claude-sonnet-4-20250514
22
26
  # Session reuse: append to existing non-archived session instead of creating new
23
27
  # Default: true. Set to false to always create new sessions.
24
28
  # reuse_active_session: true
@@ -32,6 +36,8 @@ sources:
32
36
  prompt: worktree
33
37
 
34
38
  - preset: github/review-requests
39
+ # Per-source model override (takes precedence over defaults.model)
40
+ # model: anthropic/claude-haiku-3.5
35
41
 
36
42
  # PRs needing attention (conflicts OR human feedback)
37
43
  # Session names dynamically indicate the condition: "Conflicts: ...", "Feedback: ...", or "Conflicts+Feedback: ..."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -248,6 +248,36 @@ Check for bugs and security issues.`;
248
248
  assert.strictEqual(config.working_dir, '~/workspaces');
249
249
  });
250
250
 
251
+ test('defaults model is used when source and repo have no model', async () => {
252
+ const { getActionConfig } = await import('../../service/actions.js');
253
+
254
+ const source = { name: 'my-issues' };
255
+ const repoConfig = { path: '~/code/backend' };
256
+ const defaults = { model: 'anthropic/claude-haiku-3.5' };
257
+
258
+ const config = getActionConfig(source, repoConfig, defaults);
259
+
260
+ assert.strictEqual(config.model, 'anthropic/claude-haiku-3.5');
261
+ });
262
+
263
+ test('source model overrides defaults and repo model', async () => {
264
+ const { getActionConfig } = await import('../../service/actions.js');
265
+
266
+ const source = {
267
+ name: 'my-issues',
268
+ model: 'anthropic/claude-sonnet-4-20250514'
269
+ };
270
+ const repoConfig = {
271
+ path: '~/code/backend',
272
+ model: 'anthropic/claude-haiku-3.5'
273
+ };
274
+ const defaults = { model: 'anthropic/claude-haiku-3.5' };
275
+
276
+ const config = getActionConfig(source, repoConfig, defaults);
277
+
278
+ assert.strictEqual(config.model, 'anthropic/claude-sonnet-4-20250514');
279
+ });
280
+
251
281
  });
252
282
 
253
283
  describe('buildCommand', () => {
@@ -1367,6 +1397,74 @@ Check for bugs and security issues.`;
1367
1397
  assert.strictEqual(commandBody.agent, 'code', 'Should pass agent');
1368
1398
  assert.strictEqual(commandBody.model, 'anthropic/claude-sonnet-4-20250514', 'Should pass model as string');
1369
1399
  });
1400
+
1401
+ test('passes model as providerID/modelID to /message endpoint', async () => {
1402
+ const { sendMessageToSession } = await import('../../service/actions.js');
1403
+
1404
+ let messageBody = null;
1405
+
1406
+ const mockFetch = async (url, opts) => {
1407
+ const urlObj = new URL(url);
1408
+
1409
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1410
+ messageBody = JSON.parse(opts.body);
1411
+ return {
1412
+ ok: true,
1413
+ json: async () => ({ success: true }),
1414
+ };
1415
+ }
1416
+
1417
+ return { ok: false, text: async () => 'Not found' };
1418
+ };
1419
+
1420
+ await sendMessageToSession(
1421
+ 'http://localhost:4096',
1422
+ 'ses_existing',
1423
+ '/path/to/project',
1424
+ 'Fix the bug',
1425
+ {
1426
+ fetch: mockFetch,
1427
+ model: 'anthropic/claude-haiku-3.5',
1428
+ }
1429
+ );
1430
+
1431
+ assert.strictEqual(messageBody.providerID, 'anthropic', 'Should parse provider from model');
1432
+ assert.strictEqual(messageBody.modelID, 'claude-haiku-3.5', 'Should parse model ID');
1433
+ });
1434
+
1435
+ test('defaults to anthropic provider when model has no slash', async () => {
1436
+ const { sendMessageToSession } = await import('../../service/actions.js');
1437
+
1438
+ let messageBody = null;
1439
+
1440
+ const mockFetch = async (url, opts) => {
1441
+ const urlObj = new URL(url);
1442
+
1443
+ if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1444
+ messageBody = JSON.parse(opts.body);
1445
+ return {
1446
+ ok: true,
1447
+ json: async () => ({ success: true }),
1448
+ };
1449
+ }
1450
+
1451
+ return { ok: false, text: async () => 'Not found' };
1452
+ };
1453
+
1454
+ await sendMessageToSession(
1455
+ 'http://localhost:4096',
1456
+ 'ses_existing',
1457
+ '/path/to/project',
1458
+ 'Fix the bug',
1459
+ {
1460
+ fetch: mockFetch,
1461
+ model: 'claude-haiku-3.5',
1462
+ }
1463
+ );
1464
+
1465
+ assert.strictEqual(messageBody.providerID, 'anthropic', 'Should default to anthropic provider');
1466
+ assert.strictEqual(messageBody.modelID, 'claude-haiku-3.5', 'Should use full string as model ID');
1467
+ });
1370
1468
  });
1371
1469
 
1372
1470
  describe('session reuse', () => {
@@ -204,6 +204,40 @@ sources:
204
204
  assert.strictEqual(config.worktree_name, 'issue-{number}');
205
205
  });
206
206
 
207
+ test('model from source overrides repoConfig model', async () => {
208
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
209
+
210
+ const source = {
211
+ name: 'test-source',
212
+ model: 'anthropic/claude-sonnet-4-20250514'
213
+ };
214
+ const repoConfig = {
215
+ path: '~/code/default',
216
+ model: 'anthropic/claude-haiku-3.5'
217
+ };
218
+
219
+ const config = buildActionConfigFromSource(source, repoConfig);
220
+
221
+ assert.strictEqual(config.model, 'anthropic/claude-sonnet-4-20250514');
222
+ });
223
+
224
+ test('falls back to repoConfig model when source has none', async () => {
225
+ const { buildActionConfigFromSource } = await import('../../service/poll-service.js');
226
+
227
+ const source = {
228
+ name: 'test-source'
229
+ // No model
230
+ };
231
+ const repoConfig = {
232
+ path: '~/code/default',
233
+ model: 'anthropic/claude-haiku-3.5'
234
+ };
235
+
236
+ const config = buildActionConfigFromSource(source, repoConfig);
237
+
238
+ assert.strictEqual(config.model, 'anthropic/claude-haiku-3.5');
239
+ });
240
+
207
241
  });
208
242
 
209
243
  describe('per-item repo resolution', () => {
@@ -999,6 +999,45 @@ sources:
999
999
  assert.strictEqual(sources[0].model, 'claude-3-sonnet');
1000
1000
  });
1001
1001
 
1002
+ test('defaults model flows through to sources', async () => {
1003
+ writeFileSync(configPath, `
1004
+ defaults:
1005
+ model: anthropic/claude-haiku-3.5
1006
+
1007
+ sources:
1008
+ - preset: github/my-issues
1009
+ - preset: github/review-requests
1010
+ `);
1011
+
1012
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
1013
+ loadRepoConfig(configPath);
1014
+ const sources = getSources();
1015
+
1016
+ // Both sources should inherit model from defaults
1017
+ assert.strictEqual(sources[0].model, 'anthropic/claude-haiku-3.5');
1018
+ assert.strictEqual(sources[1].model, 'anthropic/claude-haiku-3.5');
1019
+ });
1020
+
1021
+ test('source model overrides defaults model', async () => {
1022
+ writeFileSync(configPath, `
1023
+ defaults:
1024
+ model: anthropic/claude-haiku-3.5
1025
+
1026
+ sources:
1027
+ - preset: github/my-issues
1028
+ model: anthropic/claude-sonnet-4-20250514
1029
+ - preset: github/review-requests
1030
+ `);
1031
+
1032
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
1033
+ loadRepoConfig(configPath);
1034
+ const sources = getSources();
1035
+
1036
+ // First source overrides, second inherits
1037
+ assert.strictEqual(sources[0].model, 'anthropic/claude-sonnet-4-20250514');
1038
+ assert.strictEqual(sources[1].model, 'anthropic/claude-haiku-3.5');
1039
+ });
1040
+
1002
1041
  test('getDefaults returns defaults section', async () => {
1003
1042
  writeFileSync(configPath, `
1004
1043
  defaults: