acdev 1.0.10 → 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,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
@@ -33,14 +33,15 @@ import {
33
33
  runAgentOnReviewFeedback,
34
34
  stripAiAttribution,
35
35
  } from './agent.js';
36
- import { publicConfig, updateConfig } from './config.js';
36
+ import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
37
37
  import { upsertEnvVars } from './env.js';
38
38
  import { listModels } from './models.js';
39
39
  import { splitIssueUrls } from './urls.js';
40
40
  import { usageFromLogs, withJobUsage } from './usage.js';
41
41
  import { checkGhAuth } from './gh-auth.js';
42
42
  import { checkClaudeAuth } from './claude-auth.js';
43
- 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';
44
45
 
45
46
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
46
47
 
@@ -233,15 +234,39 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
233
234
  };
234
235
  }
235
236
 
236
- const claude = doCheckClaudeAuth();
237
- if (!claude.ok) {
237
+ const provider = normalizeLlmProvider(config.llmProvider);
238
+ if (!isModelIdForProvider(model, provider)) {
238
239
  return {
239
240
  status: 400,
240
241
  error:
241
- 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
242
- 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',
243
246
  };
244
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
+ }
245
270
  }
246
271
 
247
272
  return null;
@@ -348,29 +373,6 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
348
373
  issueType,
349
374
  issueTitle
350
375
  );
351
- // #region agent log
352
- try {
353
- fs.appendFileSync(
354
- '/Users/giancarlogarcia/Documents/Personal/Projects-2026/agent-mcp/.cursor/debug-473a78.log',
355
- `${JSON.stringify({
356
- sessionId: '473a78',
357
- runId: 'pre-fix',
358
- hypothesisId: 'C',
359
- location: 'src/server.js:resolveDesiredBranchName',
360
- message: 'resolved worktree branch name',
361
- data: {
362
- preferred: job.preferredBranchName ?? null,
363
- issueType,
364
- usedCustom: Boolean(job.preferredBranchName),
365
- desiredBranchName,
366
- },
367
- timestamp: Date.now(),
368
- })}\n`
369
- );
370
- } catch {
371
- // ignore debug log failures
372
- }
373
- // #endregion
374
376
  const worktreeId = worktreeIdForJob(job);
375
377
  if (worktreeId == null) {
376
378
  throw new Error('Job is missing issueNumber / jiraKey for worktree path');
@@ -599,7 +601,18 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
599
601
  req.query.refresh === '1' ||
600
602
  req.query.refresh === 'true' ||
601
603
  req.query.force === '1';
602
- 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
+ });
603
616
  res.json(result);
604
617
  } catch (err) {
605
618
  res.status(500).json({ error: err.message });
@@ -623,6 +636,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
623
636
  applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
624
637
  applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
625
638
  applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
639
+ applySecretField(envPatch, 'OPENROUTER_API_KEY', patch.openrouterApiKey);
626
640
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
627
641
  // Also mirror base URL into env for convenience when set via Settings
628
642
  const trimmed = patch.jiraBaseUrl.trim();
@@ -640,6 +654,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
640
654
  ghToken: _gh,
641
655
  anthropicApiKey: _ak,
642
656
  claudeOauthToken: _oa,
657
+ openrouterApiKey: _or,
643
658
  ...configPatch
644
659
  } = patch;
645
660
  updateConfig(repoRoot, config, configPatch);
@@ -691,29 +706,6 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
691
706
  return res.status(400).json({ error: preferredParsed.error });
692
707
  }
693
708
  const preferredBranchName = preferredParsed.value;
694
- // #region agent log
695
- try {
696
- fs.appendFileSync(
697
- '/Users/giancarlogarcia/Documents/Personal/Projects-2026/agent-mcp/.cursor/debug-473a78.log',
698
- `${JSON.stringify({
699
- sessionId: '473a78',
700
- runId: 'pre-fix',
701
- hypothesisId: 'A',
702
- location: 'src/server.js:POST /api/issues',
703
- message: 'enqueue preferred branch parse',
704
- data: {
705
- rawProvided: req.body?.branchName != null,
706
- rawType: typeof req.body?.branchName,
707
- preferredBranchName: preferredBranchName ?? null,
708
- urlCount: urls.length,
709
- },
710
- timestamp: Date.now(),
711
- })}\n`
712
- );
713
- } catch {
714
- // ignore debug log failures
715
- }
716
- // #endregion
717
709
 
718
710
  const ticketSource =
719
711
  req.body?.ticketSource === 'jira' || req.body?.ticketSource === 'github'
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.