opencode-pilot 0.26.0 → 0.27.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.
@@ -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.25.1.tar.gz"
5
- sha256 "f8be5b0d08bdd45d5c0155bf40e8930c431fc7a6e6ec77d422bb89471fe91cdc"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.26.0.tar.gz"
5
+ sha256 "2dc50f9d92e16bcbc3e5956f26bf53d3613c0f3ce756204ea050bf0faba4d9f4"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -35,6 +35,62 @@ import os from "os";
35
35
  // These are generous upper bounds — if exceeded, the server is genuinely stuck.
36
36
  export const HEADER_TIMEOUT_MS = 60_000;
37
37
 
38
+ /**
39
+ * Read model configuration from a target directory's OpenCode config file.
40
+ *
41
+ * Looks for `.opencode/opencode.json` (or `.jsonc`) in `dir`. If an `agentName`
42
+ * is provided, the per-agent model (`.agent.<name>.model`) takes precedence
43
+ * over the global `.model` value. Returns `{}` when no file is found or the
44
+ * file cannot be parsed.
45
+ *
46
+ * @param {string} dir - Absolute path to the target project directory
47
+ * @param {string} [agentName] - Optional agent name for per-agent model lookup
48
+ * @returns {{ model?: string }} Extracted config fields
49
+ */
50
+ export function readTargetOpencodeConfig(dir, agentName) {
51
+ const candidates = [
52
+ path.join(dir, '.opencode', 'opencode.json'),
53
+ path.join(dir, '.opencode', 'opencode.jsonc'),
54
+ ];
55
+
56
+ let raw;
57
+ for (const candidate of candidates) {
58
+ if (existsSync(candidate)) {
59
+ try {
60
+ raw = readFileSync(candidate, 'utf-8');
61
+ } catch {
62
+ return {};
63
+ }
64
+ break;
65
+ }
66
+ }
67
+
68
+ if (!raw) return {};
69
+
70
+ let parsed;
71
+ try {
72
+ // Strip single-line comments for .jsonc support before parsing
73
+ const stripped = raw.replace(/\/\/[^\n]*/g, '');
74
+ parsed = JSON.parse(stripped);
75
+ } catch {
76
+ return {};
77
+ }
78
+
79
+ const result = {};
80
+
81
+ // Per-agent model takes priority over global model
82
+ const agentModel = agentName && parsed.agent?.[agentName]?.model;
83
+ const globalModel = parsed.model;
84
+
85
+ if (agentModel) {
86
+ result.model = agentModel;
87
+ } else if (globalModel) {
88
+ result.model = globalModel;
89
+ }
90
+
91
+ return result;
92
+ }
93
+
38
94
  /**
39
95
  * Parse a slash command from the beginning of a prompt
40
96
  * Returns null if the prompt doesn't start with a slash command
@@ -367,9 +423,11 @@ export function buildPromptFromTemplate(templateName, item, templatesDir) {
367
423
  * @param {object} source - Source configuration (may include _explicit tracking from normalizeSource)
368
424
  * @param {object} repoConfig - Repository configuration
369
425
  * @param {object} defaults - Default configuration
426
+ * @param {object} [targetDirConfig] - Optional model config read from the target dir's opencode config.
427
+ * Used as a low-priority fallback: only applied when no model is set by source, repo, or defaults.
370
428
  * @returns {object} Merged action config
371
429
  */
372
- export function getActionConfig(source, repoConfig, defaults) {
430
+ export function getActionConfig(source, repoConfig, defaults, targetDirConfig = {}) {
373
431
  // _explicit tracks fields set directly on the source (not inherited from defaults).
374
432
  // When _explicit is absent (e.g., tests constructing source objects directly), fall
375
433
  // back to treating non-undefined source fields as explicit.
@@ -379,12 +437,14 @@ export function getActionConfig(source, repoConfig, defaults) {
379
437
  if (explicit) {
380
438
  if (explicit[field] !== undefined) return explicit[field];
381
439
  if (repoConfig[field] !== undefined) return repoConfig[field];
382
- return defaults[field];
440
+ if (defaults[field] !== undefined) return defaults[field];
441
+ return targetDirConfig[field];
383
442
  }
384
- // No tracking: source wins, then repo, then defaults
443
+ // No tracking: source wins, then repo, then defaults, then target dir
385
444
  if (source[field] !== undefined) return source[field];
386
445
  if (repoConfig[field] !== undefined) return repoConfig[field];
387
- return defaults[field];
446
+ if (defaults[field] !== undefined) return defaults[field];
447
+ return targetDirConfig[field];
388
448
  };
389
449
 
390
450
  return {
@@ -977,6 +1037,8 @@ async function executeInDirectory(serverUrl, sessionCtx, item, config, options =
977
1037
  command: apiCommand,
978
1038
  directory: cwd,
979
1039
  dryRun: true,
1040
+ ...(config.model && { model: config.model }),
1041
+ ...(config.agent && { agent: config.agent }),
980
1042
  };
981
1043
  }
982
1044
 
@@ -1022,6 +1084,17 @@ export async function executeAction(item, config, options = {}) {
1022
1084
  }
1023
1085
 
1024
1086
  const baseCwd = expandPath(workingDir);
1087
+
1088
+ // Apply target directory's opencode config as a low-priority fallback for model.
1089
+ // This reads <baseCwd>/.opencode/opencode.json and uses its model settings only
1090
+ // when no model is specified in the pilot config (source > repo > defaults).
1091
+ if (!config.model) {
1092
+ const targetDirConfig = readTargetOpencodeConfig(baseCwd, config.agent);
1093
+ if (targetDirConfig.model) {
1094
+ debug(`executeAction: using target dir model=${targetDirConfig.model} from ${baseCwd}/.opencode/opencode.json`);
1095
+ config = { ...config, model: targetDirConfig.model };
1096
+ }
1097
+ }
1025
1098
 
1026
1099
  // Discover running opencode server for this directory
1027
1100
  const discoverFn = options.discoverServer || discoverOpencodeServer;
@@ -281,6 +281,121 @@ Check for bugs and security issues.`;
281
281
 
282
282
  });
283
283
 
284
+ describe('readTargetOpencodeConfig', () => {
285
+ test('returns global model from .opencode/opencode.json', async () => {
286
+ const { readTargetOpencodeConfig } = await import('../../service/actions.js');
287
+
288
+ const opencodeDir = join(tempDir, '.opencode');
289
+ mkdirSync(opencodeDir);
290
+ writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
291
+ model: 'anthropic/claude-haiku-3.5'
292
+ }));
293
+
294
+ const result = readTargetOpencodeConfig(tempDir);
295
+ assert.strictEqual(result.model, 'anthropic/claude-haiku-3.5');
296
+ });
297
+
298
+ test('returns per-agent model from .opencode/opencode.json', async () => {
299
+ const { readTargetOpencodeConfig } = await import('../../service/actions.js');
300
+
301
+ const opencodeDir = join(tempDir, '.opencode');
302
+ mkdirSync(opencodeDir);
303
+ writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
304
+ model: 'anthropic/claude-sonnet-4-20250514',
305
+ agent: {
306
+ plan: { model: 'anthropic/claude-haiku-3.5' }
307
+ }
308
+ }));
309
+
310
+ const result = readTargetOpencodeConfig(tempDir, 'plan');
311
+ assert.strictEqual(result.model, 'anthropic/claude-haiku-3.5');
312
+ });
313
+
314
+ test('falls back to global model when agent has no model', async () => {
315
+ const { readTargetOpencodeConfig } = await import('../../service/actions.js');
316
+
317
+ const opencodeDir = join(tempDir, '.opencode');
318
+ mkdirSync(opencodeDir);
319
+ writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
320
+ model: 'anthropic/claude-opus-4',
321
+ agent: {
322
+ plan: { temperature: 0.5 }
323
+ }
324
+ }));
325
+
326
+ const result = readTargetOpencodeConfig(tempDir, 'plan');
327
+ assert.strictEqual(result.model, 'anthropic/claude-opus-4');
328
+ });
329
+
330
+ test('returns empty object when no .opencode/opencode.json exists', async () => {
331
+ const { readTargetOpencodeConfig } = await import('../../service/actions.js');
332
+
333
+ const result = readTargetOpencodeConfig(tempDir);
334
+ assert.deepStrictEqual(result, {});
335
+ });
336
+
337
+ test('returns empty object when .opencode/opencode.json is malformed', async () => {
338
+ const { readTargetOpencodeConfig } = await import('../../service/actions.js');
339
+
340
+ const opencodeDir = join(tempDir, '.opencode');
341
+ mkdirSync(opencodeDir);
342
+ writeFileSync(join(opencodeDir, 'opencode.json'), 'not valid json {{{');
343
+
344
+ const result = readTargetOpencodeConfig(tempDir);
345
+ assert.deepStrictEqual(result, {});
346
+ });
347
+ });
348
+
349
+ describe('getActionConfig with target dir opencode config', () => {
350
+ test('uses target-dir model when no pilot model is set', async () => {
351
+ const { getActionConfig } = await import('../../service/actions.js');
352
+
353
+ const source = { name: 'my-issues' };
354
+ const repoConfig = { path: tempDir };
355
+ const defaults = {};
356
+ const targetDirConfig = { model: 'anthropic/claude-haiku-3.5' };
357
+
358
+ const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
359
+ assert.strictEqual(config.model, 'anthropic/claude-haiku-3.5');
360
+ });
361
+
362
+ test('pilot model overrides target-dir model', async () => {
363
+ const { getActionConfig } = await import('../../service/actions.js');
364
+
365
+ const source = { name: 'my-issues', model: 'anthropic/claude-opus-4' };
366
+ const repoConfig = { path: tempDir };
367
+ const defaults = {};
368
+ const targetDirConfig = { model: 'anthropic/claude-haiku-3.5' };
369
+
370
+ const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
371
+ assert.strictEqual(config.model, 'anthropic/claude-opus-4');
372
+ });
373
+
374
+ test('defaults model overrides target-dir model', async () => {
375
+ const { getActionConfig } = await import('../../service/actions.js');
376
+
377
+ const source = { name: 'my-issues' };
378
+ const repoConfig = { path: tempDir };
379
+ const defaults = { model: 'anthropic/claude-sonnet-4-20250514' };
380
+ const targetDirConfig = { model: 'anthropic/claude-haiku-3.5' };
381
+
382
+ const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
383
+ assert.strictEqual(config.model, 'anthropic/claude-sonnet-4-20250514');
384
+ });
385
+
386
+ test('target-dir model used when only defaults have no model', async () => {
387
+ const { getActionConfig } = await import('../../service/actions.js');
388
+
389
+ const source = { name: 'my-issues' };
390
+ const repoConfig = { path: tempDir };
391
+ const defaults = {};
392
+ const targetDirConfig = { model: 'openai/gpt-4o' };
393
+
394
+ const config = getActionConfig(source, repoConfig, defaults, targetDirConfig);
395
+ assert.strictEqual(config.model, 'openai/gpt-4o');
396
+ });
397
+ });
398
+
284
399
  describe('buildCommand', () => {
285
400
  test('builds display string for API call', async () => {
286
401
  const { buildCommand } = await import('../../service/actions.js');
@@ -989,6 +1104,64 @@ Check for bugs and security issues.`;
989
1104
  assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
990
1105
  'Result should include worktree directory');
991
1106
  });
1107
+
1108
+ test('uses target dir opencode config model when no pilot model set (dry run)', async () => {
1109
+ const { executeAction } = await import('../../service/actions.js');
1110
+
1111
+ // Write .opencode/opencode.json into the temp project dir
1112
+ const opencodeDir = join(tempDir, '.opencode');
1113
+ mkdirSync(opencodeDir, { recursive: true });
1114
+ writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
1115
+ model: 'anthropic/claude-haiku-3.5'
1116
+ }));
1117
+
1118
+ const item = { number: 1, title: 'Fix bug' };
1119
+ const config = {
1120
+ path: tempDir,
1121
+ prompt: 'default',
1122
+ agent: 'plan',
1123
+ // No model set in pilot config
1124
+ };
1125
+
1126
+ const mockDiscoverServer = async () => 'http://localhost:4096';
1127
+ const result = await executeAction(item, config, {
1128
+ dryRun: true,
1129
+ discoverServer: mockDiscoverServer,
1130
+ });
1131
+
1132
+ assert.ok(result.dryRun);
1133
+ assert.strictEqual(result.model, 'anthropic/claude-haiku-3.5',
1134
+ 'Should use target dir model as fallback');
1135
+ });
1136
+
1137
+ test('target dir opencode config model does not override pilot model (dry run)', async () => {
1138
+ const { executeAction } = await import('../../service/actions.js');
1139
+
1140
+ // Write .opencode/opencode.json into the temp project dir
1141
+ const opencodeDir = join(tempDir, '.opencode');
1142
+ mkdirSync(opencodeDir, { recursive: true });
1143
+ writeFileSync(join(opencodeDir, 'opencode.json'), JSON.stringify({
1144
+ model: 'anthropic/claude-haiku-3.5'
1145
+ }));
1146
+
1147
+ const item = { number: 1, title: 'Fix bug' };
1148
+ const config = {
1149
+ path: tempDir,
1150
+ prompt: 'default',
1151
+ agent: 'plan',
1152
+ model: 'anthropic/claude-opus-4', // Pilot model is set — should win
1153
+ };
1154
+
1155
+ const mockDiscoverServer = async () => 'http://localhost:4096';
1156
+ const result = await executeAction(item, config, {
1157
+ dryRun: true,
1158
+ discoverServer: mockDiscoverServer,
1159
+ });
1160
+
1161
+ assert.ok(result.dryRun);
1162
+ assert.strictEqual(result.model, 'anthropic/claude-opus-4',
1163
+ 'Pilot model should override target dir model');
1164
+ });
992
1165
  });
993
1166
 
994
1167
  describe('createSessionViaApi', () => {