@openagents-org/agent-launcher 0.2.150 → 0.2.152

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,572 @@
1
+ /**
2
+ * mini-SWE-agent adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges the mini-swe-agent CLI (https://mini-swe-agent.com) to an OpenAgents
5
+ * workspace as a plain shell-based coding agent. Each workspace message spawns
6
+ * ONE non-interactive `mini` run in the task's workspace directory and exits:
7
+ *
8
+ * mini [--model <model>] --yolo --exit-immediately \
9
+ * [--cost-limit <n>] --output <trajectory> --task <prompt>
10
+ *
11
+ * Design notes (verified against the mini CLI docs):
12
+ * - `mini` defaults to *confirm* mode (interactive) and, when the agent wants
13
+ * to finish, PROMPTS for the next step instead of exiting. `--yolo` runs LM
14
+ * commands without confirmation and `--exit-immediately` makes it exit on
15
+ * finish — BOTH are required for a headless one-shot run, or the daemon would
16
+ * hang waiting on stdin. stdin is also given /dev/null (stdio 'ignore') so a
17
+ * stray prompt (e.g. the first-run setup wizard) can never wedge the process.
18
+ * - mini is a Python process with no JSON event protocol: stdout is drained as
19
+ * a best-effort progress log (ANSI-stripped, relayed as `thinking`, capped)
20
+ * and the cleaned transcript tail is sent once as the final answer. The exit
21
+ * code decides success (non-zero is never reported as success). PYTHONUNBUFFERED
22
+ * is set so stdout streams instead of being fully buffered; NO_COLOR keeps
23
+ * rich/ANSI escapes out of the relayed log.
24
+ * - Each run gets its own --output trajectory file under ~/.openagents/sessions
25
+ * (never in the project), removed afterwards, so concurrent channel runs never
26
+ * clash on a shared trajectory.
27
+ * - Stateless per task: mini keeps no memory across workspace messages (there is
28
+ * no fabricated persistent session). The agent is auto-authorized to run shell
29
+ * commands via --yolo — consistent with how Goose (GOOSE_MODE=auto) and Copilot
30
+ * (--no-ask-user) already auto-execute; there is no shared per-command approval
31
+ * gate to hook into.
32
+ * - Config: mini's config is set via the MSWEA_MINI_CONFIG_PATH env var (which
33
+ * mini reads as its default config file), forwarded through the agent env. We
34
+ * intentionally do NOT pass --config: a single --config / MSWEA_MINI_CONFIG_PATH
35
+ * REPLACES mini's built-in mini.yaml (agent prompt templates, step/env config)
36
+ * rather than merging (verified in mini's run/mini.py), so the launcher field
37
+ * is documented as a FULL config file, not a partial override.
38
+ * - Multi-provider auth is left to mini/LiteLLM: the user supplies the model
39
+ * (MSWEA_MODEL_NAME or the Model field) and provider keys (ANTHROPIC_API_KEY /
40
+ * OPENAI_API_KEY / …) through the agent environment, which flows through
41
+ * unchanged. Keys never reach the command line or the logs.
42
+ *
43
+ * Reuses all shared connectivity / dispatch / state machinery in BaseAdapter;
44
+ * only the mini-specific subprocess invocation and parsing live here. Node-only
45
+ * adapter (like Cline / Copilot) — there is no Python mirror.
46
+ */
47
+
48
+ 'use strict';
49
+
50
+ const fs = require('fs');
51
+ const os = require('os');
52
+ const path = require('path');
53
+ const { execSync, spawn } = require('child_process');
54
+
55
+ const BaseAdapter = require('./base');
56
+ const { whichBinary, getEnhancedEnv, aiderBinDirs } = require('../paths');
57
+ const {
58
+ REASON,
59
+ classifySpawnError,
60
+ redactDiagnostic,
61
+ shouldUseShellForBinary,
62
+ } = require('./health-status');
63
+
64
+ const IS_WINDOWS = process.platform === 'win32';
65
+ // Terminate if mini produces no output for this long (a wedged turn). Resets on
66
+ // any stdout activity, so a slow-but-progressing task is never killed.
67
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
68
+ // Cap how many stdout lines are relayed as `thinking` progress, so a task that
69
+ // prints large command output (diffs, test logs) can't flood the channel. The
70
+ // full transcript is still collected for the final answer.
71
+ const MAX_STREAM_UPDATES = 60;
72
+ // Cap the size of the final transcript we post back. mini's own closing summary
73
+ // is at the END of stdout, so we keep the tail when truncating.
74
+ const FINAL_RESPONSE_MAX = 12000;
75
+
76
+ // eslint-disable-next-line no-control-regex
77
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
78
+ const BLANKS_RE = /\n{3,}/g;
79
+
80
+ // Error signatures → an actionable message. mini routes model calls through
81
+ // LiteLLM, so the failure surface mirrors Aider's; a couple of mini-specific
82
+ // cases (cost limit, no model configured) are added.
83
+ const ERROR_SIGNATURES = [
84
+ [['authenticationerror', 'invalid api key', 'incorrect api key', 'no api key',
85
+ 'missing these environment variables', 'api key not found', '401',
86
+ 'unauthorized', 'permission denied to access model'],
87
+ 'Authentication failed — configure a model API key (e.g. ANTHROPIC_API_KEY or '
88
+ + 'OPENAI_API_KEY) for the selected model in the agent environment.'],
89
+ [['notfounderror', 'model_not_found', 'does not exist', 'unknown model',
90
+ 'could not find model', 'you do not have access to model'],
91
+ 'Model not found or not accessible — check MSWEA_MODEL_NAME / the Model field '
92
+ + 'and that your key has access.'],
93
+ [['you must provide a model', 'no model', 'set up a model', 'model is required',
94
+ 'run `mini-extra config setup`', 'run mini-extra config setup'],
95
+ 'No model configured — set MSWEA_MODEL_NAME (or the Model field / a --config '
96
+ + 'file), or run `mini-extra config setup`.'],
97
+ [['cost limit', 'costlimit', 'exceeded the cost', 'limitreached', 'limit reached'],
98
+ 'Stopped: the configured cost limit (MSWEA_COST_LIMIT) was reached before the '
99
+ + 'task finished.'],
100
+ [['rate limit', 'ratelimiterror', '429', 'quota', 'insufficient_quota'],
101
+ 'Rate-limited or out of quota at the model provider — try again later.'],
102
+ [['connectionerror', 'timeout', 'could not connect', 'getaddrinfo',
103
+ 'temporary failure in name resolution', 'network is unreachable',
104
+ 'failed to establish a new connection'],
105
+ 'Network error reaching the model provider — check connectivity and the base URL.'],
106
+ ];
107
+
108
+ function miniInstallHint() {
109
+ return 'pip install mini-swe-agent';
110
+ }
111
+
112
+ function cleanOutput(text) {
113
+ return String(text || '').replace(ANSI_RE, '').replace(BLANKS_RE, '\n\n').trim();
114
+ }
115
+
116
+ function classifyError(stderr, stdout) {
117
+ const blob = `${stderr}\n${stdout}`.toLowerCase();
118
+ for (const [needles, message] of ERROR_SIGNATURES) {
119
+ if (needles.some((n) => blob.includes(n))) return message;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Wrap a workspace message as a self-contained coding task. mini works on the
126
+ * files directly via shell (it does NOT use the OpenAgents MCP tools), so the
127
+ * prompt is a plain instruction — no workspace/MCP context, and deliberately no
128
+ * SWE-bench / patch / benchmark wording.
129
+ */
130
+ function buildTaskPrompt(userMessage) {
131
+ return [
132
+ 'You are working in the current workspace directory.',
133
+ '',
134
+ 'User request:',
135
+ userMessage,
136
+ '',
137
+ 'Work directly on the files in the current workspace.',
138
+ 'Inspect the codebase before editing.',
139
+ 'Run relevant tests or checks where practical.',
140
+ 'Do not merely describe a solution: make the changes in the workspace.',
141
+ 'When finished, summarize:',
142
+ '- what you changed',
143
+ '- which checks/tests you ran',
144
+ '- any remaining limitations',
145
+ ].join('\n');
146
+ }
147
+
148
+ class MiniSweAgentAdapter extends BaseAdapter {
149
+ constructor(opts) {
150
+ super(opts);
151
+ this.disabledModules = opts.disabledModules || new Set();
152
+
153
+ // channel -> running child process (for stop / cleanup)
154
+ this._channelProcesses = {};
155
+ // channels the user explicitly stopped (suppress "no response" noise)
156
+ this._stoppingChannels = new Set();
157
+ this._trajCounter = 0;
158
+
159
+ // Per-run trajectory files live here, never in the project directory.
160
+ this._sessionsDir = path.join(
161
+ os.homedir(), '.openagents', 'sessions', 'mini-swe-agent',
162
+ `${this.workspaceId}_${this.agentName}`,
163
+ );
164
+
165
+ this._miniBin = this._findMiniBinary();
166
+ if (this._miniBin) {
167
+ this._log(`Using mini-SWE-agent CLI: ${this._miniBin}`);
168
+ } else {
169
+ this._log(`Warning: mini-SWE-agent CLI not found — install with: ${miniInstallHint()}`);
170
+ }
171
+ }
172
+
173
+ // ------------------------------------------------------------------
174
+ // Binary resolution
175
+ // ------------------------------------------------------------------
176
+
177
+ _findMiniBinary() {
178
+ // Shared cross-platform resolver runs `which`/`where` against an ENHANCED
179
+ // PATH (nvm/fnm/volta/homebrew + pip/pipx/uv user-install dirs) — covers the
180
+ // GUI/daemon "installed but not on PATH" case for a `pip install`.
181
+ const resolved = whichBinary('mini');
182
+ if (resolved) return this._resolveExePreference(resolved, IS_WINDOWS);
183
+
184
+ // Explicit fallback over the pip/pipx/uv user-install bin dirs plus the uv
185
+ // tool venv for mini-swe-agent.
186
+ const names = IS_WINDOWS ? ['mini.exe', 'mini.cmd', 'mini'] : ['mini'];
187
+ const dirs = [...aiderBinDirs()];
188
+ const home = os.homedir();
189
+ if (IS_WINDOWS) {
190
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
191
+ const uvTools = process.env.UV_TOOL_DIR || path.join(appData, 'uv', 'tools');
192
+ dirs.push(path.join(uvTools, 'mini-swe-agent', 'Scripts'));
193
+ } else {
194
+ const uvTools = process.env.UV_TOOL_DIR
195
+ || path.join(home, '.local', 'share', 'uv', 'tools');
196
+ dirs.push(path.join(uvTools, 'mini-swe-agent', 'bin'));
197
+ }
198
+ for (const dir of dirs) {
199
+ for (const name of names) {
200
+ const c = path.join(dir, name);
201
+ if (fs.existsSync(c)) return c;
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * On Windows, prefer a directly-executable `mini.exe` over a `mini.cmd`/`.bat`
209
+ * shim that would otherwise have to be launched through a shell. pip/pipx/uv
210
+ * install a console-script `.exe` wrapper, so this normally resolves to the
211
+ * exe; if the resolver landed on a .cmd/.bat and a sibling .exe exists, use the
212
+ * .exe. No-op off Windows. (A .cmd shim still runs correctly via
213
+ * shouldUseShellForBinary — this just avoids the shell hop.)
214
+ */
215
+ _resolveExePreference(binPath, isWindows) {
216
+ if (!isWindows || !/\.(cmd|bat)$/i.test(String(binPath))) return binPath;
217
+ const exe = path.join(path.dirname(binPath), 'mini.exe');
218
+ return fs.existsSync(exe) ? exe : binPath;
219
+ }
220
+
221
+ /**
222
+ * Preflight gate (run by the daemon before join). mini needs a resolvable CLI
223
+ * to do anything useful; when none is found we surface 'runtime_missing' and
224
+ * skip the join. We deliberately do NOT block on a missing model here — mini
225
+ * can read a model from a global config we can't see, and a stray prompt is
226
+ * already made safe by the /dev/null stdin. A genuinely unconfigured model
227
+ * surfaces as a crisp error on the first run instead.
228
+ */
229
+ preflight() {
230
+ if (!this._miniBin) this._miniBin = this._findMiniBinary();
231
+ if (!this._miniBin) {
232
+ return {
233
+ ok: false,
234
+ reason: REASON.RUNTIME_MISSING,
235
+ message: `mini-SWE-agent CLI not found — install with: ${miniInstallHint()}`,
236
+ };
237
+ }
238
+ this._log(`mini-SWE-agent CLI resolved: ${this._miniBin}`);
239
+ return { ok: true };
240
+ }
241
+
242
+ // ------------------------------------------------------------------
243
+ // Config + command construction
244
+ // ------------------------------------------------------------------
245
+
246
+ _model() {
247
+ return String(
248
+ this.agentEnv.MSWEA_MODEL_NAME || this.agentEnv.MSWEA_MODEL
249
+ || this.agentEnv.LLM_MODEL || '',
250
+ ).trim();
251
+ }
252
+
253
+ _costLimit() {
254
+ const raw = String(this.agentEnv.MSWEA_COST_LIMIT || '').trim();
255
+ if (!raw) return null;
256
+ return /^\d+(\.\d+)?$/.test(raw) ? raw : null;
257
+ }
258
+
259
+ /**
260
+ * Build the mini argv. Order is stable: [--model], --yolo, --exit-immediately,
261
+ * [--cost-limit], [--output], --task <prompt>. The task is always the LAST
262
+ * argument and is passed as a single argv element (never string-concatenated),
263
+ * so quotes / spaces / newlines / shell metacharacters in the prompt are
264
+ * carried verbatim with no shell involvement. Config is NOT a flag here — it
265
+ * flows via the MSWEA_MINI_CONFIG_PATH env var (see the file header).
266
+ */
267
+ _buildMiniArgs({ model, costLimit, trajectoryPath, task }) {
268
+ const args = [];
269
+ if (model) args.push('--model', model);
270
+ args.push('--yolo', '--exit-immediately');
271
+ if (costLimit) args.push('--cost-limit', String(costLimit));
272
+ if (trajectoryPath) args.push('--output', trajectoryPath);
273
+ args.push('--task', task);
274
+ return args;
275
+ }
276
+
277
+ _buildSubprocessEnv() {
278
+ const base = getEnhancedEnv(this.agentEnv);
279
+ if (base.NO_COLOR === undefined) base.NO_COLOR = '1';
280
+ // mini is a Python process — force line-buffered stdout so progress streams
281
+ // instead of arriving all at once at exit.
282
+ base.PYTHONUNBUFFERED = '1';
283
+ // Skip mini's interactive first-run setup wizard. mini runs it whenever
284
+ // MSWEA_CONFIGURED is unset (see run/utilities/config.configure_if_first_time),
285
+ // which — under our /dev/null stdin — aborts with a useless "Aborted." even
286
+ // when the user HAS configured a model + key via the agent environment. In a
287
+ // daemon the wizard is never appropriate; the model/keys come from the env.
288
+ if (base.MSWEA_CONFIGURED === undefined) base.MSWEA_CONFIGURED = 'true';
289
+ return base;
290
+ }
291
+
292
+ // ------------------------------------------------------------------
293
+ // Control actions (stop)
294
+ // ------------------------------------------------------------------
295
+
296
+ async _onControlAction(action, payload) {
297
+ if (action === 'stop') {
298
+ for (const [channel, proc] of Object.entries(this._channelProcesses)) {
299
+ this._stoppingChannels.add(channel);
300
+ await this._stopProcess(proc);
301
+ delete this._channelProcesses[channel];
302
+ try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
303
+ }
304
+ return;
305
+ }
306
+ await super._onControlAction(action, payload);
307
+ }
308
+
309
+ async _stopProcess(proc) {
310
+ if (!proc || proc.exitCode !== null) return;
311
+ try {
312
+ if (IS_WINDOWS) {
313
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
314
+ return;
315
+ }
316
+ // POSIX: kill the whole process group (proc was detached) so the shell
317
+ // commands mini spawned under --yolo are reaped too — not left orphaned.
318
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
319
+ await new Promise((resolve) => {
320
+ const timeout = setTimeout(() => {
321
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
322
+ resolve();
323
+ }, 5000);
324
+ proc.on('exit', () => { clearTimeout(timeout); resolve(); });
325
+ });
326
+ } catch {}
327
+ }
328
+
329
+ /** True when an error is a child_process spawn failure (vs. a runtime error). */
330
+ _isSpawnError(e) {
331
+ if (!e) return false;
332
+ const code = e.code || e.errno;
333
+ return (
334
+ e.syscall === 'spawn'
335
+ || String(e.syscall || '').startsWith('spawn ')
336
+ || code === 'ENOENT'
337
+ || code === 'EACCES'
338
+ || code === 'EPERM'
339
+ );
340
+ }
341
+
342
+ // ------------------------------------------------------------------
343
+ // Message handler
344
+ // ------------------------------------------------------------------
345
+
346
+ async _handleMessage(msg) {
347
+ const content = (msg.content || '').trim();
348
+ if (!content) return;
349
+
350
+ const msgChannel = msg.sessionId || this.channelName;
351
+ const sender = msg.senderName || msg.senderType || 'user';
352
+ this._log(`Processing message from ${sender} in ${msgChannel}: ${content.length} chars`);
353
+
354
+ if (!this._miniBin) this._miniBin = this._findMiniBinary();
355
+ if (!this._miniBin) {
356
+ const message = `mini-SWE-agent CLI not found — install with: ${miniInstallHint()}`;
357
+ this._reportStatus(REASON.RUNTIME_MISSING, message);
358
+ await this.sendError(msgChannel, message);
359
+ return;
360
+ }
361
+
362
+ await this._autoTitleChannel(msgChannel, content);
363
+ this._stoppingChannels.delete(msgChannel);
364
+ await this.sendStatus(msgChannel, 'mini-SWE-agent is working...');
365
+
366
+ let result;
367
+ try {
368
+ result = await this._runMini(content, msgChannel);
369
+ } catch (e) {
370
+ // A failure to LAUNCH the process (ENOENT/EACCES) is a distinct, actionable
371
+ // failure — surface it as spawn_failed with the resolved path + code.
372
+ if (this._isSpawnError(e)) {
373
+ const { reason, message } = classifySpawnError(e, {
374
+ label: 'mini-SWE-agent',
375
+ bin: this._miniBin,
376
+ });
377
+ this._log(message);
378
+ this._reportStatus(reason, message);
379
+ await this.sendError(msgChannel, message);
380
+ } else {
381
+ this._log(`Error handling message: ${redactDiagnostic(e.message)}`);
382
+ await this.sendError(
383
+ msgChannel,
384
+ `Error processing message: ${redactDiagnostic(e.message)}`,
385
+ );
386
+ }
387
+ return;
388
+ }
389
+
390
+ if (this._stoppingChannels.has(msgChannel)) {
391
+ this._stoppingChannels.delete(msgChannel);
392
+ return;
393
+ }
394
+ const { text, error } = result;
395
+ if (error) {
396
+ await this.sendError(msgChannel, error);
397
+ return;
398
+ }
399
+ // A successful run proves the runtime is healthy — clear any prior error.
400
+ this._reportStatus(null);
401
+ if (text) {
402
+ await this.sendResponse(msgChannel, text);
403
+ } else {
404
+ await this.sendResponse(
405
+ msgChannel,
406
+ 'mini-SWE-agent finished with no textual output (any file changes were applied '
407
+ + 'to the workspace directory).',
408
+ );
409
+ }
410
+ }
411
+
412
+ // ------------------------------------------------------------------
413
+ // Subprocess execution
414
+ // ------------------------------------------------------------------
415
+
416
+ async _runMini(content, msgChannel) {
417
+ fs.mkdirSync(this._sessionsDir, { recursive: true });
418
+ this._trajCounter += 1;
419
+ // Per-run trajectory file — isolates concurrent channel runs from a shared
420
+ // default trajectory. Removed after the run (we rely on stdout, not the
421
+ // trajectory, for the result).
422
+ const trajectoryPath = path.join(
423
+ this._sessionsDir, `traj-${process.pid}-${this._trajCounter}.json`,
424
+ );
425
+ const cmd = [
426
+ this._miniBin,
427
+ ...this._buildMiniArgs({
428
+ model: this._model(),
429
+ costLimit: this._costLimit(),
430
+ trajectoryPath,
431
+ task: buildTaskPrompt(content),
432
+ }),
433
+ ];
434
+ let result;
435
+ try {
436
+ result = await this._spawnMini(cmd, msgChannel);
437
+ } catch (e) {
438
+ // Keep the trajectory on a launch failure — it is most useful exactly when
439
+ // something went wrong.
440
+ this._log(`mini-SWE-agent trajectory kept for debugging: ${trajectoryPath}`);
441
+ throw e;
442
+ }
443
+ // Retain the trajectory on failure / timeout / cancel (when it matters for
444
+ // debugging); only clean it up on a clean success. Timeout and cancel both
445
+ // surface here as result.error and/or a stopping channel.
446
+ const kept = !!result.error || this._stoppingChannels.has(msgChannel);
447
+ if (kept) {
448
+ this._log(`mini-SWE-agent trajectory kept for debugging: ${trajectoryPath}`);
449
+ } else {
450
+ try { fs.rmSync(trajectoryPath, { force: true }); } catch {}
451
+ }
452
+ return result;
453
+ }
454
+
455
+ _spawnMini(cmd, msgChannel) {
456
+ return new Promise((resolve, reject) => {
457
+ const env = this._buildSubprocessEnv();
458
+
459
+ const proc = spawn(cmd[0], cmd.slice(1), {
460
+ // stdin is /dev/null: mini never has a terminal to prompt against, so a
461
+ // stray confirm / first-run wizard hits EOF and exits instead of hanging.
462
+ stdio: ['ignore', 'pipe', 'pipe'],
463
+ env,
464
+ cwd: this.workingDir,
465
+ detached: !IS_WINDOWS,
466
+ windowsHide: true,
467
+ // Args are always an array (no shell string); the shared helper only
468
+ // opts into a shell for Windows .cmd/.bat shims so the daemon, adapter
469
+ // and launcher probe all agree on the rule.
470
+ shell: shouldUseShellForBinary(cmd[0]),
471
+ });
472
+ this._channelProcesses[msgChannel] = proc;
473
+ // Log the flags only — never the task text or any secret. The task is the
474
+ // last argv element; everything before it is safe to log.
475
+ this._log(`Running mini-SWE-agent: ${cmd.slice(0, cmd.indexOf('--task') + 1).map(String).join(' ')} <task>`);
476
+
477
+ // Guard the child's stdio streams against a benign post-SIGKILL 'error'
478
+ // (EPIPE/ECONNRESET) that would otherwise crash the process.
479
+ if (proc.stdout) proc.stdout.on('error', () => {});
480
+ if (proc.stderr) proc.stderr.on('error', () => {});
481
+
482
+ let stdoutBuf = '';
483
+ let stderrBuf = '';
484
+ let lineBuffer = '';
485
+ let streamCount = 0;
486
+ let pending = Promise.resolve();
487
+
488
+ let idleTimer = null;
489
+ const armIdle = () => {
490
+ if (idleTimer) clearTimeout(idleTimer);
491
+ idleTimer = setTimeout(() => {
492
+ this._log(`mini-SWE-agent produced no output for ${IDLE_TIMEOUT_MS / 1000}s — terminating`);
493
+ this._stopProcess(proc);
494
+ }, IDLE_TIMEOUT_MS);
495
+ };
496
+ armIdle();
497
+
498
+ if (proc.stderr) {
499
+ proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
500
+ }
501
+
502
+ const handleLine = async (raw) => {
503
+ const stripped = raw.replace(ANSI_RE, '').trim();
504
+ if (!stripped) return;
505
+ if (streamCount < MAX_STREAM_UPDATES) {
506
+ streamCount += 1;
507
+ try { await this.sendThinking(msgChannel, stripped.slice(0, 500)); } catch {}
508
+ }
509
+ };
510
+
511
+ proc.stdout.on('data', (chunk) => {
512
+ armIdle();
513
+ const s = chunk.toString('utf-8');
514
+ stdoutBuf += s;
515
+ lineBuffer += s;
516
+ const lines = lineBuffer.split('\n');
517
+ lineBuffer = lines.pop();
518
+ for (const line of lines) {
519
+ pending = pending.then(() => handleLine(line)).catch(() => {});
520
+ }
521
+ });
522
+
523
+ proc.on('exit', async (code) => {
524
+ if (idleTimer) clearTimeout(idleTimer);
525
+ if (lineBuffer) {
526
+ pending = pending.then(() => handleLine(lineBuffer)).catch(() => {});
527
+ }
528
+ try { await pending; } catch {}
529
+ delete this._channelProcesses[msgChannel];
530
+
531
+ // User asked to stop — swallow output, emit no final answer here (the
532
+ // stop handler already sent the "stopped" status).
533
+ if (this._stoppingChannels.has(msgChannel)) {
534
+ resolve({ text: '', error: null });
535
+ return;
536
+ }
537
+
538
+ const stdoutText = cleanOutput(stdoutBuf);
539
+ const stderrText = cleanOutput(stderrBuf);
540
+ if (stderrText) this._log(`mini-SWE-agent stderr: ${stderrText.length} chars`);
541
+
542
+ if (code !== 0) {
543
+ let diagnostic = classifyError(stderrText, stdoutText);
544
+ if (!diagnostic) {
545
+ const tail = (stderrText || stdoutText).split('\n').filter(Boolean);
546
+ const detail = tail.length ? tail[tail.length - 1] : '';
547
+ diagnostic = `mini-SWE-agent exited with code ${code}.${detail ? ` ${redactDiagnostic(detail)}` : ''}`;
548
+ }
549
+ resolve({ text: '', error: diagnostic });
550
+ return;
551
+ }
552
+
553
+ // Exit 0: surface the cleaned transcript (tail-capped — mini's closing
554
+ // summary is at the end). Success ≠ "task definitely fixed", just that
555
+ // mini ran to completion.
556
+ let text = stdoutText;
557
+ if (text.length > FINAL_RESPONSE_MAX) {
558
+ text = `…(earlier output truncated)…\n\n${text.slice(-FINAL_RESPONSE_MAX)}`;
559
+ }
560
+ resolve({ text, error: null });
561
+ });
562
+
563
+ proc.on('error', (err) => {
564
+ if (idleTimer) clearTimeout(idleTimer);
565
+ delete this._channelProcesses[msgChannel];
566
+ reject(err);
567
+ });
568
+ });
569
+ }
570
+ }
571
+
572
+ module.exports = MiniSweAgentAdapter;
@@ -18,7 +18,7 @@ const { spawn, execSync } = require('child_process');
18
18
 
19
19
  const BaseAdapter = require('./base');
20
20
  const { formatAttachmentsForPrompt } = require('./utils');
21
- const { buildOpenclawSkillMd, buildOpenclawSystemPrompt } = require('./workspace-prompt');
21
+ const { buildOpenclawSkillMd, buildOpenclawSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
22
22
  const { getRuntimePrefix } = require('../paths');
23
23
 
24
24
  const IS_WINDOWS = process.platform === 'win32';
@@ -151,7 +151,44 @@ class OpenClawAdapter extends BaseAdapter {
151
151
  return;
152
152
  }
153
153
 
154
- const skillName = `openagents-workspace-${this.agentName}`;
154
+ const skillName = workspaceSkillName(this.agentName);
155
+ // Migration — pre-normalization versions installed the skill under the
156
+ // RAW agent name. For names the normalizer rewrites (uppercase,
157
+ // underscores, …) that leaves a stale directory with an outdated
158
+ // channel/token/endpoint that OpenClaw would keep auto-loading alongside
159
+ // the new one (`always: true` metadata). Remove the exact legacy dir when
160
+ // its name differs from the current one; for already-valid names the two
161
+ // coincide and nothing is deleted.
162
+ // The raw name feeds a recursive delete, so it must be provably inert
163
+ // first — agent names are not validated at creation, and a name carrying
164
+ // path separators or `..` would otherwise let the join escape the skills
165
+ // root (rmSync then destroys whatever it lands on). Only names the safe
166
+ // charset allows are considered, and the resolved target must still be a
167
+ // DIRECT child of the skills root. Legacy dirs from weirder names are
168
+ // left in place — stale beats deleting an unproven path.
169
+ const skillsRoot = path.join(wsDir, 'skills');
170
+ const legacyName = `openagents-workspace-${this.agentName}`;
171
+ const legacyDir = path.join(skillsRoot, legacyName);
172
+ if (
173
+ legacyName !== skillName &&
174
+ /^[A-Za-z0-9._-]+$/.test(String(this.agentName)) &&
175
+ path.dirname(path.resolve(legacyDir)) === path.resolve(skillsRoot)
176
+ ) {
177
+ // Ownership proof — the path checks above cannot see filesystem
178
+ // aliasing (case-insensitive lookup on Windows/macOS, trailing-dot
179
+ // stripping on Windows): agent Foo's legacy path can be the SAME
180
+ // directory as agent foo's CURRENT skill. The stored SKILL.md embeds
181
+ // its owner's identity in a quote-delimited phrase, so delete only
182
+ // when that identity is exactly this agent; otherwise leave the dir.
183
+ let owned = false;
184
+ try {
185
+ const stored = fs.readFileSync(path.join(legacyDir, 'SKILL.md'), 'utf-8');
186
+ owned = stored.includes(`agent '${this.agentName}'`);
187
+ } catch {}
188
+ if (owned) {
189
+ try { fs.rmSync(legacyDir, { recursive: true, force: true }); } catch {}
190
+ }
191
+ }
155
192
  const skillDir = path.join(wsDir, 'skills', skillName);
156
193
  fs.mkdirSync(skillDir, { recursive: true });
157
194