acdev 1.0.10 → 1.0.12

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
+ }