acdev 1.0.9 → 1.0.11

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.
@@ -13,6 +13,9 @@
13
13
  # An API key takes precedence over subscription login:
14
14
  # ANTHROPIC_API_KEY=sk-ant-your-key-here
15
15
  #
16
+ # OpenRouter (Settings → switch LLM provider to OpenRouter):
17
+ # OPENROUTER_API_KEY=sk-or-your-key-here
18
+ #
16
19
  # Jira Cloud (only when ticketSource is "jira" in Settings / config.json):
17
20
  # JIRA_BASE_URL=https://your-domain.atlassian.net
18
21
  # JIRA_EMAIL=you@company.com
package/bin/acdev.js CHANGED
@@ -4,8 +4,9 @@ import { execSync } from 'node:child_process';
4
4
  import http from 'node:http';
5
5
  import open from 'open';
6
6
  import { checkClaudeAuth, formatClaudeAuthError } from '../src/claude-auth.js';
7
+ import { checkOpenRouterAuth, formatOpenRouterAuthError } from '../src/openrouter-auth.js';
7
8
  import { checkGhAuth, formatGhAuthError } from '../src/gh-auth.js';
8
- import { loadConfig } from '../src/config.js';
9
+ import { loadConfig, normalizeLlmProvider } from '../src/config.js';
9
10
  import { loadEnv } from '../src/env.js';
10
11
  import { migrateLegacyDataDir, migrateLegacyWorktreesDir } from '../src/paths.js';
11
12
  import { Store } from '../src/store.js';
@@ -75,9 +76,16 @@ async function main() {
75
76
  }
76
77
 
77
78
  if (!opts.stubAgent) {
78
- const claudeAuth = checkClaudeAuth();
79
- if (!claudeAuth.ok) {
80
- console.warn(formatClaudeAuthError(claudeAuth));
79
+ if (normalizeLlmProvider(config.llmProvider) === 'openrouter') {
80
+ const openrouterAuth = checkOpenRouterAuth();
81
+ if (!openrouterAuth.ok) {
82
+ console.warn(formatOpenRouterAuthError(openrouterAuth));
83
+ }
84
+ } else {
85
+ const claudeAuth = checkClaudeAuth();
86
+ if (!claudeAuth.ok) {
87
+ console.warn(formatClaudeAuthError(claudeAuth));
88
+ }
81
89
  }
82
90
  } else {
83
91
  console.log('✓ Stub agent enabled (LLM auth not required)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,11 +22,13 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@anthropic-ai/claude-agent-sdk": "0.1.77",
25
+ "@openrouter/agent": "^0.11.0",
25
26
  "dotenv": "^17.4.2",
26
27
  "express": "^4.21.2",
27
28
  "open": "^10.1.0",
28
29
  "simple-git": "^3.27.0",
29
- "uuid": "^11.1.0"
30
+ "uuid": "^11.1.0",
31
+ "zod": "^4.5.4"
30
32
  },
31
33
  "publishConfig": {
32
34
  "access": "public"
package/public/app.js CHANGED
@@ -160,6 +160,8 @@ let currentModel = 'claude-sonnet-5';
160
160
  let availableModels = [];
161
161
  /** @type {'github' | 'jira'} */
162
162
  let ticketSource = 'github';
163
+ /** @type {'claude' | 'openrouter'} */
164
+ let llmProvider = 'claude';
163
165
  /** @type {{
164
166
  * repoName?: string,
165
167
  * baseBranch?: string,
@@ -170,6 +172,8 @@ let ticketSource = 'github';
170
172
  * knownTools?: string[],
171
173
  * models?: Array<{id:string,label:string}>,
172
174
  * model?: string,
175
+ * llmProvider?: 'claude' | 'openrouter',
176
+ * lastModelsByProvider?: { claude?: string, openrouter?: string },
173
177
  * ticketSource?: 'github' | 'jira',
174
178
  * jiraBaseUrl?: string,
175
179
  * jiraEmail?: string | null,
@@ -190,6 +194,9 @@ let ticketSource = 'github';
190
194
  * anthropicApiKeyMasked?: string | null,
191
195
  * claudeOauthTokenSet?: boolean,
192
196
  * claudeOauthTokenMasked?: string | null,
197
+ * openrouterApiKeySet?: boolean,
198
+ * openrouterApiKeyMasked?: string | null,
199
+ * openrouterAuthOk?: boolean,
193
200
  * llmAuthOk?: boolean,
194
201
  * stubAgent?: boolean,
195
202
  * }} */
@@ -228,6 +235,7 @@ const els = {
228
235
  enqueueJiraSettingsLink: document.getElementById('enqueue-jira-settings-link'),
229
236
  overviewTicketSource: document.getElementById('overview-ticket-source'),
230
237
  enqueueFeedback: document.getElementById('enqueue-feedback'),
238
+ preferredBranch: document.getElementById('preferred-branch'),
231
239
  addBtn: document.getElementById('add-btn'),
232
240
  statsGrid: document.getElementById('stats-grid'),
233
241
  overviewRunning: document.getElementById('overview-running'),
@@ -330,6 +338,13 @@ const els = {
330
338
  settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
331
339
  settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
332
340
  settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
341
+ settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
342
+ settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
343
+ settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
344
+ settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
345
+ settingsLlmProvider: document.getElementById('settings-llm-provider'),
346
+ overviewLlmProvider: document.getElementById('overview-llm-provider'),
347
+ sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
333
348
  settingsTabs: document.getElementById('settings-tabs'),
334
349
  };
335
350
 
@@ -900,16 +915,22 @@ function jobTitle(job) {
900
915
  return job.issueTitle || truncate(job.issueUrl, 48);
901
916
  }
902
917
 
918
+ function jobDisplayBranch(job) {
919
+ return job.branchName || job.preferredBranchName || '';
920
+ }
921
+
903
922
  function jobRepoLine(job) {
904
923
  if (isJiraJob(job)) {
905
924
  const key = parseJiraKeyFromJob(job) || '—';
906
- const branch = job.branchName ? ` · ${job.branchName}` : '';
925
+ const name = jobDisplayBranch(job);
926
+ const branch = name ? ` · ${name}` : '';
907
927
  return { repo: 'Jira', number: key, branch, text: `Jira ${key}${branch}`, isJira: true };
908
928
  }
909
929
  const ref = parseIssueRef(job.issueUrl);
910
930
  const repo = ref?.full || '—';
911
931
  const num = ref?.number || job.issueNumber || '—';
912
- const branch = job.branchName ? ` · ${job.branchName}` : '';
932
+ const name = jobDisplayBranch(job);
933
+ const branch = name ? ` · ${name}` : '';
913
934
  return { repo, number: num, branch, text: `${repo} #${num}${branch}`, isJira: false };
914
935
  }
915
936
 
@@ -2696,6 +2717,7 @@ function jobMatchesReviewSearch(job, q) {
2696
2717
  job.issueTitle,
2697
2718
  job.issueUrl,
2698
2719
  job.branchName,
2720
+ job.preferredBranchName,
2699
2721
  job.prTitle,
2700
2722
  job.prUrl,
2701
2723
  ref?.number,
@@ -3000,6 +3022,7 @@ function updateModelLabel() {
3000
3022
  ? 'none selected'
3001
3023
  : currentModel;
3002
3024
  const combined = agentDisplayText(currentModel);
3025
+ const providerLabel = llmProvider === 'openrouter' ? 'OpenRouter' : 'Claude';
3003
3026
 
3004
3027
  if (els.modelCurrent) {
3005
3028
  els.modelCurrent.textContent = modelText;
@@ -3007,7 +3030,21 @@ function updateModelLabel() {
3007
3030
  }
3008
3031
  if (els.modelCombined) {
3009
3032
  els.modelCombined.textContent = combined;
3010
- els.modelCombined.title = combined;
3033
+ els.modelCombined.title = `${providerLabel}: ${combined}`;
3034
+ }
3035
+ if (els.modelProvider) {
3036
+ els.modelProvider.textContent = providerLabel;
3037
+ }
3038
+ if (els.sidebarAgentLabel) {
3039
+ els.sidebarAgentLabel.textContent = providerLabel;
3040
+ }
3041
+ if (els.overviewAgentIcon) {
3042
+ els.overviewAgentIcon.classList.toggle('agent-pill-icon--claude', llmProvider !== 'openrouter');
3043
+ els.overviewAgentIcon.classList.toggle('agent-pill-icon--openrouter', llmProvider === 'openrouter');
3044
+ }
3045
+ const pillLabel = els.overviewAgentTrigger?.querySelector('.agent-pill-label');
3046
+ if (pillLabel) {
3047
+ pillLabel.textContent = providerLabel;
3011
3048
  }
3012
3049
  syncAgentPickerDisplay();
3013
3050
  refreshModelSourceHint();
@@ -3513,6 +3550,16 @@ function reconcileModelForProvider(models, candidate = currentModel, storedCandi
3513
3550
  * @returns {Array<{id:string,label?:string,name?:string}>}
3514
3551
  */
3515
3552
  function defaultModels() {
3553
+ if (llmProvider === 'openrouter') {
3554
+ return [
3555
+ { id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
3556
+ { id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
3557
+ { id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
3558
+ { id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
3559
+ { id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
3560
+ { id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
3561
+ ];
3562
+ }
3516
3563
  return [
3517
3564
  { id: 'claude-sonnet-5', label: 'Sonnet 5' },
3518
3565
  { id: 'claude-opus-5', label: 'Opus 5' },
@@ -3524,16 +3571,24 @@ function defaultModels() {
3524
3571
  }
3525
3572
 
3526
3573
  /**
3527
- * @param {'anthropic' | 'fallback' | string | undefined} source
3574
+ * @param {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback' | string | undefined} source
3528
3575
  */
3529
3576
  function updateModelSourceHint(source) {
3530
3577
  if (!els.settingsModelHint) return;
3531
- if (source === 'anthropic') {
3578
+ if (source === 'openrouter') {
3579
+ modelSourceHintBase =
3580
+ 'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
3581
+ } else if (source === 'openrouter-fallback') {
3582
+ modelSourceHintBase =
3583
+ 'OpenRouter model for agent runs. Showing a short fallback list (live catalog unavailable).';
3584
+ } else if (source === 'anthropic') {
3532
3585
  modelSourceHintBase =
3533
3586
  'Claude model for agent runs. List loaded from Anthropic Models API.';
3534
3587
  } else if (source === 'fallback') {
3535
3588
  modelSourceHintBase =
3536
3589
  'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3590
+ } else if (llmProvider === 'openrouter') {
3591
+ modelSourceHintBase = 'OpenRouter model for agent runs.';
3537
3592
  } else {
3538
3593
  modelSourceHintBase = 'Claude model for agent runs.';
3539
3594
  }
@@ -3603,6 +3658,7 @@ async function fetchModels(opts = {}) {
3603
3658
  }
3604
3659
  const query = new URLSearchParams();
3605
3660
  if (refresh) query.set('refresh', '1');
3661
+ if (llmProvider) query.set('provider', llmProvider);
3606
3662
  try {
3607
3663
  const res = await fetch(`/api/models?${query.toString()}`);
3608
3664
  if (!res.ok) {
@@ -3745,6 +3801,13 @@ function claudeStatusText(cfg) {
3745
3801
  }
3746
3802
  }
3747
3803
 
3804
+ function openrouterStatusText(cfg) {
3805
+ if (cfg.openrouterAuthOk) {
3806
+ return 'Authenticated via OPENROUTER_API_KEY';
3807
+ }
3808
+ return 'Not authenticated — add an OpenRouter API key';
3809
+ }
3810
+
3748
3811
  function fillAuthSettings(cfg) {
3749
3812
  if (els.settingsGhStatus) {
3750
3813
  els.settingsGhStatus.textContent = ghStatusText(cfg);
@@ -3803,6 +3866,25 @@ function fillAuthSettings(cfg) {
3803
3866
  ? 'OAuth token stored in <code>.acdev/.env</code> as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.'
3804
3867
  : 'From <code>claude setup-token</code> for non-interactive subscription auth. Stored as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.';
3805
3868
  }
3869
+
3870
+ if (els.settingsOpenrouterStatus) {
3871
+ els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
3872
+ els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
3873
+ ? 'auth-status ok'
3874
+ : 'auth-status err';
3875
+ }
3876
+ fillSecretInput(
3877
+ els.settingsOpenrouterKey,
3878
+ els.settingsOpenrouterKeyClear,
3879
+ Boolean(cfg.openrouterApiKeySet),
3880
+ cfg.openrouterApiKeyMasked,
3881
+ 'Paste an OpenRouter API key'
3882
+ );
3883
+ if (els.settingsOpenrouterKeyHint) {
3884
+ els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
3885
+ ? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed). Leave blank to keep.'
3886
+ : 'From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.';
3887
+ }
3806
3888
  }
3807
3889
 
3808
3890
  function fillSettingsForm(cfg) {
@@ -3811,6 +3893,7 @@ function fillSettingsForm(cfg) {
3811
3893
  fillAuthSettings(cfg);
3812
3894
 
3813
3895
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
3896
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
3814
3897
 
3815
3898
  if (els.settingsJiraBaseUrl) {
3816
3899
  els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
@@ -4037,6 +4120,23 @@ function updateTicketSourceUI(source) {
4037
4120
  }
4038
4121
  }
4039
4122
 
4123
+ /**
4124
+ * @param {'claude' | 'openrouter'} provider
4125
+ */
4126
+ function updateLlmProviderUI(provider) {
4127
+ llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
4128
+
4129
+ for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
4130
+ if (!toggle) continue;
4131
+ toggle.querySelectorAll('.source-btn').forEach((btn) => {
4132
+ const active = btn.dataset.provider === llmProvider;
4133
+ btn.classList.toggle('active', active);
4134
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4135
+ });
4136
+ }
4137
+ updateModelLabel();
4138
+ }
4139
+
4040
4140
  /**
4041
4141
  * @param {'github' | 'jira'} next
4042
4142
  */
@@ -4058,6 +4158,26 @@ async function saveTicketSource(next) {
4058
4158
  }
4059
4159
  }
4060
4160
 
4161
+ async function saveLlmProvider(next) {
4162
+ const provider = next === 'openrouter' ? 'openrouter' : 'claude';
4163
+ updateLlmProviderUI(provider);
4164
+ clearAvailableModels();
4165
+ try {
4166
+ const res = await fetch('/api/config', {
4167
+ method: 'PATCH',
4168
+ headers: { 'Content-Type': 'application/json' },
4169
+ body: JSON.stringify({ llmProvider: provider }),
4170
+ });
4171
+ const data = await readJson(res);
4172
+ if (res.ok) {
4173
+ applyConfigSnapshot({ ...appConfig, ...data });
4174
+ await fetchModels({ refresh: true, reconcile: true });
4175
+ }
4176
+ } catch {
4177
+ void fetchModels({ refresh: true, reconcile: true });
4178
+ }
4179
+ }
4180
+
4061
4181
  function updateTimeoutMsHint(minutes) {
4062
4182
  if (!els.settingsTimeoutMs) return;
4063
4183
  const m = Number(minutes);
@@ -4085,6 +4205,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
4085
4205
  */
4086
4206
  function fillOverviewLlmControls(cfg) {
4087
4207
  if (!cfg) return;
4208
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4088
4209
  syncAllModelComboboxValues(currentModel);
4089
4210
  }
4090
4211
 
@@ -4132,6 +4253,7 @@ function applyConfigSnapshot(data) {
4132
4253
  updateModelLabel();
4133
4254
  fillOverviewLlmControls(data);
4134
4255
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
4256
+ updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4135
4257
  if (els.repoName) {
4136
4258
  els.repoName.textContent = data?.repoName || 'local repo';
4137
4259
  }
@@ -4178,6 +4300,7 @@ function readSettingsForm() {
4178
4300
  testCommand: testTrimmed === '' ? null : testTrimmed,
4179
4301
  allowedTools,
4180
4302
  ticketSource,
4303
+ llmProvider,
4181
4304
  };
4182
4305
 
4183
4306
  const ghToken = secretPatchValue(els.settingsGhToken);
@@ -4186,6 +4309,8 @@ function readSettingsForm() {
4186
4309
  if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
4187
4310
  const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
4188
4311
  if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
4312
+ const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
4313
+ if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
4189
4314
 
4190
4315
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
4191
4316
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
@@ -4353,6 +4478,12 @@ bindSecretClear(
4353
4478
  els.settingsClaudeOauthHint,
4354
4479
  'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
4355
4480
  );
4481
+ bindSecretClear(
4482
+ els.settingsOpenrouterKeyClear,
4483
+ els.settingsOpenrouterKey,
4484
+ els.settingsOpenrouterKeyHint,
4485
+ 'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
4486
+ );
4356
4487
 
4357
4488
 
4358
4489
  document.addEventListener('click', (ev) => {
@@ -4445,18 +4576,29 @@ els.issueUrls?.addEventListener('keydown', (ev) => {
4445
4576
  els.addBtn?.click();
4446
4577
  });
4447
4578
 
4579
+ els.preferredBranch?.addEventListener('keydown', (ev) => {
4580
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) return;
4581
+ ev.preventDefault();
4582
+ els.addBtn?.click();
4583
+ });
4584
+
4448
4585
  els.addBtn.addEventListener('click', async () => {
4449
4586
  els.enqueueFeedback.classList.add('hidden');
4450
4587
  const text = els.issueUrls.value.trim();
4451
4588
  if (!text) return;
4452
4589
 
4453
4590
  const urls = splitIssueUrls(text);
4591
+ const branchName = els.preferredBranch?.value?.trim() || '';
4454
4592
 
4455
4593
  try {
4456
4594
  const res = await fetch('/api/issues', {
4457
4595
  method: 'POST',
4458
4596
  headers: { 'Content-Type': 'application/json' },
4459
- body: JSON.stringify({ urls, ticketSource }),
4597
+ body: JSON.stringify({
4598
+ urls,
4599
+ ticketSource,
4600
+ ...(branchName ? { branchName } : {}),
4601
+ }),
4460
4602
  });
4461
4603
  const data = await res.json();
4462
4604
  if (!res.ok) {
@@ -4467,6 +4609,7 @@ els.addBtn.addEventListener('click', async () => {
4467
4609
  return;
4468
4610
  }
4469
4611
  els.issueUrls.value = '';
4612
+ if (els.preferredBranch) els.preferredBranch.value = '';
4470
4613
  const parts = [];
4471
4614
  if (data.jobs?.length) parts.push(`Added ${data.jobs.length}`);
4472
4615
  if (data.skipped?.length) {
@@ -4501,6 +4644,18 @@ function handleTicketSourceToggleClick(e) {
4501
4644
  els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4502
4645
  els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4503
4646
 
4647
+ function handleLlmProviderToggleClick(e) {
4648
+ const btn = e.target.closest('.source-btn');
4649
+ if (!btn?.dataset.provider) return;
4650
+ const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4651
+ if (next === llmProvider) return;
4652
+ e.stopPropagation();
4653
+ void saveLlmProvider(next);
4654
+ }
4655
+
4656
+ els.settingsLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
4657
+ els.overviewLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
4658
+
4504
4659
  els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
4505
4660
  e.preventDefault();
4506
4661
  setView('settings');
package/public/index.html CHANGED
@@ -72,7 +72,7 @@
72
72
  <div class="sidebar-footer">
73
73
  <div class="sidebar-divider"></div>
74
74
  <div class="model-block">
75
- <div class="model-label">Agent</div>
75
+ <div class="model-label" id="sidebar-agent-label">Agent</div>
76
76
  <div class="model-meta-row">
77
77
  <span class="model-combined" id="model-combined">—</span>
78
78
  </div>
@@ -138,6 +138,24 @@
138
138
  or a browse link. Press <kbd class="enqueue-kbd">Enter</kbd> to add.
139
139
  </p>
140
140
 
141
+ <div class="enqueue-branch-field">
142
+ <div class="enqueue-branch-label-row">
143
+ <label class="field-label" for="preferred-branch">Branch name</label>
144
+ <span class="enqueue-branch-optional">Optional</span>
145
+ </div>
146
+ <input
147
+ id="preferred-branch"
148
+ class="input enqueue-branch-input"
149
+ type="text"
150
+ spellcheck="false"
151
+ autocomplete="off"
152
+ placeholder="your-branch-name"
153
+ >
154
+ <p class="enqueue-input-hint">
155
+ Leave empty to keep feat/fix + ticket title. If set, that name is used instead.
156
+ </p>
157
+ </div>
158
+
141
159
  <div class="enqueue-info-banner" id="enqueue-info-banner" role="note">
142
160
  <svg class="enqueue-info-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
143
161
  <circle cx="12" cy="12" r="10"/>
@@ -172,6 +190,10 @@
172
190
  <svg class="agent-pill-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
173
191
  </button>
174
192
  <div class="agent-picker-panel" id="overview-agent-panel" role="dialog" aria-label="Choose agent model" aria-hidden="true">
193
+ <div class="source-toggle source-toggle--compact agent-picker-provider" id="overview-llm-provider" role="group" aria-label="LLM provider">
194
+ <button type="button" class="source-btn active" data-provider="claude">Claude</button>
195
+ <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
196
+ </div>
175
197
  <label class="sr-only" for="overview-agent-search">Search models</label>
176
198
  <input
177
199
  id="overview-agent-search"
@@ -530,6 +552,21 @@
530
552
  </div>
531
553
  </div>
532
554
  </div>
555
+
556
+ <div class="card settings-card">
557
+ <div class="rules-heading">OpenRouter authentication</div>
558
+ <p id="settings-openrouter-status" class="auth-status" role="status">Checking OpenRouter auth…</p>
559
+ <div class="settings-grid">
560
+ <div class="settings-field settings-field-full">
561
+ <label class="field-label" for="settings-openrouter-key">OpenRouter API key</label>
562
+ <input id="settings-openrouter-key" class="input" type="password" name="openrouterApiKey" autocomplete="new-password" placeholder="Leave blank to keep existing">
563
+ <button type="button" class="auth-clear hidden" id="settings-openrouter-key-clear">Clear saved API key</button>
564
+ <p class="field-hint" id="settings-openrouter-key-hint">
565
+ From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.
566
+ </p>
567
+ </div>
568
+ </div>
569
+ </div>
533
570
  </div>
534
571
  </div>
535
572
 
@@ -625,6 +662,14 @@
625
662
  Changes save to <code>.acdev/config.json</code> and apply to the next job — no restart needed.
626
663
  </p>
627
664
  <div class="card settings-card">
665
+ <div class="settings-field settings-field-full">
666
+ <div class="field-label">LLM provider</div>
667
+ <div class="source-toggle" id="settings-llm-provider" role="group" aria-label="LLM provider">
668
+ <button type="button" class="source-btn active" data-provider="claude">Claude</button>
669
+ <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
670
+ </div>
671
+ <p class="field-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model).</p>
672
+ </div>
628
673
  <div class="settings-grid">
629
674
  <div class="settings-field">
630
675
  <label class="field-label" for="settings-base-branch">Base branch</label>
@@ -664,7 +709,7 @@
664
709
  ></ul>
665
710
  </div>
666
711
  </div>
667
- <p class="field-hint" id="settings-model-hint">Claude model for agent runs.</p>
712
+ <p class="field-hint" id="settings-model-hint">Model for agent runs.</p>
668
713
  </div>
669
714
 
670
715
  <div class="settings-field">
package/public/styles.css CHANGED
@@ -459,6 +459,38 @@ a { color: var(--primary); text-underline-offset: 3px; }
459
459
  color: var(--text-muted);
460
460
  }
461
461
 
462
+ .enqueue-branch-field {
463
+ display: flex;
464
+ flex-direction: column;
465
+ gap: 6px;
466
+ }
467
+
468
+ .enqueue-branch-field .field-label {
469
+ margin-bottom: 0;
470
+ }
471
+
472
+ .enqueue-branch-field .enqueue-input-hint {
473
+ margin: 0;
474
+ }
475
+
476
+ .enqueue-branch-label-row {
477
+ display: flex;
478
+ align-items: baseline;
479
+ justify-content: space-between;
480
+ gap: 8px;
481
+ }
482
+
483
+ .enqueue-branch-optional {
484
+ font-size: 11px;
485
+ font-weight: 500;
486
+ color: var(--text-muted);
487
+ }
488
+
489
+ .enqueue-branch-input {
490
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
491
+ font-size: 13px;
492
+ }
493
+
462
494
  .enqueue-kbd {
463
495
  display: inline-block;
464
496
  padding: 1px 6px;
@@ -556,6 +588,10 @@ a { color: var(--primary); text-underline-offset: 3px; }
556
588
  background: linear-gradient(145deg, #e8a87c 0%, #d97706 100%);
557
589
  }
558
590
 
591
+ .agent-pill-icon--openrouter {
592
+ background: linear-gradient(145deg, #7c9cff 0%, #4f46e5 100%);
593
+ }
594
+
559
595
  .agent-pill-body {
560
596
  flex: 1;
561
597
  min-width: 0;
@@ -604,7 +640,7 @@ a { color: var(--primary); text-underline-offset: 3px; }
604
640
  display: flex;
605
641
  flex-direction: column;
606
642
  width: min(360px, calc(100vw - 48px));
607
- max-height: 320px;
643
+ max-height: 360px;
608
644
  background: var(--surface);
609
645
  border: 1px solid var(--border);
610
646
  border-radius: 10px;
@@ -681,6 +717,16 @@ a { color: var(--primary); text-underline-offset: 3px; }
681
717
  background: transparent;
682
718
  }
683
719
 
720
+ .agent-picker-provider {
721
+ margin: 10px 10px 0;
722
+ max-width: none;
723
+ }
724
+
725
+ .agent-picker-provider .source-btn {
726
+ padding: 7px 10px;
727
+ font-size: 12px;
728
+ }
729
+
684
730
  .agent-picker-search {
685
731
  flex-shrink: 0;
686
732
  min-height: 36px;