@openagents-org/agent-launcher 0.2.149 → 0.2.151

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.149",
4
- "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
3
+ "version": "0.2.151",
4
+ "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "agn": "bin/agent-connector.js",
package/registry.json CHANGED
@@ -835,5 +835,100 @@
835
835
  "login_command": "hermes setup",
836
836
  "not_ready_message": "Hermes not configured — run: hermes setup"
837
837
  }
838
+ },
839
+ {
840
+ "name": "mini-swe-agent",
841
+ "label": "mini-SWE-agent",
842
+ "description": "A minimal shell-based coding agent for fixing issues and modifying code in a workspace.",
843
+ "homepage": "https://mini-swe-agent.com",
844
+ "tags": [
845
+ "coding",
846
+ "cli",
847
+ "shell",
848
+ "autonomous"
849
+ ],
850
+ "builtin": true,
851
+ "support": {
852
+ "install": true,
853
+ "workspace": true,
854
+ "collaboration": true
855
+ },
856
+ "install": {
857
+ "binary": "mini",
858
+ "verify": "mini --help",
859
+ "verify_win": "mini --help",
860
+ "macos": "pip install mini-swe-agent",
861
+ "linux": "pip install mini-swe-agent",
862
+ "windows": "pip install mini-swe-agent"
863
+ },
864
+ "launch": {
865
+ "args": []
866
+ },
867
+ "env_config": [
868
+ {
869
+ "name": "MSWEA_MODEL_NAME",
870
+ "description": "Model to use (routed through LiteLLM), e.g. anthropic/claude-sonnet-4-5 or gpt-4o. Optional when a model is already configured via a config file or `mini-extra config setup`. Passed as --model.",
871
+ "required": false,
872
+ "placeholder": "anthropic/claude-sonnet-4-5"
873
+ },
874
+ {
875
+ "name": "MSWEA_MINI_CONFIG_PATH",
876
+ "description": "Path to a FULL mini config file (YAML). Optional. This REPLACES mini's built-in default config (agent prompt templates, step/environment settings) — it does not merge — so provide a complete file (a good starting point is the output of `mini-extra config setup`). Leave blank to use mini's default config.",
877
+ "required": false,
878
+ "placeholder": "/path/to/mini.yaml"
879
+ },
880
+ {
881
+ "name": "MSWEA_COST_LIMIT",
882
+ "description": "Optional per-run cost limit in USD, passed as --cost-limit. Leave blank for mini's default.",
883
+ "required": false,
884
+ "placeholder": "3.0"
885
+ },
886
+ {
887
+ "name": "ANTHROPIC_API_KEY",
888
+ "description": "Anthropic API key — used when the model is an Anthropic model. Leave blank if using another provider or a key already in your environment.",
889
+ "required": false,
890
+ "password": true
891
+ },
892
+ {
893
+ "name": "ANTHROPIC_BASE_URL",
894
+ "description": "Custom endpoint for native `anthropic/…` models (an enterprise gateway or Anthropic-protocol relay). Leave blank for hosted Anthropic. Note: most third-party relays are OpenAI-compatible — for those use an `openai/…` model + OPENAI_BASE_URL instead.",
895
+ "required": false,
896
+ "placeholder": "https://your-anthropic-gateway.example"
897
+ },
898
+ {
899
+ "name": "OPENAI_API_KEY",
900
+ "description": "OpenAI API key — used when the model is an OpenAI model. Leave blank if using another provider or a key already in your environment.",
901
+ "required": false,
902
+ "password": true
903
+ },
904
+ {
905
+ "name": "OPENAI_BASE_URL",
906
+ "description": "Custom endpoint for OpenAI-compatible models (a relay/proxy or self-hosted gateway) — applies only to `openai/…` models. Leave blank for hosted OpenAI, or for native providers like `deepseek/…` / `anthropic/…`.",
907
+ "required": false,
908
+ "placeholder": "https://your-relay.example/v1"
909
+ },
910
+ {
911
+ "name": "DEEPSEEK_API_KEY",
912
+ "description": "DeepSeek API key — used when the model is a `deepseek/…` model (e.g. deepseek/deepseek-chat or deepseek/deepseek-reasoner). Connects directly to api.deepseek.com; no base URL needed.",
913
+ "required": false,
914
+ "password": true
915
+ },
916
+ {
917
+ "name": "DEEPSEEK_API_BASE",
918
+ "description": "Custom endpoint for native `deepseek/…` models. Leave blank to use DeepSeek's default (api.deepseek.com).",
919
+ "required": false,
920
+ "placeholder": "https://api.deepseek.com"
921
+ }
922
+ ],
923
+ "check_ready": {
924
+ "env_vars": [
925
+ "MSWEA_MODEL_NAME",
926
+ "MSWEA_MINI_CONFIG_PATH",
927
+ "ANTHROPIC_API_KEY",
928
+ "OPENAI_API_KEY",
929
+ "DEEPSEEK_API_KEY"
930
+ ],
931
+ "not_ready_message": "mini-SWE-agent CLI detected. Configure a model through environment variables (MSWEA_MODEL_NAME + a provider key), a full config file (MSWEA_MINI_CONFIG_PATH), or the Model field before running a task."
932
+ }
838
933
  }
839
934
  ]
@@ -107,13 +107,14 @@ function classifyError(stderr, stdout) {
107
107
  // ---------------------------------------------------------------------------
108
108
 
109
109
  const VALID_PROVIDERS = [
110
- 'auto', 'openai', 'anthropic', 'openrouter', 'gemini', 'deepseek',
110
+ 'auto', 'openai', 'anthropic', 'openrouter', 'requesty', 'gemini', 'deepseek',
111
111
  'openai-compatible',
112
112
  ];
113
113
  const PROVIDER_KEY_VAR = {
114
114
  openai: 'OPENAI_API_KEY',
115
115
  anthropic: 'ANTHROPIC_API_KEY',
116
116
  openrouter: 'OPENROUTER_API_KEY',
117
+ requesty: 'REQUESTY_API_KEY',
117
118
  gemini: 'GEMINI_API_KEY',
118
119
  deepseek: 'DEEPSEEK_API_KEY',
119
120
  'openai-compatible': 'OPENAI_API_KEY',
@@ -122,6 +123,7 @@ const PROVIDER_KEY_VAR = {
122
123
  function explicitPrefixProvider(model) {
123
124
  const m = String(model || '').trim().toLowerCase();
124
125
  if (m.startsWith('openrouter/')) return 'openrouter';
126
+ if (m.startsWith('requesty/')) return 'requesty';
125
127
  if (m.startsWith('anthropic/')) return 'anthropic';
126
128
  if (m.startsWith('openai/')) return 'openai';
127
129
  if (m.startsWith('gemini/') || m.startsWith('google/')) return 'gemini';
@@ -167,7 +169,7 @@ function resolveAiderProvider(provider, model, apiKey, baseUrl) {
167
169
  return {
168
170
  env: {}, model,
169
171
  error: `Unknown AIDER_PROVIDER '${provider}'. Valid values: auto, openai, `
170
- + 'anthropic, openrouter, gemini, deepseek, openai-compatible.',
172
+ + 'anthropic, openrouter, requesty, gemini, deepseek, openai-compatible.',
171
173
  };
172
174
  }
173
175
  if (!provider) provider = 'auto';
@@ -191,7 +193,7 @@ function resolveAiderProvider(provider, model, apiKey, baseUrl) {
191
193
  return {
192
194
  env: {}, model,
193
195
  error: 'Could not determine the model provider for LLM_API_KEY. Set '
194
- + 'AIDER_PROVIDER (openai, anthropic, openrouter, gemini, deepseek, or '
196
+ + 'AIDER_PROVIDER (openai, anthropic, openrouter, requesty, gemini, deepseek, or '
195
197
  + 'openai-compatible), use an AIDER_MODEL whose name identifies the '
196
198
  + 'provider, or set the native provider key directly.',
197
199
  };
@@ -370,12 +370,31 @@ class BaseAdapter {
370
370
  }
371
371
 
372
372
  try {
373
- const result = installer.installSkill({
374
- skill,
375
- agentType: this.agentType,
376
- workingDir: this.workingDir,
377
- log: (m) => this._log(`skill.install: ${m}`),
378
- });
373
+ // Custom (uploaded) skills carry source_type=workspace_file + a file_id.
374
+ // Download the package bytes here (async), then hand them to the sync
375
+ // installer. Catalog skills keep their existing fetch-by-source path.
376
+ const sourceType = skill && (skill.source_type || skill.sourceType);
377
+ let result;
378
+ if (sourceType === 'workspace_file') {
379
+ const fileId = skill.file_id || skill.fileId;
380
+ if (!fileId) throw new Error('workspace_file skill is missing file_id');
381
+ this._log(`skill.install: downloading uploaded package (file_id=${fileId})`);
382
+ const buffer = await this.client.readFile(this.workspaceId, this.token, fileId);
383
+ if (!buffer || buffer.length === 0) throw new Error('uploaded skill file is empty');
384
+ result = installer.installUploadedSkill({
385
+ skill, buffer,
386
+ agentType: this.agentType,
387
+ workingDir: this.workingDir,
388
+ log: (m) => this._log(`skill.install: ${m}`),
389
+ });
390
+ } else {
391
+ result = installer.installSkill({
392
+ skill,
393
+ agentType: this.agentType,
394
+ workingDir: this.workingDir,
395
+ log: (m) => this._log(`skill.install: ${m}`),
396
+ });
397
+ }
379
398
  try {
380
399
  await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
381
400
  skillId, state: 'installed', path: result.path, partial: result.partial === true,
@@ -688,6 +707,15 @@ class BaseAdapter {
688
707
  }
689
708
 
690
709
  if (this._channelBusy.has(channel)) {
710
+ // A routine that's already running must not stack up. Routine fires are
711
+ // periodic, so a fire that arrives while the previous run is still going
712
+ // is simply dropped — the next scheduled tick re-fires once the agent is
713
+ // free. (The backend guard normally prevents this from even being sent;
714
+ // this is the last line of defense against a queue backlog.)
715
+ if (msg.senderName === 'system:routine') {
716
+ this._log(`Skipping routine fire in ${channel} — previous run still in progress`);
717
+ return;
718
+ }
691
719
  if (!this._channelQueues[channel]) this._channelQueues[channel] = [];
692
720
  const queueId = `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
693
721
  msg._queueId = queueId;
@@ -971,6 +971,15 @@ class ClaudeAdapter extends BaseAdapter {
971
971
  try { await this.sendResponse(msgChannel, fullResponse); } catch {}
972
972
  } else if (existingPP.lastErrorText) {
973
973
  try { await this.sendError(msgChannel, this._formatClaudeError(existingPP.lastErrorText)); } catch {}
974
+ } else if (!existingPP.everPostedAnything) {
975
+ // Error with no text and no error message — don't leave the UI stuck
976
+ // on "thinking…". Reset the (possibly corrupt) session and tell the user.
977
+ if (this._channelSessions[msgChannel]) {
978
+ delete this._channelSessions[msgChannel];
979
+ try { this._saveSessions(); } catch {}
980
+ this._log(`Empty-error result — cleared session for ${msgChannel}`);
981
+ }
982
+ try { await this.sendError(msgChannel, 'The agent hit an error and could not respond. The session was reset — please send the message again.'); } catch {}
974
983
  }
975
984
  this._resetIdleTimer(msgChannel);
976
985
  if (!msg._todoNudge) {
@@ -1145,6 +1154,19 @@ class ClaudeAdapter extends BaseAdapter {
1145
1154
  // Claude finished with an error and no assistant text (e.g. 401 invalid
1146
1155
  // token). Surface it instead of silently dropping the turn.
1147
1156
  try { await this.sendError(msgChannel, this._formatClaudeError(pp.lastErrorText)); } catch {}
1157
+ } else if (!pp.everPostedAnything) {
1158
+ // Claude ended in error with NO assistant text AND an empty error
1159
+ // message (e.g. a fast code=1 crash while resuming a corrupt session).
1160
+ // Without this the turn is silently dropped and the UI hangs forever on
1161
+ // "thinking…". Clear the (possibly bad) session so the next message
1162
+ // starts fresh instead of re-resuming the same broken state, and tell
1163
+ // the user rather than leaving a dead spinner.
1164
+ if (this._channelSessions[msgChannel]) {
1165
+ delete this._channelSessions[msgChannel];
1166
+ try { this._saveSessions(); } catch {}
1167
+ this._log(`Empty-error result — cleared session for ${msgChannel}`);
1168
+ }
1169
+ try { await this.sendError(msgChannel, 'The agent hit an error and could not respond. The session was reset — please send the message again.'); } catch {}
1148
1170
  }
1149
1171
 
1150
1172
  this._resetIdleTimer(msgChannel);
@@ -19,6 +19,7 @@ const GooseAdapter = require('./goose');
19
19
  const CopilotAdapter = require('./copilot');
20
20
  const ClineAdapter = require('./cline');
21
21
  const AmpAdapter = require('./amp');
22
+ const MiniSweAgentAdapter = require('./mini');
22
23
 
23
24
  const ADAPTER_MAP = {
24
25
  openclaw: OpenClawAdapter,
@@ -35,11 +36,12 @@ const ADAPTER_MAP = {
35
36
  copilot: CopilotAdapter,
36
37
  cline: ClineAdapter,
37
38
  amp: AmpAdapter,
39
+ 'mini-swe-agent': MiniSweAgentAdapter,
38
40
  };
39
41
 
40
42
  /**
41
43
  * Create an adapter instance for the given agent type.
42
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp)
44
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp, mini-swe-agent)
43
45
  * @param {object} opts - Adapter constructor options
44
46
  * @returns {BaseAdapter}
45
47
  */
@@ -67,6 +69,7 @@ module.exports = {
67
69
  CopilotAdapter,
68
70
  ClineAdapter,
69
71
  AmpAdapter,
72
+ MiniSweAgentAdapter,
70
73
  createAdapter,
71
74
  ADAPTER_MAP,
72
75
  };
@@ -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;
@@ -24,9 +24,16 @@
24
24
  const fs = require('fs');
25
25
  const os = require('os');
26
26
  const path = require('path');
27
+ const zlib = require('zlib');
27
28
  const { execFileSync } = require('child_process');
28
29
  const { defaultAgentWorkdir } = require('./paths');
29
30
 
31
+ // Uploaded-skill (.zip) extraction guards. Mirrors the server-side limits in
32
+ // workspace/backend/app/custom_skills.py so both ends agree.
33
+ const MAX_ZIP_ENTRIES = 2000;
34
+ const MAX_ZIP_UNCOMPRESSED_BYTES = 50 * 1024 * 1024; // 50 MB expanded total
35
+ const MAX_ZIP_COMPRESSION_RATIO = 200; // expanded / stored
36
+
30
37
  /**
31
38
  * Resolve the skills directory for a given agent type, rooted at the agent's
32
39
  * working directory. Different runtimes look in different places:
@@ -257,6 +264,47 @@ function installSkill({ skill, agentType, workingDir, fetcher, log }) {
257
264
  return { path: destDir, skillId: norm.id, partial };
258
265
  }
259
266
 
267
+ /**
268
+ * Install a user-uploaded custom skill (source_type=workspace_file) from an
269
+ * already-downloaded Buffer. Unlike catalog skills, the bytes are provided by
270
+ * the caller (the adapter downloads them via WorkspaceClient.readFile) — the
271
+ * installer never touches the network here.
272
+ *
273
+ * - `.md` → written verbatim as <destDir>/SKILL.md
274
+ * - `.zip` → extracted with full path/symlink/zip-bomb validation; a single
275
+ * top-level directory is stripped so SKILL.md ends up at the root.
276
+ *
277
+ * Reuses installSkill's dest-dir resolution, clean-on-reinstall, and final
278
+ * SKILL.md verification by passing a synchronous fetcher that writes the Buffer.
279
+ *
280
+ * @param {object} args
281
+ * @param {object} args.skill metadata (id, package_type, filename, …)
282
+ * @param {Buffer} args.buffer the uploaded file bytes
283
+ * @param {string} args.agentType
284
+ * @param {string} [args.workingDir]
285
+ * @param {function} [args.log]
286
+ * @returns {{path: string, skillId: string, partial: boolean}}
287
+ */
288
+ function installUploadedSkill({ skill, buffer, agentType, workingDir, log }) {
289
+ const _log = log || (() => {});
290
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
291
+ const id = (skill && (skill.id || skill.skill_id)) || 'unknown';
292
+ throw new Error(`uploaded skill "${id}" has no file content`);
293
+ }
294
+ const packageType = _resolvePackageType(skill, buffer);
295
+ const fetcher = ({ destDir }) => {
296
+ if (packageType === 'zip') {
297
+ _installUploadedZip(buffer, destDir, _log);
298
+ } else {
299
+ fs.mkdirSync(destDir, { recursive: true });
300
+ fs.writeFileSync(path.join(destDir, 'SKILL.md'), buffer);
301
+ _log('wrote uploaded SKILL.md');
302
+ }
303
+ return { partial: false };
304
+ };
305
+ return installSkill({ skill, agentType, workingDir, fetcher, log: _log });
306
+ }
307
+
260
308
  /**
261
309
  * Remove an installed skill directory. Idempotent.
262
310
  *
@@ -363,6 +411,175 @@ function _copyDir(src, dest) {
363
411
  }
364
412
  }
365
413
 
414
+ // ---------------------------------------------------------------------------
415
+ // Uploaded-skill (workspace_file) install internals
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /** Decide md vs zip: declared package_type → zip magic sniff → filename hint. */
419
+ function _resolvePackageType(skill, buffer) {
420
+ const declared = String((skill && (skill.package_type || skill.packageType)) || '').toLowerCase();
421
+ if (declared === 'zip' || declared === 'md') return declared;
422
+ if (Buffer.isBuffer(buffer) && buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b) {
423
+ return 'zip'; // "PK"
424
+ }
425
+ const fn = String((skill && (skill.filename || skill.file_name)) || '').toLowerCase();
426
+ if (fn.endsWith('.zip')) return 'zip';
427
+ return 'md';
428
+ }
429
+
430
+ /** Only a real SKILL.md counts (uploaded zips must ship one, not just any *.md). */
431
+ function _hasSkillMd(dir) {
432
+ for (const c of ['SKILL.md', 'skill.md', 'Skill.md']) {
433
+ if (fs.existsSync(path.join(dir, c))) return true;
434
+ }
435
+ return false;
436
+ }
437
+
438
+ // Path safety for a zip entry name. Mirrors _is_unsafe_zip_name in the backend:
439
+ // reject absolute paths, Windows drive paths, and any ".." segment.
440
+ function _isUnsafeZipName(name) {
441
+ if (!name) return true;
442
+ if (name.startsWith('/') || name.startsWith('\\')) return true;
443
+ if (/^[A-Za-z]:/.test(name)) return true;
444
+ return name.split(/[\\/]/).some((seg) => seg === '..');
445
+ }
446
+
447
+ function _isSymlinkMode(unixMode) {
448
+ return (unixMode & 0o170000) === 0o120000; // S_IFLNK
449
+ }
450
+
451
+ /**
452
+ * Parse a zip's central directory into entry descriptors. We trust the central
453
+ * directory (not local headers) for sizes/method, and reject zip64 (skills are
454
+ * small). Returns [{name, method, compSize, uncompSize, unixMode, localOffset}].
455
+ */
456
+ function _readZipEntries(buf) {
457
+ const EOCD_SIG = 0x06054b50;
458
+ const maxBack = Math.min(buf.length, 22 + 0xffff);
459
+ let eocd = -1;
460
+ for (let i = buf.length - 22; i >= buf.length - maxBack && i >= 0; i--) {
461
+ if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
462
+ }
463
+ if (eocd < 0) throw new Error('invalid zip: end-of-central-directory not found');
464
+
465
+ const totalEntries = buf.readUInt16LE(eocd + 10);
466
+ const cdOffset = buf.readUInt32LE(eocd + 16);
467
+ if (totalEntries === 0xffff || cdOffset === 0xffffffff) {
468
+ throw new Error('zip64 archives are not supported');
469
+ }
470
+
471
+ const entries = [];
472
+ let p = cdOffset;
473
+ for (let n = 0; n < totalEntries; n++) {
474
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) {
475
+ throw new Error('invalid zip: corrupt central directory');
476
+ }
477
+ const method = buf.readUInt16LE(p + 10);
478
+ const compSize = buf.readUInt32LE(p + 20);
479
+ const uncompSize = buf.readUInt32LE(p + 24);
480
+ const nameLen = buf.readUInt16LE(p + 28);
481
+ const extraLen = buf.readUInt16LE(p + 30);
482
+ const commentLen = buf.readUInt16LE(p + 32);
483
+ const externalAttr = buf.readUInt32LE(p + 38);
484
+ const localOffset = buf.readUInt32LE(p + 42);
485
+ if (compSize === 0xffffffff || uncompSize === 0xffffffff || localOffset === 0xffffffff) {
486
+ throw new Error('zip64 archives are not supported');
487
+ }
488
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
489
+ entries.push({
490
+ name, method, compSize, uncompSize, localOffset,
491
+ unixMode: (externalAttr >>> 16) & 0xffff,
492
+ });
493
+ p += 46 + nameLen + extraLen + commentLen;
494
+ }
495
+ return entries;
496
+ }
497
+
498
+ /** Read + decompress one entry's bytes (store or deflate only). */
499
+ function _zipEntryData(buf, e) {
500
+ if (e.localOffset + 30 > buf.length || buf.readUInt32LE(e.localOffset) !== 0x04034b50) {
501
+ throw new Error(`invalid zip: bad local header for "${e.name}"`);
502
+ }
503
+ const nameLen = buf.readUInt16LE(e.localOffset + 26);
504
+ const extraLen = buf.readUInt16LE(e.localOffset + 28);
505
+ const start = e.localOffset + 30 + nameLen + extraLen;
506
+ const end = start + e.compSize;
507
+ if (end > buf.length) throw new Error(`invalid zip: truncated data for "${e.name}"`);
508
+ const raw = buf.subarray(start, end);
509
+ if (e.method === 0) return Buffer.from(raw); // stored
510
+ if (e.method === 8) return zlib.inflateRawSync(raw); // deflate
511
+ throw new Error(`unsupported zip compression method ${e.method} for "${e.name}"`);
512
+ }
513
+
514
+ /**
515
+ * Extract a validated zip Buffer into `outDir`. Enforces entry-count, total-
516
+ * size and compression-ratio caps, rejects unsafe paths / symlinks, and
517
+ * verifies every written path stays inside `outDir`.
518
+ */
519
+ function _extractZipToDir(buffer, outDir, log) {
520
+ const entries = _readZipEntries(buffer);
521
+ if (entries.length > MAX_ZIP_ENTRIES) {
522
+ throw new Error(`zip has too many entries (> ${MAX_ZIP_ENTRIES})`);
523
+ }
524
+ let totalUncomp = 0;
525
+ let totalComp = 0;
526
+ for (const e of entries) { totalUncomp += e.uncompSize; totalComp += e.compSize; }
527
+ if (totalUncomp > MAX_ZIP_UNCOMPRESSED_BYTES) {
528
+ throw new Error('zip expands too large');
529
+ }
530
+ if (totalComp > 0 && totalUncomp / totalComp > MAX_ZIP_COMPRESSION_RATIO) {
531
+ throw new Error('zip compression ratio looks abusive (possible zip bomb)');
532
+ }
533
+
534
+ const root = path.resolve(outDir);
535
+ fs.mkdirSync(root, { recursive: true });
536
+ for (const e of entries) {
537
+ if (_isSymlinkMode(e.unixMode)) throw new Error(`zip contains a symlink entry: "${e.name}"`);
538
+ if (_isUnsafeZipName(e.name)) throw new Error(`zip contains an unsafe path: "${e.name}"`);
539
+ const isDir = e.name.endsWith('/');
540
+ const dest = path.resolve(root, e.name.replace(/\\/g, '/'));
541
+ if (dest !== root && !dest.startsWith(root + path.sep)) {
542
+ throw new Error(`zip entry escapes target dir: "${e.name}"`);
543
+ }
544
+ if (isDir) { fs.mkdirSync(dest, { recursive: true }); continue; }
545
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
546
+ fs.writeFileSync(dest, _zipEntryData(buffer, e));
547
+ }
548
+ if (log) log(`extracted ${entries.length} zip entr${entries.length === 1 ? 'y' : 'ies'}`);
549
+ }
550
+
551
+ /**
552
+ * Install an uploaded `.zip` into `destDir`: extract to a temp dir, verify a
553
+ * SKILL.md is present (stripping a single wrapper directory if needed), then
554
+ * copy the validated tree in. On any failure the temp dir is cleaned up and
555
+ * nothing partial is written into destDir (installSkill emptied it first).
556
+ */
557
+ function _installUploadedZip(buffer, destDir, log) {
558
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oa-skillzip-'));
559
+ try {
560
+ _extractZipToDir(buffer, tmp, log);
561
+
562
+ // Strip a single top-level wrapper dir (my-skill/SKILL.md → SKILL.md).
563
+ let srcRoot = tmp;
564
+ if (!_hasSkillMd(tmp)) {
565
+ const ents = fs.readdirSync(tmp, { withFileTypes: true });
566
+ const dirs = ents.filter((e) => e.isDirectory());
567
+ const files = ents.filter((e) => !e.isDirectory());
568
+ if (dirs.length === 1 && files.length === 0 && _hasSkillMd(path.join(tmp, dirs[0].name))) {
569
+ srcRoot = path.join(tmp, dirs[0].name);
570
+ }
571
+ }
572
+ if (!_hasSkillMd(srcRoot)) {
573
+ throw new Error('uploaded skill .zip has no SKILL.md');
574
+ }
575
+
576
+ fs.mkdirSync(destDir, { recursive: true });
577
+ _copyDir(srcRoot, destDir);
578
+ } finally {
579
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
580
+ }
581
+ }
582
+
366
583
  // Blocking GET used only by defaultFetcher's HTTPS fallback (which runs off
367
584
  // the hot path inside a try/catch). Uses curl for redirect handling; returns
368
585
  // the body string or throws. git sparse-checkout is the primary strategy, so
@@ -379,6 +596,7 @@ function _httpGetSync(url) {
379
596
  module.exports = {
380
597
  skillsDirForAgentType,
381
598
  installSkill,
599
+ installUploadedSkill,
382
600
  uninstallSkill,
383
601
  listInstalledSkills,
384
602
  normalizeSkill,
@@ -115,6 +115,44 @@ function npmPrefixFromBin(npmBin, platform = process.platform) {
115
115
  return platform === 'win32' ? binDir : p.dirname(binDir);
116
116
  }
117
117
 
118
+ /**
119
+ * Detect when the running launcher lives in an *isolated runtime* — the local
120
+ * npm project install.sh creates at `~/.openagents/nodejs`, where the package
121
+ * is at `<dir>/node_modules/@openagents-org/agent-launcher` and `<dir>` has its
122
+ * own `package.json`.
123
+ *
124
+ * This matters because a `-g` install (what a normal global/nvm install needs)
125
+ * lands in `<dir>/lib/node_modules`, but the isolated runtime's daemon loads
126
+ * from `<dir>/node_modules` — so `agn update` would report success while the
127
+ * running code never changed (the acen "update no-op" incident). For an
128
+ * isolated runtime we must do a *local* install into `<dir>` instead.
129
+ *
130
+ * Returns the project dir (`<dir>`) for an isolated runtime, else null (caller
131
+ * falls back to the global `-g` path). Global/nvm layouts are excluded: their
132
+ * package sits under `.../lib/node_modules` (basename 'lib') and the prefix
133
+ * root has no package.json.
134
+ */
135
+ function isolatedRuntimeDir(opts = {}) {
136
+ const dir = opts.dir || __dirname; // <pkgRoot>/src
137
+ const exists = opts.exists || fs.existsSync;
138
+ // Path impl is injectable so tests can force POSIX/Windows semantics
139
+ // regardless of the host OS (mirrors npmPrefixFromBin's `platform`).
140
+ const p = opts.path || path;
141
+ try {
142
+ const pkgRoot = p.resolve(dir, '..'); // .../agent-launcher
143
+ const nodeModules = p.resolve(pkgRoot, '..', '..'); // .../node_modules
144
+ if (p.basename(nodeModules) !== 'node_modules') return null;
145
+ const projectDir = p.resolve(nodeModules, '..'); // dir containing node_modules
146
+ // Global npm layout is <prefix>/lib/node_modules — not an isolated project.
147
+ if (p.basename(projectDir) === 'lib') return null;
148
+ // A real local project has its own package.json; a global prefix does not.
149
+ if (!exists(p.join(projectDir, 'package.json'))) return null;
150
+ return projectDir;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
118
156
  /**
119
157
  * Locate the npm executable next to the running node binary, falling back to
120
158
  * a PATH lookup. On Windows the runnable file is `npm.cmd`; the extensionless
@@ -150,15 +188,28 @@ function runUpdate(opts = {}) {
150
188
  const platform = opts.platform || process.platform;
151
189
  const spawn = opts.spawn || spawnSync;
152
190
  const npmBin = opts.npmBin || findNpmBin({ platform });
153
- const prefix = opts.prefix !== undefined ? opts.prefix : npmPrefixFromBin(npmBin, platform);
154
- // Use a GLOBAL install (-g). A LOCAL install (`npm install <pkg> --prefix
155
- // <dir>`) treats <dir> as a project root and prunes every node_modules entry
156
- // not reachable from its package.json. When the prefix is the portable node
157
- // dir, that deletes node's own bundled npm/corepack (and other agent runtimes),
158
- // breaking every subsequent `agn` command. A global install only adds/updates
159
- // the named package into <prefix>/node_modules and never prunes its siblings.
160
- const args = ['install', '-g', `${PKG_NAME}@latest`];
161
- if (prefix) args.push('--prefix', prefix);
191
+ const isoDir = opts.isolatedDir !== undefined ? opts.isolatedDir : isolatedRuntimeDir();
192
+
193
+ let args;
194
+ if (isoDir) {
195
+ // Isolated runtime (install.sh's ~/.openagents/nodejs): a LOCAL npm project
196
+ // whose package lives in <dir>/node_modules — NOT <dir>/lib/node_modules.
197
+ // A `-g` install lands in lib/node_modules and silently no-ops the running
198
+ // launcher/daemon (which loads from node_modules). Install locally into the
199
+ // project dir instead. `--no-save` leaves package.json untouched (install.sh
200
+ // owns it), and because <dir>'s package.json already lists our deps npm has
201
+ // nothing extraneous to prune.
202
+ args = ['install', '--no-save', `${PKG_NAME}@latest`, '--prefix', isoDir];
203
+ } else {
204
+ // Global / nvm install. A LOCAL install (`npm install <pkg> --prefix <dir>`)
205
+ // here would treat <dir> as a project root and prune every node_modules
206
+ // entry not reachable from its package.json — deleting node's own bundled
207
+ // npm/corepack. A global install only adds/updates the named package into
208
+ // <prefix>/node_modules and never prunes its siblings.
209
+ const prefix = opts.prefix !== undefined ? opts.prefix : npmPrefixFromBin(npmBin, platform);
210
+ args = ['install', '-g', `${PKG_NAME}@latest`];
211
+ if (prefix) args.push('--prefix', prefix);
212
+ }
162
213
 
163
214
  // On Windows, npm.cmd is a batch file and must be invoked through cmd.exe.
164
215
  // Passing the args via cmd.exe with shell:false lets Node quote paths that
@@ -240,5 +291,6 @@ module.exports = {
240
291
  runUpdate,
241
292
  findNpmBin,
242
293
  npmPrefixFromBin,
294
+ isolatedRuntimeDir,
243
295
  currentVersion,
244
296
  };