@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 +2 -2
- package/registry.json +95 -0
- package/src/adapters/aider.js +5 -3
- package/src/adapters/base.js +34 -6
- package/src/adapters/claude.js +22 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/mini.js +572 -0
- package/src/skill-installer.js +218 -0
- package/src/update-check.js +61 -9
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openagents-org/agent-launcher",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "OpenAgents Launcher
|
|
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
|
]
|
package/src/adapters/aider.js
CHANGED
|
@@ -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
|
};
|
package/src/adapters/base.js
CHANGED
|
@@ -370,12 +370,31 @@ class BaseAdapter {
|
|
|
370
370
|
}
|
|
371
371
|
|
|
372
372
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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;
|
package/src/adapters/claude.js
CHANGED
|
@@ -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);
|
package/src/adapters/index.js
CHANGED
|
@@ -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;
|
package/src/skill-installer.js
CHANGED
|
@@ -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,
|
package/src/update-check.js
CHANGED
|
@@ -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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
};
|