@openagents-org/agent-launcher 0.2.143 → 0.2.145
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/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +108 -7
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +10 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +24 -7
- package/src/config.js +8 -2
- package/src/daemon.js +86 -5
- package/src/installer.js +509 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aider adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges Aider (https://aider.chat) to an OpenAgents workspace by running the
|
|
5
|
+
* CLI in its official non-interactive *scripting* mode:
|
|
6
|
+
*
|
|
7
|
+
* aider --message-file <tmp> --yes-always --no-pretty --no-stream \
|
|
8
|
+
* --no-auto-commits --no-dirty-commits --no-gitignore \
|
|
9
|
+
* --chat-history-file <per-channel> --input-history-file <per-channel> \
|
|
10
|
+
* [--restore-chat-history] [--model <model>]
|
|
11
|
+
*
|
|
12
|
+
* Differs from the Amp adapter in every Aider-specific dimension:
|
|
13
|
+
* - Prompt is written to a private per-task message file (--message-file), so
|
|
14
|
+
* a long prompt never hits ARG_MAX / shell quoting; never string-concatenated.
|
|
15
|
+
* - Aider has no JSON event protocol — plain text is drained incrementally,
|
|
16
|
+
* notable progress lines relayed as `status`, and the cleaned transcript sent
|
|
17
|
+
* once as the final answer. The exit code decides success (non-zero is never
|
|
18
|
+
* reported as success even with stdout).
|
|
19
|
+
* - Per-channel `--chat-history-file` + `--restore-chat-history` give isolated,
|
|
20
|
+
* resumable sessions stored under ~/.openagents/sessions/aider (never in the
|
|
21
|
+
* project). A corrupt history file degrades to a fresh session.
|
|
22
|
+
* - Git auto-commit is OFF by default (--no-auto-commits --no-dirty-commits)
|
|
23
|
+
* and the tracked .gitignore is never rewritten (--no-gitignore); Aider's
|
|
24
|
+
* local cache is excluded via .git/info/exclude.
|
|
25
|
+
* - Multi-provider auth: a generic LLM_API_KEY is mapped to the right provider
|
|
26
|
+
* env var from the model string; keys never reach the command line or logs.
|
|
27
|
+
*
|
|
28
|
+
* Reuses all shared connectivity / dispatch / state machinery in BaseAdapter.
|
|
29
|
+
*
|
|
30
|
+
* Mirrors the Python adapter: sdk/src/openagents/adapters/aider.py
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const os = require('os');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const { execSync, spawn } = require('child_process');
|
|
39
|
+
|
|
40
|
+
const BaseAdapter = require('./base');
|
|
41
|
+
const { whichBinary, getEnhancedEnv, aiderBinDirs } = require('../paths');
|
|
42
|
+
|
|
43
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
44
|
+
// Terminate if Aider produces no output for this long (a wedged turn). Resets
|
|
45
|
+
// on any stdout activity, so a slow-but-progressing task is never killed.
|
|
46
|
+
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
47
|
+
const MAX_STATUS_UPDATES = 60;
|
|
48
|
+
|
|
49
|
+
// eslint-disable-next-line no-control-regex
|
|
50
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
51
|
+
const BLANKS_RE = /\n{3,}/g;
|
|
52
|
+
const PROGRESS_RE =
|
|
53
|
+
/^(Applied edit|Edited |Wrote |Created |Added |Removed |Committing|Commit |Running |Scanning |Repo-map|Reformatting|Skipped |Renamed )/i;
|
|
54
|
+
|
|
55
|
+
const ERROR_SIGNATURES = [
|
|
56
|
+
[['authenticationerror', 'invalid api key', 'incorrect api key', 'no api key',
|
|
57
|
+
'missing these environment variables', 'api key not found', '401',
|
|
58
|
+
'unauthorized', 'permission denied to access model'],
|
|
59
|
+
'Authentication failed — check the API key for the selected model/provider.'],
|
|
60
|
+
[['notfounderror', 'model_not_found', 'does not exist', 'unknown model',
|
|
61
|
+
'could not find model', 'you do not have access to model'],
|
|
62
|
+
'Model not found or not accessible — check the model name and that your key has access.'],
|
|
63
|
+
[['rate limit', 'ratelimiterror', '429', 'quota', 'insufficient_quota'],
|
|
64
|
+
'Rate-limited or out of quota at the model provider — try again later.'],
|
|
65
|
+
[['connectionerror', 'timeout', 'could not connect', 'getaddrinfo',
|
|
66
|
+
'temporary failure in name resolution', 'network is unreachable',
|
|
67
|
+
'failed to establish a new connection'],
|
|
68
|
+
'Network error reaching the model provider — check connectivity and the base URL.'],
|
|
69
|
+
[['permission denied', 'eacces', 'read-only file system', 'operation not permitted'],
|
|
70
|
+
'File permission error in the working directory.'],
|
|
71
|
+
[['gitcommanderror', 'fatal: not a git repository', 'git failed', 'not a git repo'],
|
|
72
|
+
'Git error while applying changes.'],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function aiderInstallHint() {
|
|
76
|
+
return IS_WINDOWS
|
|
77
|
+
? 'powershell -NoProfile -Command "irm https://aider.chat/install.ps1 | iex"'
|
|
78
|
+
: 'curl -LsSf https://aider.chat/install.sh | sh';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isTruthy(v) {
|
|
82
|
+
return ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cleanOutput(text) {
|
|
86
|
+
return String(text || '').replace(ANSI_RE, '').replace(BLANKS_RE, '\n\n').trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function classifyError(stderr, stdout) {
|
|
90
|
+
const blob = `${stderr}\n${stdout}`.toLowerCase();
|
|
91
|
+
for (const [needles, message] of ERROR_SIGNATURES) {
|
|
92
|
+
if (needles.some((n) => blob.includes(n))) return message;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Provider resolution — must stay behaviourally identical to the Python adapter
|
|
99
|
+
// (sdk/src/openagents/adapters/aider.py). Aider routes through LiteLLM, so a
|
|
100
|
+
// single generic LLM_API_KEY is injected into the provider-specific env var the
|
|
101
|
+
// chosen model expects. Provider is chosen DETERMINISTICALLY:
|
|
102
|
+
// 1. explicit AIDER_PROVIDER (not 'auto') wins;
|
|
103
|
+
// 2. else infer from an unambiguous model name (case-insensitive);
|
|
104
|
+
// 3. key set but provider undeterminable → config error (never silently OpenAI);
|
|
105
|
+
// 4. no key → leave the inherited/native provider env untouched (auto mode).
|
|
106
|
+
// Env var names verified against aider's official docs / `aider --help`.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
const VALID_PROVIDERS = [
|
|
110
|
+
'auto', 'openai', 'anthropic', 'openrouter', 'gemini', 'deepseek',
|
|
111
|
+
'openai-compatible',
|
|
112
|
+
];
|
|
113
|
+
const PROVIDER_KEY_VAR = {
|
|
114
|
+
openai: 'OPENAI_API_KEY',
|
|
115
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
116
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
117
|
+
gemini: 'GEMINI_API_KEY',
|
|
118
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
119
|
+
'openai-compatible': 'OPENAI_API_KEY',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function explicitPrefixProvider(model) {
|
|
123
|
+
const m = String(model || '').trim().toLowerCase();
|
|
124
|
+
if (m.startsWith('openrouter/')) return 'openrouter';
|
|
125
|
+
if (m.startsWith('anthropic/')) return 'anthropic';
|
|
126
|
+
if (m.startsWith('openai/')) return 'openai';
|
|
127
|
+
if (m.startsWith('gemini/') || m.startsWith('google/')) return 'gemini';
|
|
128
|
+
if (m.startsWith('deepseek/')) return 'deepseek';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function inferProviderFromModel(model) {
|
|
133
|
+
const m = String(model || '').trim().toLowerCase();
|
|
134
|
+
if (!m) return null;
|
|
135
|
+
const prefixed = explicitPrefixProvider(m);
|
|
136
|
+
if (prefixed) return prefixed;
|
|
137
|
+
if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku')) return 'anthropic';
|
|
138
|
+
if (m.includes('deepseek')) return 'deepseek';
|
|
139
|
+
if (m.includes('gemini')) return 'gemini';
|
|
140
|
+
if (m.startsWith('gpt') || m.startsWith('o1') || m.startsWith('o3')
|
|
141
|
+
|| m.startsWith('o4-') || m.startsWith('chatgpt') || m.includes('gpt-')) return 'openai';
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeOpenAiModel(model) {
|
|
146
|
+
const m = String(model || '').trim();
|
|
147
|
+
if (!m) return m;
|
|
148
|
+
if (m.toLowerCase().startsWith('openai/')) return m;
|
|
149
|
+
return `openai/${m}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Deterministically resolve the provider env vars (and any model normalization)
|
|
154
|
+
* for a generic LLM_API_KEY.
|
|
155
|
+
* @returns {{ env: object, model: string, error: string|null }}
|
|
156
|
+
* `env` is the provider vars to inject; `model` is the possibly-normalized
|
|
157
|
+
* model; `error` is a clear message when the config is invalid/ambiguous (in
|
|
158
|
+
* which case Aider must NOT start).
|
|
159
|
+
*/
|
|
160
|
+
function resolveAiderProvider(provider, model, apiKey, baseUrl) {
|
|
161
|
+
provider = String(provider || '').trim().toLowerCase();
|
|
162
|
+
model = String(model || '').trim();
|
|
163
|
+
apiKey = String(apiKey || '').trim();
|
|
164
|
+
baseUrl = String(baseUrl || '').trim();
|
|
165
|
+
|
|
166
|
+
if (provider && !VALID_PROVIDERS.includes(provider)) {
|
|
167
|
+
return {
|
|
168
|
+
env: {}, model,
|
|
169
|
+
error: `Unknown AIDER_PROVIDER '${provider}'. Valid values: auto, openai, `
|
|
170
|
+
+ 'anthropic, openrouter, gemini, deepseek, openai-compatible.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!provider) provider = 'auto';
|
|
174
|
+
|
|
175
|
+
// No generic key: auto mode — never fabricate/override provider keys.
|
|
176
|
+
if (!apiKey) {
|
|
177
|
+
if (provider === 'openai-compatible') {
|
|
178
|
+
if (!baseUrl) {
|
|
179
|
+
return { env: {}, model, error: 'AIDER_PROVIDER=openai-compatible requires LLM_BASE_URL (the OpenAI-compatible endpoint URL).' };
|
|
180
|
+
}
|
|
181
|
+
return { env: { OPENAI_API_BASE: baseUrl }, model: normalizeOpenAiModel(model), error: null };
|
|
182
|
+
}
|
|
183
|
+
return { env: {}, model, error: null };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Generic key present: a concrete provider is required.
|
|
187
|
+
let resolved;
|
|
188
|
+
if (provider === 'auto') {
|
|
189
|
+
const inferred = inferProviderFromModel(model);
|
|
190
|
+
if (!inferred) {
|
|
191
|
+
return {
|
|
192
|
+
env: {}, model,
|
|
193
|
+
error: 'Could not determine the model provider for LLM_API_KEY. Set '
|
|
194
|
+
+ 'AIDER_PROVIDER (openai, anthropic, openrouter, gemini, deepseek, or '
|
|
195
|
+
+ 'openai-compatible), use an AIDER_MODEL whose name identifies the '
|
|
196
|
+
+ 'provider, or set the native provider key directly.',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
resolved = inferred;
|
|
200
|
+
} else {
|
|
201
|
+
resolved = provider;
|
|
202
|
+
const prefixProv = explicitPrefixProvider(model);
|
|
203
|
+
const effective = resolved === 'openai-compatible' ? 'openai' : resolved;
|
|
204
|
+
if (prefixProv && prefixProv !== effective) {
|
|
205
|
+
return {
|
|
206
|
+
env: {}, model,
|
|
207
|
+
error: `AIDER_PROVIDER=${provider} conflicts with AIDER_MODEL '${model}' `
|
|
208
|
+
+ `(which targets ${prefixProv}). Fix the provider or the model.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (resolved === 'openai-compatible') {
|
|
214
|
+
if (!baseUrl) {
|
|
215
|
+
return { env: {}, model, error: 'AIDER_PROVIDER=openai-compatible requires LLM_BASE_URL (the OpenAI-compatible endpoint URL).' };
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
env: { OPENAI_API_KEY: apiKey, OPENAI_API_BASE: baseUrl },
|
|
219
|
+
model: normalizeOpenAiModel(model), error: null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const env = { [PROVIDER_KEY_VAR[resolved]]: apiKey };
|
|
224
|
+
// A base URL only belongs to the OpenAI variable family.
|
|
225
|
+
if (baseUrl && resolved === 'openai') env.OPENAI_API_BASE = baseUrl;
|
|
226
|
+
return { env, model, error: null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
class AiderAdapter extends BaseAdapter {
|
|
230
|
+
constructor(opts) {
|
|
231
|
+
super(opts);
|
|
232
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
233
|
+
|
|
234
|
+
// channel -> running child process (for stop / cleanup)
|
|
235
|
+
this._channelProcesses = {};
|
|
236
|
+
// channels the user explicitly stopped (suppress "no response" noise)
|
|
237
|
+
this._stoppingChannels = new Set();
|
|
238
|
+
// git repos already given the local Aider exclude
|
|
239
|
+
this._gitExcludeDone = new Set();
|
|
240
|
+
|
|
241
|
+
this._sessionsDir = path.join(
|
|
242
|
+
os.homedir(), '.openagents', 'sessions', 'aider',
|
|
243
|
+
`${this.workspaceId}_${this.agentName}`,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
this._aiderBin = this._findAiderBinary();
|
|
247
|
+
if (this._aiderBin) {
|
|
248
|
+
this._log(`Using Aider CLI: ${this._aiderBin}`);
|
|
249
|
+
} else {
|
|
250
|
+
this._log(`Warning: Aider CLI not found — install with: ${aiderInstallHint()}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ------------------------------------------------------------------
|
|
255
|
+
// Binary resolution
|
|
256
|
+
// ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
_findAiderBinary() {
|
|
259
|
+
// Shared cross-platform resolver runs `which`/`where` against an ENHANCED
|
|
260
|
+
// PATH (nvm/fnm/volta/homebrew + the Aider install dirs added to paths.js)
|
|
261
|
+
// — covers the GUI/daemon "not on PATH" case for uv-tool / pipx / pip-user
|
|
262
|
+
// installs.
|
|
263
|
+
const resolved = whichBinary('aider');
|
|
264
|
+
if (resolved) return resolved;
|
|
265
|
+
|
|
266
|
+
// Explicit fallback over every real install dir (XDG bin, XDG_DATA_HOME/../
|
|
267
|
+
// bin, ~/.local/bin, the uv tools venv) in the installer's own priority.
|
|
268
|
+
const names = IS_WINDOWS ? ['aider.exe', 'aider.cmd', 'aider'] : ['aider'];
|
|
269
|
+
for (const dir of aiderBinDirs()) {
|
|
270
|
+
for (const name of names) {
|
|
271
|
+
const c = path.join(dir, name);
|
|
272
|
+
if (fs.existsSync(c)) return c;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ------------------------------------------------------------------
|
|
279
|
+
// Per-channel session storage
|
|
280
|
+
// ------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
_safeChannelId(channel) {
|
|
283
|
+
let slug = String(channel || '').replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '');
|
|
284
|
+
return slug || 'general';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_chatHistoryFile(channel) {
|
|
288
|
+
return path.join(this._sessionsDir, `${this._safeChannelId(channel)}.chat.history.md`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_inputHistoryFile(channel) {
|
|
292
|
+
return path.join(this._sessionsDir, `${this._safeChannelId(channel)}.input.history`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_hasHistory(channel) {
|
|
296
|
+
const p = this._chatHistoryFile(channel);
|
|
297
|
+
try {
|
|
298
|
+
const st = fs.statSync(p);
|
|
299
|
+
if (!st.size) return false;
|
|
300
|
+
fs.readFileSync(p, 'utf-8');
|
|
301
|
+
return true;
|
|
302
|
+
} catch (e) {
|
|
303
|
+
if (e && e.code === 'ENOENT') return false;
|
|
304
|
+
// Unreadable/corrupt → degrade to a fresh session.
|
|
305
|
+
this._log(`Aider chat history for ${this._safeChannelId(channel)} unreadable — starting fresh`);
|
|
306
|
+
try { fs.renameSync(p, `${p}.corrupt`); } catch {}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
resetChannelSession(channel) {
|
|
312
|
+
for (const p of [this._chatHistoryFile(channel), this._inputHistoryFile(channel)]) {
|
|
313
|
+
try { fs.rmSync(p, { force: true }); } catch {}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
clearAllSessions() {
|
|
318
|
+
try { fs.rmSync(this._sessionsDir, { recursive: true, force: true }); } catch {}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ------------------------------------------------------------------
|
|
322
|
+
// Git hygiene — keep Aider's cache out of `git status` without touching the
|
|
323
|
+
// user's tracked .gitignore.
|
|
324
|
+
// ------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
_ensureLocalGitExclude(workingDir) {
|
|
327
|
+
if (!workingDir || this._gitExcludeDone.has(workingDir)) return;
|
|
328
|
+
this._gitExcludeDone.add(workingDir);
|
|
329
|
+
try {
|
|
330
|
+
const gitDir = path.join(workingDir, '.git');
|
|
331
|
+
if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) return;
|
|
332
|
+
const infoDir = path.join(gitDir, 'info');
|
|
333
|
+
fs.mkdirSync(infoDir, { recursive: true });
|
|
334
|
+
const exclude = path.join(infoDir, 'exclude');
|
|
335
|
+
let existing = '';
|
|
336
|
+
try { existing = fs.readFileSync(exclude, 'utf-8'); } catch {}
|
|
337
|
+
if (!existing.includes('.aider')) {
|
|
338
|
+
const sep = (!existing || existing.endsWith('\n')) ? '' : '\n';
|
|
339
|
+
fs.writeFileSync(exclude, `${existing}${sep}# Added by OpenAgents Aider agent\n.aider*\n`);
|
|
340
|
+
}
|
|
341
|
+
} catch {}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ------------------------------------------------------------------
|
|
345
|
+
// Command + env construction
|
|
346
|
+
// ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
_model() {
|
|
349
|
+
return String(this.agentEnv.AIDER_MODEL || this.agentEnv.LLM_MODEL || '').trim();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Deterministically resolve provider env + model from the agent config.
|
|
354
|
+
* Pure, so command/env builders and the pre-flight gate stay in sync.
|
|
355
|
+
* @returns {{ env: object, model: string, error: string|null }}
|
|
356
|
+
*/
|
|
357
|
+
_resolveConfig() {
|
|
358
|
+
return resolveAiderProvider(
|
|
359
|
+
this.agentEnv.AIDER_PROVIDER,
|
|
360
|
+
this._model(),
|
|
361
|
+
this.agentEnv.LLM_API_KEY,
|
|
362
|
+
this.agentEnv.LLM_BASE_URL || this.agentEnv.OPENAI_API_BASE,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_buildAiderCmd(channel, msgFile, restore) {
|
|
367
|
+
const autoCommit = isTruthy(this.agentEnv.AIDER_AUTO_COMMITS);
|
|
368
|
+
const cmd = [
|
|
369
|
+
this._aiderBin,
|
|
370
|
+
'--message-file', msgFile,
|
|
371
|
+
'--yes-always',
|
|
372
|
+
'--no-pretty',
|
|
373
|
+
'--no-stream',
|
|
374
|
+
'--no-check-update',
|
|
375
|
+
'--no-gitignore',
|
|
376
|
+
'--no-dirty-commits',
|
|
377
|
+
'--chat-history-file', this._chatHistoryFile(channel),
|
|
378
|
+
'--input-history-file', this._inputHistoryFile(channel),
|
|
379
|
+
];
|
|
380
|
+
cmd.push(autoCommit ? '--auto-commits' : '--no-auto-commits');
|
|
381
|
+
if (restore) cmd.push('--restore-chat-history');
|
|
382
|
+
// Use the resolved (possibly openai/-normalized) model, not the raw value.
|
|
383
|
+
const model = this._resolveConfig().model;
|
|
384
|
+
if (model) cmd.push('--model', model);
|
|
385
|
+
return cmd;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
_buildSubprocessEnv() {
|
|
389
|
+
const base = getEnhancedEnv(this.agentEnv);
|
|
390
|
+
if (base.NO_COLOR === undefined) base.NO_COLOR = '1';
|
|
391
|
+
base.PYTHONUNBUFFERED = '1';
|
|
392
|
+
Object.assign(base, this._resolveConfig().env);
|
|
393
|
+
return base;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ------------------------------------------------------------------
|
|
397
|
+
// Control actions (stop / reset)
|
|
398
|
+
// ------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
async _onControlAction(action, payload) {
|
|
401
|
+
if (action === 'stop') {
|
|
402
|
+
for (const [channel, proc] of Object.entries(this._channelProcesses)) {
|
|
403
|
+
this._stoppingChannels.add(channel);
|
|
404
|
+
await this._stopProcess(proc);
|
|
405
|
+
delete this._channelProcesses[channel];
|
|
406
|
+
try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (action === 'reset_session' || action === 'clear_session') {
|
|
411
|
+
const channel = (payload && payload.channel) || this.channelName;
|
|
412
|
+
this.resetChannelSession(channel);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await super._onControlAction(action, payload);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async _stopProcess(proc) {
|
|
419
|
+
if (!proc || proc.exitCode !== null) return;
|
|
420
|
+
try {
|
|
421
|
+
if (IS_WINDOWS) {
|
|
422
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
|
|
426
|
+
await new Promise((resolve) => {
|
|
427
|
+
const timeout = setTimeout(() => {
|
|
428
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
|
|
429
|
+
resolve();
|
|
430
|
+
}, 5000);
|
|
431
|
+
proc.on('exit', () => { clearTimeout(timeout); resolve(); });
|
|
432
|
+
});
|
|
433
|
+
} catch {}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ------------------------------------------------------------------
|
|
437
|
+
// Message handler
|
|
438
|
+
// ------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
async _handleMessage(msg) {
|
|
441
|
+
const content = (msg.content || '').trim();
|
|
442
|
+
if (!content) return;
|
|
443
|
+
|
|
444
|
+
const msgChannel = msg.sessionId || this.channelName;
|
|
445
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
446
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${content.length} chars`);
|
|
447
|
+
|
|
448
|
+
if (!this._aiderBin) this._aiderBin = this._findAiderBinary();
|
|
449
|
+
if (!this._aiderBin) {
|
|
450
|
+
await this.sendError(msgChannel, `Aider CLI not found. Install with: ${aiderInstallHint()}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Pre-flight: resolve the provider/key BEFORE starting Aider so a
|
|
455
|
+
// misconfiguration returns a crisp, actionable error (and never silently
|
|
456
|
+
// injects the key into the wrong provider).
|
|
457
|
+
const resolution = this._resolveConfig();
|
|
458
|
+
if (resolution.error) {
|
|
459
|
+
await this.sendError(msgChannel, `Configuration error: ${resolution.error}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await this._autoTitleChannel(msgChannel, content);
|
|
464
|
+
this._stoppingChannels.delete(msgChannel);
|
|
465
|
+
this._ensureLocalGitExclude(this.workingDir);
|
|
466
|
+
await this.sendStatus(msgChannel, 'Aider is working...');
|
|
467
|
+
|
|
468
|
+
let result;
|
|
469
|
+
try {
|
|
470
|
+
result = await this._runAider(content, msgChannel);
|
|
471
|
+
} catch (e) {
|
|
472
|
+
this._log(`Error handling message: ${e.message}`);
|
|
473
|
+
await this.sendError(msgChannel, `Error processing message: ${e.message}`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (this._stoppingChannels.has(msgChannel)) {
|
|
478
|
+
this._stoppingChannels.delete(msgChannel);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const { text, error } = result;
|
|
482
|
+
if (error) {
|
|
483
|
+
await this.sendError(msgChannel, error);
|
|
484
|
+
} else if (text) {
|
|
485
|
+
await this.sendResponse(msgChannel, text);
|
|
486
|
+
} else {
|
|
487
|
+
await this.sendResponse(
|
|
488
|
+
msgChannel,
|
|
489
|
+
'Aider finished with no textual output (any file changes were applied to the working directory).',
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ------------------------------------------------------------------
|
|
495
|
+
// Subprocess execution
|
|
496
|
+
// ------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
async _runAider(content, msgChannel) {
|
|
499
|
+
// Defensive re-check (the primary gate is in _handleMessage): never spawn
|
|
500
|
+
// Aider — nor create a temp file — on an invalid configuration.
|
|
501
|
+
const resolution = this._resolveConfig();
|
|
502
|
+
if (resolution.error) {
|
|
503
|
+
return { text: '', error: `Configuration error: ${resolution.error}` };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
fs.mkdirSync(this._sessionsDir, { recursive: true });
|
|
507
|
+
const restore = this._hasHistory(msgChannel);
|
|
508
|
+
|
|
509
|
+
// Private per-task message file outside the project. The prompt body never
|
|
510
|
+
// appears on the command line or in a filename/log.
|
|
511
|
+
const msgFile = path.join(
|
|
512
|
+
this._sessionsDir,
|
|
513
|
+
`aider-msg-${process.pid}-${this._msgCounter = (this._msgCounter || 0) + 1}.txt`,
|
|
514
|
+
);
|
|
515
|
+
try {
|
|
516
|
+
fs.writeFileSync(msgFile, content, 'utf-8');
|
|
517
|
+
const cmd = this._buildAiderCmd(msgChannel, msgFile, restore);
|
|
518
|
+
return await this._spawnAider(cmd, msgChannel);
|
|
519
|
+
} finally {
|
|
520
|
+
try { fs.rmSync(msgFile, { force: true }); } catch {}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
_spawnAider(cmd, msgChannel) {
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const env = this._buildSubprocessEnv();
|
|
527
|
+
|
|
528
|
+
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
529
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
530
|
+
env,
|
|
531
|
+
cwd: this.workingDir,
|
|
532
|
+
detached: !IS_WINDOWS,
|
|
533
|
+
windowsHide: true,
|
|
534
|
+
shell: IS_WINDOWS && String(cmd[0]).toLowerCase().endsWith('.cmd'),
|
|
535
|
+
});
|
|
536
|
+
this._channelProcesses[msgChannel] = proc;
|
|
537
|
+
|
|
538
|
+
// Guard the child's stdio streams against 'error'. When the process is
|
|
539
|
+
// SIGKILL'd on stop, macOS Node can emit a benign EPIPE/ECONNRESET 'error'
|
|
540
|
+
// on stdout/stderr a tick later; with a 'data' listener but no 'error'
|
|
541
|
+
// listener that becomes an uncaughtException which crashes the
|
|
542
|
+
// (single-process, e.g. Node 18) test run with exit 1 and no `not ok`.
|
|
543
|
+
// No-op — data handling below is unchanged.
|
|
544
|
+
if (proc.stdout) proc.stdout.on('error', () => {});
|
|
545
|
+
if (proc.stderr) proc.stderr.on('error', () => {});
|
|
546
|
+
|
|
547
|
+
let stdoutBuf = '';
|
|
548
|
+
let stderrBuf = '';
|
|
549
|
+
let lineBuffer = '';
|
|
550
|
+
let statusCount = 0;
|
|
551
|
+
let pending = Promise.resolve();
|
|
552
|
+
|
|
553
|
+
let idleTimer = null;
|
|
554
|
+
const armIdle = () => {
|
|
555
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
556
|
+
idleTimer = setTimeout(() => {
|
|
557
|
+
this._log(`Aider produced no output for ${IDLE_TIMEOUT_MS / 1000}s — terminating`);
|
|
558
|
+
this._stopProcess(proc);
|
|
559
|
+
}, IDLE_TIMEOUT_MS);
|
|
560
|
+
};
|
|
561
|
+
armIdle();
|
|
562
|
+
|
|
563
|
+
if (proc.stderr) {
|
|
564
|
+
proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const handleLine = async (raw) => {
|
|
568
|
+
const stripped = raw.replace(ANSI_RE, '').trim();
|
|
569
|
+
if (stripped && statusCount < MAX_STATUS_UPDATES && PROGRESS_RE.test(stripped)) {
|
|
570
|
+
statusCount += 1;
|
|
571
|
+
try { await this.sendStatus(msgChannel, stripped.slice(0, 300)); } catch {}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
proc.stdout.on('data', (chunk) => {
|
|
576
|
+
armIdle();
|
|
577
|
+
const s = chunk.toString('utf-8');
|
|
578
|
+
stdoutBuf += s;
|
|
579
|
+
lineBuffer += s;
|
|
580
|
+
const lines = lineBuffer.split('\n');
|
|
581
|
+
lineBuffer = lines.pop();
|
|
582
|
+
for (const line of lines) {
|
|
583
|
+
pending = pending.then(() => handleLine(line)).catch(() => {});
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
proc.on('exit', async (code) => {
|
|
588
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
589
|
+
if (lineBuffer) {
|
|
590
|
+
pending = pending.then(() => handleLine(lineBuffer)).catch(() => {});
|
|
591
|
+
}
|
|
592
|
+
try { await pending; } catch {}
|
|
593
|
+
delete this._channelProcesses[msgChannel];
|
|
594
|
+
|
|
595
|
+
if (this._stoppingChannels.has(msgChannel)) {
|
|
596
|
+
resolve({ text: '', error: null });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const stdoutText = cleanOutput(stdoutBuf);
|
|
601
|
+
const stderrText = cleanOutput(stderrBuf);
|
|
602
|
+
if (stderrText) this._log(`Aider stderr: ${stderrText.length} chars`);
|
|
603
|
+
|
|
604
|
+
if (code !== 0) {
|
|
605
|
+
let diagnostic = classifyError(stderrText, stdoutText);
|
|
606
|
+
if (!diagnostic) {
|
|
607
|
+
const tail = (stderrText || stdoutText).split('\n');
|
|
608
|
+
const detail = tail.length ? tail[tail.length - 1] : '';
|
|
609
|
+
diagnostic = `Aider exited with code ${code}.${detail ? ` ${detail}` : ''}`;
|
|
610
|
+
}
|
|
611
|
+
resolve({ text: '', error: diagnostic });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!stdoutText) {
|
|
616
|
+
const diagnostic = classifyError(stderrText, stdoutText);
|
|
617
|
+
if (diagnostic) {
|
|
618
|
+
resolve({ text: '', error: diagnostic });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
resolve({ text: stdoutText, error: null });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
proc.on('error', (err) => {
|
|
626
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
627
|
+
delete this._channelProcesses[msgChannel];
|
|
628
|
+
reject(err);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
module.exports = AiderAdapter;
|