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.
@@ -0,0 +1,142 @@
1
+ import { OpenRouter, stepCountIs } from '@openrouter/agent';
2
+ import { extractUsageFromOpenRouter } from './usage.js';
3
+ import { checkOpenRouterAuth, openRouterApiKey } from './openrouter-auth.js';
4
+ import { buildOpenRouterCodingTools } from './openrouter-tools.js';
5
+
6
+ /**
7
+ * @param {{
8
+ * name?: string,
9
+ * arguments?: unknown,
10
+ * input?: unknown,
11
+ * }} call
12
+ */
13
+ function toolUseEvent(call) {
14
+ const name = call?.name || 'tool';
15
+ const input = call?.arguments ?? call?.input ?? {};
16
+ return {
17
+ type: 'assistant',
18
+ message: {
19
+ content: [{ type: 'tool_use', name, input }],
20
+ },
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Run the OpenRouter Agent SDK against a worktree with the same coding tools
26
+ * Claude Agent SDK exposes (Read/Glob/Grep/Edit/Write/Bash).
27
+ *
28
+ * @param {{
29
+ * prompt: string,
30
+ * worktreePath: string,
31
+ * config: object,
32
+ * onEvent: (message: unknown) => void,
33
+ * callModelFn?: (args: object) => object,
34
+ * }} params
35
+ */
36
+ export async function runOpenRouterQuery({
37
+ prompt,
38
+ worktreePath,
39
+ config,
40
+ onEvent,
41
+ callModelFn,
42
+ }) {
43
+ const auth = checkOpenRouterAuth();
44
+ if (!auth.ok && !callModelFn) {
45
+ throw new Error(
46
+ 'OpenRouter is not authenticated. Add OPENROUTER_API_KEY in Settings → Authentication.'
47
+ );
48
+ }
49
+
50
+ const timeoutMs = config.agentTimeoutMs ?? 900_000;
51
+ const maxTurns = Math.max(1, Number(config.maxAgentTurns) || 30);
52
+ const model = String(config.model || '').trim();
53
+ if (!model) {
54
+ throw new Error('No OpenRouter model selected');
55
+ }
56
+
57
+ const tools = buildOpenRouterCodingTools(worktreePath, config.allowedTools || []);
58
+ const abortController = new AbortController();
59
+ const started = Date.now();
60
+
61
+ const run = async () => {
62
+ let result;
63
+ if (callModelFn) {
64
+ result = callModelFn({
65
+ model,
66
+ input: prompt,
67
+ tools,
68
+ stopWhen: stepCountIs(maxTurns),
69
+ signal: abortController.signal,
70
+ });
71
+ } else {
72
+ const client = new OpenRouter({ apiKey: openRouterApiKey() });
73
+ result = client.callModel({
74
+ model,
75
+ input: prompt,
76
+ tools,
77
+ stopWhen: stepCountIs(maxTurns),
78
+ signal: abortController.signal,
79
+ });
80
+ }
81
+
82
+ if (result?.getToolCallsStream) {
83
+ void (async () => {
84
+ try {
85
+ for await (const call of result.getToolCallsStream()) {
86
+ onEvent(toolUseEvent(call));
87
+ }
88
+ } catch {
89
+ // stream may abort after success
90
+ }
91
+ })();
92
+ }
93
+
94
+ const resultText =
95
+ typeof result?.getText === 'function' ? await result.getText() : String(result ?? '');
96
+ let usageRaw = null;
97
+ if (typeof result?.getUsage === 'function') {
98
+ try {
99
+ usageRaw = await result.getUsage();
100
+ } catch {
101
+ usageRaw = null;
102
+ }
103
+ }
104
+
105
+ const usage = extractUsageFromOpenRouter(usageRaw, {
106
+ durationMs: Date.now() - started,
107
+ numTurns: usageRaw?.modelCalls,
108
+ });
109
+
110
+ const fakeResult = {
111
+ type: 'result',
112
+ subtype: 'success',
113
+ result: resultText,
114
+ num_turns: usage?.numTurns,
115
+ duration_ms: usage?.durationMs,
116
+ total_cost_usd: usage?.totalCostUsd,
117
+ usage: {
118
+ input_tokens: usage?.inputTokens,
119
+ output_tokens: usage?.outputTokens,
120
+ cache_read_input_tokens: usage?.cacheReadInputTokens,
121
+ },
122
+ };
123
+ onEvent(fakeResult);
124
+
125
+ return { resultText, usage };
126
+ };
127
+
128
+ let timeoutId;
129
+ try {
130
+ return await Promise.race([
131
+ run(),
132
+ new Promise((_, reject) => {
133
+ timeoutId = setTimeout(() => {
134
+ abortController.abort();
135
+ reject(new Error(`Agent timed out after ${timeoutMs}ms`));
136
+ }, timeoutMs);
137
+ }),
138
+ ]);
139
+ } finally {
140
+ if (timeoutId) clearTimeout(timeoutId);
141
+ }
142
+ }
@@ -0,0 +1,41 @@
1
+ /** @typedef {{ ok: true } | { ok: false, reason: 'missing' }} OpenRouterAuthResult */
2
+
3
+ /** @type {() => NodeJS.ProcessEnv} */
4
+ let envResolver = () => process.env;
5
+
6
+ /** @param {() => NodeJS.ProcessEnv} fn */
7
+ export function _setEnvResolver(fn) {
8
+ envResolver = fn;
9
+ }
10
+
11
+ export function _resetEnvResolver() {
12
+ envResolver = () => process.env;
13
+ }
14
+
15
+ /**
16
+ * OpenRouter API key from env (Settings writes OPENROUTER_API_KEY).
17
+ * @returns {string}
18
+ */
19
+ export function openRouterApiKey() {
20
+ return String(envResolver().OPENROUTER_API_KEY || '').trim();
21
+ }
22
+
23
+ /**
24
+ * @returns {OpenRouterAuthResult}
25
+ */
26
+ export function checkOpenRouterAuth() {
27
+ if (openRouterApiKey()) return { ok: true };
28
+ return { ok: false, reason: 'missing' };
29
+ }
30
+
31
+ /**
32
+ * @param {OpenRouterAuthResult} [_result]
33
+ */
34
+ export function formatOpenRouterAuthError(_result) {
35
+ return [
36
+ '⚠ OpenRouter is not authenticated — server will still start.',
37
+ ' Open Settings → Authentication to add an OpenRouter API key',
38
+ ' (or set OPENROUTER_API_KEY in `.acdev/.env`).',
39
+ ' For UI-only testing without auth: pass `--stub-agent`.',
40
+ ].join('\n');
41
+ }
@@ -0,0 +1,291 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { tool } from '@openrouter/agent';
6
+ import { z } from 'zod';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ const SKIP_DIR_NAMES = new Set([
11
+ '.git',
12
+ 'node_modules',
13
+ '.acdev',
14
+ '.acdev-worktrees',
15
+ '.codepilot',
16
+ '.codepilot-worktrees',
17
+ '.agent-mcp',
18
+ '.agent-mcp-worktrees',
19
+ ]);
20
+
21
+ /**
22
+ * Convert a glob (with `*` / `**` / `?`) to a RegExp.
23
+ * @param {string} pattern
24
+ */
25
+ export function globToRegExp(pattern) {
26
+ const src = String(pattern || '').replace(/\\/g, '/');
27
+ let i = 0;
28
+ let out = '^';
29
+ while (i < src.length) {
30
+ if (src[i] === '*' && src[i + 1] === '*') {
31
+ if (src[i + 2] === '/') {
32
+ out += '(?:.*/)?';
33
+ i += 3;
34
+ } else {
35
+ out += '.*';
36
+ i += 2;
37
+ }
38
+ } else if (src[i] === '*') {
39
+ out += '[^/]*';
40
+ i += 1;
41
+ } else if (src[i] === '?') {
42
+ out += '[^/]';
43
+ i += 1;
44
+ } else {
45
+ out += src[i].replace(/[.+^${}()|[\]\\]/g, '\\$&');
46
+ i += 1;
47
+ }
48
+ }
49
+ return new RegExp(`${out}$`);
50
+ }
51
+
52
+ /**
53
+ * Resolve a user path inside the worktree. Rejects escapes.
54
+ * @param {string} worktreePath
55
+ * @param {string} rel
56
+ */
57
+ export function resolveInWorktree(worktreePath, rel) {
58
+ const root = path.resolve(worktreePath);
59
+ const target = path.resolve(root, String(rel || '.'));
60
+ if (target !== root && !target.startsWith(root + path.sep)) {
61
+ throw new Error(`Path escapes worktree: ${rel}`);
62
+ }
63
+ return target;
64
+ }
65
+
66
+ /**
67
+ * @param {string} root
68
+ * @param {string} [sub]
69
+ * @returns {string[]} absolute file paths
70
+ */
71
+ export function listWorktreeFiles(root, sub) {
72
+ const start = sub ? resolveInWorktree(root, sub) : path.resolve(root);
73
+ /** @type {string[]} */
74
+ const files = [];
75
+ const walk = (dir) => {
76
+ let entries;
77
+ try {
78
+ entries = fs.readdirSync(dir, { withFileTypes: true });
79
+ } catch {
80
+ return;
81
+ }
82
+ for (const ent of entries) {
83
+ if (SKIP_DIR_NAMES.has(ent.name)) continue;
84
+ const full = path.join(dir, ent.name);
85
+ if (ent.isDirectory()) walk(full);
86
+ else if (ent.isFile() || ent.isSymbolicLink()) files.push(full);
87
+ }
88
+ };
89
+ if (fs.existsSync(start) && fs.statSync(start).isDirectory()) walk(start);
90
+ else if (fs.existsSync(start)) files.push(start);
91
+ return files;
92
+ }
93
+
94
+ function relToRoot(root, abs) {
95
+ return path.relative(root, abs).replace(/\\/g, '/');
96
+ }
97
+
98
+ /**
99
+ * @param {string} worktreePath
100
+ * @param {string[]} allowedTools
101
+ */
102
+ export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
103
+ const allowed = new Set(allowedTools || []);
104
+ const root = path.resolve(worktreePath);
105
+ /** @type {ReturnType<typeof tool>[]} */
106
+ const tools = [];
107
+
108
+ if (allowed.has('Read')) {
109
+ tools.push(
110
+ tool({
111
+ name: 'Read',
112
+ description: 'Read a file from the worktree. Optional 1-based offset/limit for line slices.',
113
+ inputSchema: z.object({
114
+ path: z.string().describe('Path relative to the worktree root'),
115
+ offset: z.number().int().positive().optional(),
116
+ limit: z.number().int().positive().optional(),
117
+ }),
118
+ execute: async ({ path: rel, offset, limit }) => {
119
+ const full = resolveInWorktree(root, rel);
120
+ const text = fs.readFileSync(full, 'utf8');
121
+ const lines = text.split('\n');
122
+ const start = offset ? Math.max(0, offset - 1) : 0;
123
+ const slice = limit ? lines.slice(start, start + limit) : lines.slice(start);
124
+ const numbered = slice.map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`);
125
+ return numbered.join('\n') || '(empty file)';
126
+ },
127
+ })
128
+ );
129
+ }
130
+
131
+ if (allowed.has('Write')) {
132
+ tools.push(
133
+ tool({
134
+ name: 'Write',
135
+ description: 'Write a file in the worktree, creating parent directories as needed.',
136
+ inputSchema: z.object({
137
+ path: z.string().describe('Path relative to the worktree root'),
138
+ content: z.string().describe('Full file contents'),
139
+ }),
140
+ execute: async ({ path: rel, content }) => {
141
+ const full = resolveInWorktree(root, rel);
142
+ fs.mkdirSync(path.dirname(full), { recursive: true });
143
+ fs.writeFileSync(full, content, 'utf8');
144
+ return `Wrote ${relToRoot(root, full)} (${Buffer.byteLength(content, 'utf8')} bytes)`;
145
+ },
146
+ })
147
+ );
148
+ }
149
+
150
+ if (allowed.has('Edit')) {
151
+ tools.push(
152
+ tool({
153
+ name: 'Edit',
154
+ description:
155
+ 'Replace exact text in a file. old_string must match uniquely unless replace_all is true.',
156
+ inputSchema: z.object({
157
+ path: z.string(),
158
+ old_string: z.string(),
159
+ new_string: z.string(),
160
+ replace_all: z.boolean().optional(),
161
+ }),
162
+ execute: async ({ path: rel, old_string, new_string, replace_all }) => {
163
+ const full = resolveInWorktree(root, rel);
164
+ const before = fs.readFileSync(full, 'utf8');
165
+ const count = before.split(old_string).length - 1;
166
+ if (count === 0) {
167
+ throw new Error(`old_string not found in ${rel}`);
168
+ }
169
+ if (count > 1 && !replace_all) {
170
+ throw new Error(
171
+ `old_string found ${count} times in ${rel}. Pass replace_all true or include more context.`
172
+ );
173
+ }
174
+ const after = replace_all
175
+ ? before.split(old_string).join(new_string)
176
+ : before.replace(old_string, new_string);
177
+ fs.writeFileSync(full, after, 'utf8');
178
+ return `Edited ${relToRoot(root, full)} (${count} replacement${count === 1 ? '' : 's'})`;
179
+ },
180
+ })
181
+ );
182
+ }
183
+
184
+ if (allowed.has('Glob')) {
185
+ tools.push(
186
+ tool({
187
+ name: 'Glob',
188
+ description: 'Find files in the worktree matching a glob pattern (e.g. **/*.js).',
189
+ inputSchema: z.object({
190
+ pattern: z.string(),
191
+ path: z.string().optional().describe('Subdirectory to search from'),
192
+ }),
193
+ execute: async ({ pattern, path: sub }) => {
194
+ const re = globToRegExp(pattern);
195
+ const files = listWorktreeFiles(root, sub)
196
+ .map((abs) => relToRoot(root, abs))
197
+ .filter((rel) => re.test(rel) || re.test(rel.split('/').pop() || rel));
198
+ if (files.length === 0) return '(no matches)';
199
+ return files.sort().join('\n');
200
+ },
201
+ })
202
+ );
203
+ }
204
+
205
+ if (allowed.has('Grep')) {
206
+ tools.push(
207
+ tool({
208
+ name: 'Grep',
209
+ description: 'Search file contents in the worktree with a regular expression.',
210
+ inputSchema: z.object({
211
+ pattern: z.string(),
212
+ path: z.string().optional(),
213
+ glob: z.string().optional(),
214
+ }),
215
+ execute: async ({ pattern, path: sub, glob }) => {
216
+ let re;
217
+ try {
218
+ re = new RegExp(pattern);
219
+ } catch (err) {
220
+ throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
221
+ }
222
+ const globRe = glob ? globToRegExp(glob) : null;
223
+ /** @type {string[]} */
224
+ const hits = [];
225
+ for (const abs of listWorktreeFiles(root, sub)) {
226
+ const rel = relToRoot(root, abs);
227
+ if (globRe && !globRe.test(rel) && !globRe.test(path.basename(rel))) continue;
228
+ let text;
229
+ try {
230
+ text = fs.readFileSync(abs, 'utf8');
231
+ } catch {
232
+ continue;
233
+ }
234
+ const lines = text.split('\n');
235
+ for (let i = 0; i < lines.length; i++) {
236
+ if (re.test(lines[i])) {
237
+ hits.push(`${rel}:${i + 1}:${lines[i]}`);
238
+ if (hits.length >= 200) {
239
+ hits.push('… truncated at 200 matches');
240
+ return hits.join('\n');
241
+ }
242
+ }
243
+ }
244
+ }
245
+ return hits.length ? hits.join('\n') : '(no matches)';
246
+ },
247
+ })
248
+ );
249
+ }
250
+
251
+ if (allowed.has('Bash')) {
252
+ tools.push(
253
+ tool({
254
+ name: 'Bash',
255
+ description: 'Run a shell command in the worktree. Returns stdout and stderr.',
256
+ inputSchema: z.object({
257
+ command: z.string(),
258
+ }),
259
+ execute: async ({ command }) => {
260
+ const cmd = String(command || '').trim();
261
+ if (!cmd) throw new Error('command is required');
262
+ const shell = process.env.SHELL || '/bin/bash';
263
+ try {
264
+ const { stdout, stderr } = await execFileAsync(shell, ['-lc', cmd], {
265
+ cwd: root,
266
+ timeout: 120_000,
267
+ maxBuffer: 4 * 1024 * 1024,
268
+ env: { ...process.env },
269
+ });
270
+ const out = [stdout, stderr].filter((s) => String(s || '').trim()).join('\n');
271
+ return out.trim() || '(no output)';
272
+ } catch (err) {
273
+ const e = /** @type {NodeJS.ErrnoException & { stdout?: string, stderr?: string }} */ (
274
+ err
275
+ );
276
+ const bits = [
277
+ e.stderr,
278
+ e.stdout,
279
+ e.message,
280
+ ]
281
+ .map((s) => String(s || '').trim())
282
+ .filter(Boolean);
283
+ throw new Error(bits.join('\n') || 'Command failed');
284
+ }
285
+ },
286
+ })
287
+ );
288
+ }
289
+
290
+ return tools;
291
+ }
package/src/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import express from 'express';
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { Store } from './store.js';
@@ -18,7 +19,8 @@ import {
18
19
  getDiff,
19
20
  pushBranch,
20
21
  detectIssueType,
21
- buildBranchName,
22
+ parsePreferredBranchName,
23
+ resolveDesiredBranchName,
22
24
  sanitizeBranchCommits,
23
25
  listChangedFiles,
24
26
  applyFileExclusions,
@@ -31,14 +33,15 @@ import {
31
33
  runAgentOnReviewFeedback,
32
34
  stripAiAttribution,
33
35
  } from './agent.js';
34
- import { publicConfig, updateConfig } from './config.js';
36
+ import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
35
37
  import { upsertEnvVars } from './env.js';
36
38
  import { listModels } from './models.js';
37
39
  import { splitIssueUrls } from './urls.js';
38
40
  import { usageFromLogs, withJobUsage } from './usage.js';
39
41
  import { checkGhAuth } from './gh-auth.js';
40
42
  import { checkClaudeAuth } from './claude-auth.js';
41
- import { isValidModelId, isNoModel, NO_MODEL } from './models.js';
43
+ import { checkOpenRouterAuth } from './openrouter-auth.js';
44
+ import { isValidModelId, isNoModel, NO_MODEL, isModelIdForProvider } from './models.js';
42
45
 
43
46
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
44
47
 
@@ -231,15 +234,39 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
231
234
  };
232
235
  }
233
236
 
234
- const claude = doCheckClaudeAuth();
235
- if (!claude.ok) {
237
+ const provider = normalizeLlmProvider(config.llmProvider);
238
+ if (!isModelIdForProvider(model, provider)) {
236
239
  return {
237
240
  status: 400,
238
241
  error:
239
- 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
240
- code: 'claude_auth_required',
242
+ provider === 'openrouter'
243
+ ? 'Invalid OpenRouter model. Choose a model from the OpenRouter catalog in Settings → Configuration.'
244
+ : 'Invalid Claude model. Choose a model in Settings → Configuration.',
245
+ code: 'model_invalid',
241
246
  };
242
247
  }
248
+
249
+ if (provider === 'openrouter') {
250
+ const orAuth = checkOpenRouterAuth();
251
+ if (!orAuth.ok) {
252
+ return {
253
+ status: 400,
254
+ error:
255
+ 'OpenRouter is not authenticated. Add an API key in Settings → Authentication, or start with --stub-agent.',
256
+ code: 'openrouter_auth_required',
257
+ };
258
+ }
259
+ } else {
260
+ const claude = doCheckClaudeAuth();
261
+ if (!claude.ok) {
262
+ return {
263
+ status: 400,
264
+ error:
265
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
266
+ code: 'claude_auth_required',
267
+ };
268
+ }
269
+ }
243
270
  }
244
271
 
245
272
  return null;
@@ -341,7 +368,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
341
368
  issueType = detectIssueType(issueDetails);
342
369
  }
343
370
 
344
- const desiredBranchName = buildBranchName(issueType, issueTitle);
371
+ const desiredBranchName = resolveDesiredBranchName(
372
+ job.preferredBranchName,
373
+ issueType,
374
+ issueTitle
375
+ );
345
376
  const worktreeId = worktreeIdForJob(job);
346
377
  if (worktreeId == null) {
347
378
  throw new Error('Job is missing issueNumber / jiraKey for worktree path');
@@ -570,7 +601,18 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
570
601
  req.query.refresh === '1' ||
571
602
  req.query.refresh === 'true' ||
572
603
  req.query.force === '1';
573
- const result = await listModels({ selected: config.model, force });
604
+ const provider = normalizeLlmProvider(
605
+ typeof req.query.provider === 'string' ? req.query.provider : config.llmProvider
606
+ );
607
+ const selected =
608
+ provider === normalizeLlmProvider(config.llmProvider)
609
+ ? config.model
610
+ : config.lastModelsByProvider?.[provider];
611
+ const result = await listModels({
612
+ selected,
613
+ force,
614
+ provider,
615
+ });
574
616
  res.json(result);
575
617
  } catch (err) {
576
618
  res.status(500).json({ error: err.message });
@@ -594,6 +636,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
594
636
  applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
595
637
  applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
596
638
  applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
639
+ applySecretField(envPatch, 'OPENROUTER_API_KEY', patch.openrouterApiKey);
597
640
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
598
641
  // Also mirror base URL into env for convenience when set via Settings
599
642
  const trimmed = patch.jiraBaseUrl.trim();
@@ -611,6 +654,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
611
654
  ghToken: _gh,
612
655
  anthropicApiKey: _ak,
613
656
  claudeOauthToken: _oa,
657
+ openrouterApiKey: _or,
614
658
  ...configPatch
615
659
  } = patch;
616
660
  updateConfig(repoRoot, config, configPatch);
@@ -657,6 +701,12 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
657
701
  });
658
702
  }
659
703
 
704
+ const preferredParsed = parsePreferredBranchName(req.body?.branchName);
705
+ if (!preferredParsed.ok) {
706
+ return res.status(400).json({ error: preferredParsed.error });
707
+ }
708
+ const preferredBranchName = preferredParsed.value;
709
+
660
710
  const ticketSource =
661
711
  req.body?.ticketSource === 'jira' || req.body?.ticketSource === 'github'
662
712
  ? req.body.ticketSource
@@ -751,6 +801,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
751
801
  issueNumber: item.number,
752
802
  ticketSource: item.ticketSource,
753
803
  jiraKey: item.jiraKey,
804
+ ...(preferredBranchName ? { preferredBranchName } : {}),
754
805
  });
755
806
  created.push(job);
756
807
  }
package/src/store.js CHANGED
@@ -55,6 +55,7 @@ export class Store {
55
55
  * issueNumber?: number,
56
56
  * ticketSource?: 'github' | 'jira',
57
57
  * jiraKey?: string,
58
+ * preferredBranchName?: string,
58
59
  * }} data
59
60
  * @returns {Job}
60
61
  */
@@ -68,6 +69,7 @@ export class Store {
68
69
  issueNumber: data.issueNumber,
69
70
  ticketSource,
70
71
  ...(data.jiraKey ? { jiraKey: data.jiraKey } : {}),
72
+ ...(data.preferredBranchName ? { preferredBranchName: data.preferredBranchName } : {}),
71
73
  status: 'queued',
72
74
  createdAt: now,
73
75
  updatedAt: now,
package/src/usage.js CHANGED
@@ -70,6 +70,41 @@ export function extractUsageFromResult(message) {
70
70
  return out;
71
71
  }
72
72
 
73
+ /**
74
+ * Map OpenRouter Agent SDK getUsage() totals onto JobUsage.
75
+ * @param {object | null | undefined} totals
76
+ * @param {{ durationMs?: number, numTurns?: number }} [extra]
77
+ * @returns {JobUsage | null}
78
+ */
79
+ export function extractUsageFromOpenRouter(totals, extra = {}) {
80
+ if (!totals || typeof totals !== 'object') return null;
81
+ const totalCostUsd = asFiniteNumber(totals.cost);
82
+ const inputTokens = asFiniteNumber(totals.inputTokens);
83
+ const outputTokens = asFiniteNumber(totals.outputTokens);
84
+ const cacheReadInputTokens = asFiniteNumber(totals.cachedTokens);
85
+ const numTurns = asFiniteNumber(extra.numTurns ?? totals.modelCalls);
86
+ const durationMs = asFiniteNumber(extra.durationMs);
87
+
88
+ const hasSignal =
89
+ totalCostUsd !== undefined ||
90
+ inputTokens !== undefined ||
91
+ outputTokens !== undefined ||
92
+ cacheReadInputTokens !== undefined ||
93
+ numTurns !== undefined;
94
+
95
+ if (!hasSignal) return null;
96
+
97
+ /** @type {JobUsage} */
98
+ const out = {};
99
+ if (totalCostUsd !== undefined) out.totalCostUsd = totalCostUsd;
100
+ if (inputTokens !== undefined) out.inputTokens = inputTokens;
101
+ if (outputTokens !== undefined) out.outputTokens = outputTokens;
102
+ if (cacheReadInputTokens !== undefined) out.cacheReadInputTokens = cacheReadInputTokens;
103
+ if (numTurns !== undefined) out.numTurns = numTurns;
104
+ if (durationMs !== undefined) out.durationMs = durationMs;
105
+ return out;
106
+ }
107
+
73
108
  /**
74
109
  * Scan job logs for the last `agent_event` whose payload is a `result` message
75
110
  * that carries usage/cost fields. Used to backfill older jobs.