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.
- package/.acdev/.env.example +3 -0
- package/bin/acdev.js +12 -4
- package/package.json +4 -2
- package/public/app.js +161 -6
- package/public/index.html +47 -2
- package/public/styles.css +47 -1
- package/src/agent.js +68 -21
- package/src/config.js +122 -10
- package/src/git.js +62 -0
- package/src/models.js +131 -18
- package/src/openrouter-agent.js +142 -0
- package/src/openrouter-auth.js +41 -0
- package/src/openrouter-tools.js +291 -0
- package/src/server.js +60 -9
- package/src/store.js +2 -0
- package/src/usage.js +35 -0
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
235
|
-
if (!
|
|
237
|
+
const provider = normalizeLlmProvider(config.llmProvider);
|
|
238
|
+
if (!isModelIdForProvider(model, provider)) {
|
|
236
239
|
return {
|
|
237
240
|
status: 400,
|
|
238
241
|
error:
|
|
239
|
-
|
|
240
|
-
|
|
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 =
|
|
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
|
|
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.
|