scalemax 0.1.47 → 0.1.49

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/README.md CHANGED
@@ -78,6 +78,7 @@ scalemax doctor Run diagnostics
78
78
  scalemax models List available ScaleMax models
79
79
  scalemax helper-bot Open the ScaleMax-only support assistant
80
80
  scalemax claude-tools Install bounded GitHub skill tools for Claude
81
+ scalemax fix-roo Fix Roo Code stuck at "API Request…" on recent VS Code
81
82
  scalemax update Reconfigure detected clients with the stored key
82
83
  scalemax remove Remove ScaleMax-generated client configuration
83
84
  scalemax --help Show help
@@ -172,6 +173,8 @@ ScaleMax creates family-specific Hermes providers in `~/.hermes/config.yaml` and
172
173
 
173
174
  Roo Code stores provider profiles and secrets in VS Code extension storage, so ScaleMax does not mutate its private database. Instead, the CLI writes a private `0600` Roo settings-import file at `~/.scalemax/roo-code-import.json` and points the supported `roo-cline.autoImportSettingsPath` VS Code setting at it. Codex, Claude, and tool-capable DeepSeek profiles remain separate. DeepSeek is offered only when authenticated discovery reports `capabilities.tools=true`; otherwise Roo fails closed and points the user to Continue Chat/Edit. Reload VS Code once so Roo imports supported profiles into its SecretStorage. CLI verification intentionally reports the handoff as verified, not the extension runtime, until that reload occurs.
174
175
 
176
+ Roo Code 3.54 (its latest release) looks for ripgrep only in folders newer VS Code releases no longer use, so on recent VS Code versions every task hangs at "API Request…" with "Could not find ripgrep binary". Configuring Roo Code also patches the installed extension's `dist/extension.js` (in the editor's per-user extensions folder, never the VS Code app) so it tries `node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/<platform>-<arch>/` first and then its original folders. The untouched file is kept once as `extension.js.scalemax-orig`. Run `npx scalemax@latest fix-roo` to apply only this fix (VS Code, Insiders, Cursor, Windsurf, VSCodium, Trae, Kiro, and remote servers), `scalemax fix-roo --undo` to restore the original, and reload VS Code afterwards. `scalemax doctor` flags installs that still need it.
177
+
175
178
  ### Cline (CLI and VS Code)
176
179
 
177
180
  ScaleMax supports the verified Cline CLI `3.0.47` and VS Code extension `4.0.12` formats. The CLI surface uses Cline's built-in `openai-native` provider and Responses protocol. The extension uses its built-in OpenAI-compatible provider. Both receive the raw ScaleMax key and the matching credit or token base URL; ScaleMax never writes a custom `Authorization` or `x-api-key` header because Cline generates `Authorization: Bearer …` itself. Configuration is attempted only while Cline and VS Code are stopped, preserves unrelated state, uses private atomic files with backups and rollback, and fails closed on unknown versions or malformed/symlinked state.
@@ -209,6 +209,8 @@ export async function configureCommand(opts = {}) {
209
209
  const ctx = { ...baseCtx, apiKey, baseUrl: selectedBaseUrl, providerScope, models: keyInfo.models, interactive: Boolean(process.stdin.isTTY), allowAppModification };
210
210
  const res = await client.configure(ctx);
211
211
  spinner.succeed(`${client.name} ${res.message}`);
212
+ if (res.note)
213
+ console.log(chalk.dim(` ${res.note}`));
212
214
  if (client.verify) {
213
215
  const verify = ora(`Verifying ${client.name}`).start();
214
216
  try {
@@ -1 +1,8 @@
1
+ /**
2
+ * Roo Code 3.54 cannot find newer VS Code's relocated ripgrep, so every task
3
+ * hangs at "API Request…". Read-only: reports installs that still need
4
+ * `scalemax fix-roo`. A warning, not a failure, because older VS Code builds
5
+ * still work unpatched.
6
+ */
7
+ export declare function rooRipgrepDoctorLines(home: string): Promise<string[]>;
1
8
  export declare function doctorCommand(home?: string): Promise<void>;
@@ -6,6 +6,26 @@ import { validateApiKey, testStreaming } from '../utils/api.js';
6
6
  import { homeDir } from '../utils/paths.js';
7
7
  import { clients } from '../clients.js';
8
8
  import { SCALEMAX_BASE_URL } from '../constants.js';
9
+ import { repairRooRipgrep, rooInstallLabel } from '../repair/roo-ripgrep.js';
10
+ /**
11
+ * Roo Code 3.54 cannot find newer VS Code's relocated ripgrep, so every task
12
+ * hangs at "API Request…". Read-only: reports installs that still need
13
+ * `scalemax fix-roo`. A warning, not a failure, because older VS Code builds
14
+ * still work unpatched.
15
+ */
16
+ export async function rooRipgrepDoctorLines(home) {
17
+ const results = await repairRooRipgrep(home, { dryRun: true });
18
+ return results.map(result => {
19
+ const label = rooInstallLabel(result);
20
+ if (result.status === 'patched')
21
+ return `${chalk.yellow('!')} ${label} cannot find ripgrep on recent VS Code, so tasks hang at "API Request…". Run ${chalk.bold('npx scalemax@latest fix-roo')}, then Developer: Reload Window.`;
22
+ if (result.status === 'already_patched')
23
+ return `${chalk.green('✓')} ${label} ripgrep fix for newer VS Code applied`;
24
+ if (result.status === 'unsupported')
25
+ return `${chalk.dim('•')} ${label} ripgrep lookup not recognised; fix-roo not needed`;
26
+ return `${chalk.yellow('!')} ${label} could not be checked for the ripgrep fix: ${result.error}`;
27
+ });
28
+ }
9
29
  export async function doctorCommand(home = homeDir()) {
10
30
  let failures = 0;
11
31
  const key = await loadApiKey(home);
@@ -61,6 +81,8 @@ export async function doctorCommand(home = homeDir()) {
61
81
  else
62
82
  console.log(`${chalk.green('✓')} ${client.name} configured (${d.reason || 'detector confirmed'})`);
63
83
  }
84
+ for (const line of await rooRipgrepDoctorLines(home))
85
+ console.log(line);
64
86
  console.log('');
65
87
  if (failures === 0)
66
88
  console.log(chalk.green('Everything looks good!'));
@@ -0,0 +1 @@
1
+ export declare function fixRooCommand(args?: readonly string[], home?: string): Promise<void>;
@@ -0,0 +1,64 @@
1
+ import chalk from 'chalk';
2
+ import { repairRooRipgrep, restoreRooRipgrep, rooInstallLabel } from '../repair/roo-ripgrep.js';
3
+ import { homeDir } from '../utils/paths.js';
4
+ const RELOAD_HINT = 'Reload VS Code (Developer: Reload Window) so Roo Code picks up the fix.';
5
+ export async function fixRooCommand(args = [], home = homeDir()) {
6
+ if (args.includes('--undo'))
7
+ return undoFixRoo(home);
8
+ const results = await repairRooRipgrep(home);
9
+ if (!results.length) {
10
+ console.log(chalk.yellow('! Roo Code is not installed in VS Code or a VS Code-based editor on this computer. Nothing to fix.'));
11
+ return;
12
+ }
13
+ console.log(chalk.dim(`Found ${results.length} Roo Code installation${results.length === 1 ? '' : 's'}.`));
14
+ let failures = 0;
15
+ for (const result of results) {
16
+ const label = rooInstallLabel(result);
17
+ if (result.status === 'patched') {
18
+ console.log(chalk.green(`✓ ${label}: patched — it now finds newer VS Code's ripgrep.`));
19
+ console.log(chalk.dim(` Original saved as ${result.backup}`));
20
+ }
21
+ else if (result.status === 'already_patched') {
22
+ console.log(chalk.green(`✓ ${label}: already patched.`));
23
+ }
24
+ else if (result.status === 'unsupported') {
25
+ console.log(chalk.yellow(`! ${label}: ripgrep lookup not recognised; left unchanged (this version may not need the fix).`));
26
+ }
27
+ else {
28
+ failures++;
29
+ console.log(chalk.red(`✗ ${label}: could not patch ${result.script}: ${result.error}`));
30
+ }
31
+ }
32
+ if (results.some(result => result.status === 'patched' || result.status === 'already_patched'))
33
+ console.log(`\n${RELOAD_HINT}`);
34
+ if (failures)
35
+ throw new Error(`${failures} Roo Code installation${failures === 1 ? '' : 's'} could not be patched. Close VS Code and run \`npx scalemax@latest fix-roo\` again.`);
36
+ }
37
+ async function undoFixRoo(home) {
38
+ const results = await restoreRooRipgrep(home);
39
+ if (!results.length) {
40
+ console.log(chalk.yellow('! Roo Code is not installed in VS Code or a VS Code-based editor on this computer. Nothing to undo.'));
41
+ return;
42
+ }
43
+ let failures = 0;
44
+ for (const result of results) {
45
+ const label = rooInstallLabel(result);
46
+ if (result.status === 'restored')
47
+ console.log(chalk.green(`✓ ${label}: original extension restored.`));
48
+ else if (result.status === 'not_patched')
49
+ console.log(chalk.dim(`• ${label}: not patched; nothing to undo.`));
50
+ else {
51
+ failures++;
52
+ const reason = result.status === 'no_backup'
53
+ ? `no ScaleMax backup found next to ${result.script}`
54
+ : result.status === 'mismatch'
55
+ ? `the extension changed after it was patched, so the backup at ${result.backup} was not applied`
56
+ : result.error;
57
+ console.log(chalk.red(`✗ ${label}: ${reason}. Reinstall Roo Code to get a clean copy.`));
58
+ }
59
+ }
60
+ if (results.some(result => result.status === 'restored'))
61
+ console.log('\nReload VS Code (Developer: Reload Window) so Roo Code loads the original files.');
62
+ if (failures)
63
+ throw new Error(`${failures} Roo Code installation${failures === 1 ? '' : 's'} could not be restored.`);
64
+ }
@@ -714,7 +714,7 @@ export function selectHelperModel(models, requested) {
714
714
  throw new Error(`Model "${requested}" is not available to this API key. Run \`scalemax models\` to see available models.`);
715
715
  return exact.id;
716
716
  }
717
- const preferred = ['gpt-5.4', 'gpt-5.4-mini', 'claude-haiku-4-5-20251001', 'fable-5', 'gpt-5.6-terra', 'gpt-5.6-sol'];
717
+ const preferred = ['gpt-5.6-luna', 'claude-haiku-4-5-20251001', 'fable-5', 'gpt-5.6-terra', 'gpt-5.6-sol'];
718
718
  for (const id of preferred) {
719
719
  const match = models.find(model => model.id.toLowerCase() === id);
720
720
  if (match)
@@ -1020,6 +1020,8 @@ async function runOneHelperClientAction(kind, client, connection, home, requeste
1020
1020
  write(`→ Configuring ${client.name} with the authenticated stored key…`);
1021
1021
  const configured = await client.configure(ctx);
1022
1022
  write(`✓ ${client.name} ${configured.message}`);
1023
+ if (configured.note)
1024
+ write(` ${configured.note}`);
1023
1025
  if (client.verify) {
1024
1026
  write(`→ Verifying ${client.name} configuration…`);
1025
1027
  try {
@@ -23,6 +23,12 @@ export const HELPER_DOC_SECTIONS = [
23
23
  keywords: ['roo', 'roo code', 'vscode', 'vs code', 'extension', 'visual studio code', 'deepseek'],
24
24
  content: `For Roo Code, the ScaleMax CLI writes a private settings import file at ~/.scalemax/roo-code-import.json and sets roo-cline.autoImportSettingsPath in VS Code User settings. Roo imports that profile after VS Code is reloaded. A DeepSeek V4 Flash profile is offered only when the authenticated catalog reports capabilities.tools=true. If the active route is text-only, Roo fails closed because its task loop requires tools and the helper recommends Continue Chat/Edit instead. The CLI can install the official Roo Code extension with code --install-extension RooVeterinaryInc.roo-cline when VS Code is detected. On Windows, helper-bot may install VS Code first with winget using the fixed Microsoft.VisualStudioCode package id, then install Roo, write the import handoff, and verify the files.`
25
25
  },
26
+ {
27
+ id: 'roo-code-ripgrep',
28
+ title: 'Roo Code stuck at API Request on recent VS Code versions',
29
+ keywords: ['ripgrep', 'stuck', 'hang', 'hangs', 'hanging', 'frozen', 'fix-roo'],
30
+ content: `Roo Code 3.54, its latest release, looks for VS Code's bundled ripgrep only in folders newer VS Code releases moved to node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/<platform>-<arch>/, so on recent VS Code versions every Roo task hangs at "API Request..." with "Could not find ripgrep binary", whatever the model or provider; the ScaleMax key is fine. Fix: run npx scalemax@latest fix-roo, then Developer: Reload Window. It patches only the installed Roo Code extension in the home folder, keeps the original as extension.js.scalemax-orig, never changes the VS Code app, and fix-roo --undo restores it. Configuring Roo Code with the CLI applies the same fix and scalemax doctor reports installs that still need it. Alternatives: copy rg from that folder to <VS Code app>/resources/app/node_modules/@vscode/ripgrep/bin/ (removed by every VS Code update), or use Cline or Kilo Code.`
31
+ },
26
32
  {
27
33
  id: 'continue-deepseek',
28
34
  title: 'Continue setup for DeepSeek V4 Flash',
@@ -4,12 +4,12 @@ import { jsonConfig } from './templates.js';
4
4
  const id = 'api-code';
5
5
  const name = 'API Code';
6
6
  export async function configure(ctx) {
7
- const file = clientConfigPath(id, ctx.home, ctx.platform);
7
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
8
8
  const content = jsonConfig(name, '$SCALEMAX_API_KEY', ctx.baseUrl, { environment: { SCALEMAX_API_KEY: 'your ScaleMax API key', SCALEMAX_BASE_URL: ctx.baseUrl }, node: `import OpenAI from 'openai';\nconst client = new OpenAI({ apiKey: process.env.SCALEMAX_API_KEY, baseURL: '${ctx.baseUrl}' });`, python: `from openai import OpenAI\nclient = OpenAI(api_key='...', base_url='${ctx.baseUrl}')` });
9
9
  const result = await writeManagedConfig(ctx, id, name, file, content);
10
10
  return { ...result, message: 'API code examples written' };
11
11
  }
12
12
  export async function remove(ctx) {
13
- const file = clientConfigPath(id, ctx.home, ctx.platform);
13
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
14
14
  return removeManagedConfig(ctx, id, name, file);
15
15
  }
@@ -6,7 +6,23 @@ export interface CodexVerificationResult {
6
6
  configPath: string;
7
7
  authPath: string;
8
8
  }
9
- export declare function mergeCodexConfig(existing: string, baseUrl: string, model?: string, authHash?: string): string;
9
+ export declare function mergeCodexConfig(existing: string, baseUrl: string, model?: string, authHash?: string, keyHash?: string): string;
10
+ /**
11
+ * Take ScaleMax OUT of a config.toml, keeping everything else the user has --
12
+ * including what Codex or the user added after setup (trusted projects,
13
+ * notices, other providers).
14
+ *
15
+ * Ownership is read from the CONTENT, not only from the generated header: a
16
+ * Codex rewrite that drops comments still leaves `model_provider = "scalemax"`
17
+ * and the `[model_providers.scalemax]` table, and those must go. The three
18
+ * top-level keys are removed only while ScaleMax is still the provider; if the
19
+ * user switched provider after setup they are the user's.
20
+ */
21
+ export declare function stripScaleMaxCodexConfig(existing: string): {
22
+ text: string;
23
+ found: boolean;
24
+ strippedTopLevel: boolean;
25
+ };
10
26
  export declare function verifyCodexConfiguration(ctx: ConfigureContext): Promise<CodexVerificationResult>;
11
27
  export declare function install(ctx: InstallContext): Promise<ConfigureResult>;
12
28
  export declare function verify(ctx: ConfigureContext): Promise<ConfigureResult>;
@@ -10,17 +10,29 @@ import { AUTO_MODEL_ID, preferredOpenAiResponsesModel } from './models.js';
10
10
  const id = 'codex';
11
11
  const name = 'Codex';
12
12
  const provider = 'scalemax';
13
- const fallbackModel = 'gpt-5.5';
13
+ const fallbackModel = 'gpt-5.6-luna'; // gpt-5.5 retired 2026-09-30; same price
14
14
  const reasoningEffort = 'medium';
15
15
  const managedAuthHashPrefix = '# ScaleMax managed auth.json SHA-256: ';
16
+ // Fingerprint of the API key setup wrote into auth.json. Unlike the auth.json
17
+ // hash above it survives Codex rewriting auth.json (a token refresh, its own
18
+ // serialisation), so remove() can still tell OUR key from one the user put
19
+ // there later.
20
+ const managedKeyHashPrefix = '# ScaleMax managed API key SHA-256: ';
21
+ const modelsComment = '# ScaleMax models are loaded from the authenticated live model catalog.';
22
+ // The top-level keys setup owns while ScaleMax is the provider. remove() puts
23
+ // the user's pre-ScaleMax values for exactly these back from the backup.
24
+ const managedTopLevelKey = /^(model|model_provider|model_reasoning_effort)\s*=/;
16
25
  function contentSha256(content) {
17
26
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
18
27
  }
19
- function managedAuthHash(config) {
20
- const marker = config.split(/\r?\n/).find(line => line.startsWith(managedAuthHashPrefix));
21
- const hash = marker?.slice(managedAuthHashPrefix.length).trim();
28
+ function markerHash(config, prefix) {
29
+ const marker = config.split(/\r?\n/).find(line => line.startsWith(prefix));
30
+ const hash = marker?.slice(prefix.length).trim();
22
31
  return hash && /^[a-f0-9]{64}$/.test(hash) ? hash : undefined;
23
32
  }
33
+ function managedAuthHash(config) {
34
+ return markerHash(config, managedAuthHashPrefix);
35
+ }
24
36
  function selectedModel(ctx) {
25
37
  if (!ctx.models) {
26
38
  // An auto-api key's ONLY model is `auto`; gpt-5.5 (the generic fallback
@@ -30,11 +42,22 @@ function selectedModel(ctx) {
30
42
  }
31
43
  return preferredOpenAiResponsesModel(ctx);
32
44
  }
33
- function scalemaxCodexBlock(baseUrl, model = fallbackModel, authHash) {
45
+ function scalemaxCodexBlock(baseUrl, model = fallbackModel, authHash, keyHash) {
34
46
  const authMarker = authHash ? `${managedAuthHashPrefix}${authHash}\n` : '';
35
- return `# ${GENERATED_HEADER}\n${authMarker}model = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n# ScaleMax models are loaded from the authenticated live model catalog.\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
47
+ const keyMarker = keyHash ? `${managedKeyHashPrefix}${keyHash}\n` : '';
48
+ return `# ${GENERATED_HEADER}\n${authMarker}${keyMarker}model = "${model}"\nmodel_provider = "${provider}"\nmodel_reasoning_effort = "${reasoningEffort}"\n${modelsComment}\n\n[model_providers.${provider}]\nname = "ScaleMax"\nbase_url = "${baseUrl}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
49
+ }
50
+ /** A line setup writes as a comment. Never user content, and never preserved. */
51
+ function isManagedCommentLine(line) {
52
+ return line === `# ${GENERATED_HEADER}`
53
+ || line === modelsComment
54
+ || line.startsWith(managedAuthHashPrefix)
55
+ || line.startsWith(managedKeyHashPrefix);
36
56
  }
37
- export function mergeCodexConfig(existing, baseUrl, model = fallbackModel, authHash) {
57
+ function isScaleMaxProviderSection(section) {
58
+ return section === `model_providers.${provider}` || section.startsWith(`model_providers.${provider}.`);
59
+ }
60
+ export function mergeCodexConfig(existing, baseUrl, model = fallbackModel, authHash, keyHash) {
38
61
  const preserved = [];
39
62
  let section = '';
40
63
  let skipScaleMaxProvider = false;
@@ -43,20 +66,94 @@ export function mergeCodexConfig(existing, baseUrl, model = fallbackModel, authH
43
66
  const sectionMatch = line.match(/^\[([^\]]+)\]$/);
44
67
  if (sectionMatch) {
45
68
  section = sectionMatch[1];
46
- skipScaleMaxProvider = section === `model_providers.${provider}`;
69
+ skipScaleMaxProvider = isScaleMaxProviderSection(section);
47
70
  if (skipScaleMaxProvider)
48
71
  continue;
49
72
  }
50
73
  if (skipScaleMaxProvider)
51
74
  continue;
52
- if (!section && /^(model|model_provider|model_reasoning_effort)\s*=/.test(line))
75
+ if (!section && managedTopLevelKey.test(line))
53
76
  continue;
54
- if (line === `# ${GENERATED_HEADER}` || line.startsWith(managedAuthHashPrefix))
77
+ // Includes the models comment: it used to be kept here, so every re-run of
78
+ // setup stacked one more copy of it into the preserved tail.
79
+ if (isManagedCommentLine(line))
55
80
  continue;
56
81
  preserved.push(raw);
57
82
  }
58
83
  const tail = preserved.join('\n').trim();
59
- return `${scalemaxCodexBlock(baseUrl, model, authHash)}${tail ? `\n${tail}\n` : ''}`;
84
+ return `${scalemaxCodexBlock(baseUrl, model, authHash, keyHash)}${tail ? `\n${tail}\n` : ''}`;
85
+ }
86
+ /**
87
+ * Take ScaleMax OUT of a config.toml, keeping everything else the user has --
88
+ * including what Codex or the user added after setup (trusted projects,
89
+ * notices, other providers).
90
+ *
91
+ * Ownership is read from the CONTENT, not only from the generated header: a
92
+ * Codex rewrite that drops comments still leaves `model_provider = "scalemax"`
93
+ * and the `[model_providers.scalemax]` table, and those must go. The three
94
+ * top-level keys are removed only while ScaleMax is still the provider; if the
95
+ * user switched provider after setup they are the user's.
96
+ */
97
+ export function stripScaleMaxCodexConfig(existing) {
98
+ const lines = existing.split(/\r?\n/);
99
+ let section = '';
100
+ let topProvider;
101
+ let found = false;
102
+ for (const raw of lines) {
103
+ const line = raw.trim();
104
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
105
+ if (sectionMatch) {
106
+ section = sectionMatch[1];
107
+ if (isScaleMaxProviderSection(section))
108
+ found = true;
109
+ continue;
110
+ }
111
+ if (isManagedCommentLine(line))
112
+ found = true;
113
+ if (!section) {
114
+ const providerMatch = line.match(/^model_provider\s*=\s*"([^"]*)"/);
115
+ if (providerMatch)
116
+ topProvider = providerMatch[1];
117
+ }
118
+ }
119
+ const strippedTopLevel = topProvider === provider;
120
+ if (strippedTopLevel)
121
+ found = true;
122
+ if (!found)
123
+ return { text: existing, found: false, strippedTopLevel: false };
124
+ const kept = [];
125
+ section = '';
126
+ let skip = false;
127
+ for (const raw of lines) {
128
+ const line = raw.trim();
129
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
130
+ if (sectionMatch) {
131
+ section = sectionMatch[1];
132
+ skip = isScaleMaxProviderSection(section);
133
+ if (skip)
134
+ continue;
135
+ }
136
+ if (skip)
137
+ continue;
138
+ if (!section && strippedTopLevel && managedTopLevelKey.test(line))
139
+ continue;
140
+ if (isManagedCommentLine(line))
141
+ continue;
142
+ kept.push(raw);
143
+ }
144
+ return { text: kept.join('\n').trim(), found: true, strippedTopLevel };
145
+ }
146
+ /** The pre-ScaleMax top-level model / model_provider / model_reasoning_effort lines of a backup. */
147
+ function originalTopLevelLines(backup) {
148
+ const out = [];
149
+ for (const raw of backup.split(/\r?\n/)) {
150
+ const line = raw.trim();
151
+ if (/^\[[^\]]+\]$/.test(line))
152
+ break;
153
+ if (managedTopLevelKey.test(line))
154
+ out.push(line);
155
+ }
156
+ return out;
60
157
  }
61
158
  function codexAuth(apiKey, existing = {}) {
62
159
  return JSON.stringify({ ...existing, auth_mode: 'apikey', OPENAI_API_KEY: apiKey }, null, 2) + '\n';
@@ -111,22 +208,99 @@ async function latestRestorableBackup(file, home) {
111
208
  }
112
209
  return undefined;
113
210
  }
114
- async function restoreOrRemove(file, ctx, managedOverride) {
211
+ async function removeCodexConfig(file, ctx) {
115
212
  if (!(await fs.pathExists(file)))
116
213
  return { changed: false, message: 'no ScaleMax configuration found' };
117
214
  const current = await fs.readFile(file, 'utf8').catch(() => '');
118
- const structurallyManaged = current.includes(GENERATED_HEADER)
119
- || (file.endsWith('auth.json') && isManagedCodexAuth(current));
120
- const managed = managedOverride ?? structurallyManaged;
121
- if (!managed)
215
+ const stripped = stripScaleMaxCodexConfig(current);
216
+ if (!stripped.found)
217
+ return { changed: false, message: 'left unmanaged configuration unchanged' };
218
+ // Put back the user's own model settings from before setup (the first
219
+ // pre-ScaleMax backup) -- only when ScaleMax's were actually taken out.
220
+ let restoredTop = [];
221
+ if (stripped.strippedTopLevel) {
222
+ const backup = await latestRestorableBackup(file, ctx.home);
223
+ if (backup)
224
+ restoredTop = originalTopLevelLines(await fs.readFile(backup, 'utf8').catch(() => ''));
225
+ }
226
+ const next = [restoredTop.join('\n'), stripped.text].filter(Boolean).join('\n\n');
227
+ if (!next.trim()) {
228
+ await fs.remove(file);
229
+ return { changed: true, message: 'removed ScaleMax configuration' };
230
+ }
231
+ await atomicWrite(file, `${next}\n`, 0o600, ctx.platform);
232
+ return {
233
+ changed: true,
234
+ message: restoredTop.length
235
+ ? 'removed ScaleMax settings and restored your previous model settings'
236
+ : 'removed ScaleMax settings; your other settings were kept',
237
+ };
238
+ }
239
+ /**
240
+ * Take the ScaleMax credential OUT of auth.json.
241
+ *
242
+ * Owned when ANY of: the file is byte-identical to what setup wrote (auth
243
+ * hash marker); its key is the key the CLI has saved; its key matches the
244
+ * key fingerprint setup wrote into config.toml. The last two survive Codex
245
+ * rewriting the file -- a ChatGPT token refresh or its own serialisation --
246
+ * which the exact hash alone did not, and which left the ScaleMax key in
247
+ * place after "remove". A DIFFERENT key the user put there after setup is
248
+ * none of these, and is left alone.
249
+ *
250
+ * Only the two fields setup sets are touched: auth_mode and OPENAI_API_KEY go
251
+ * back to their pre-setup values from the backup, or away if there were none.
252
+ * Tokens and refresh times stay as Codex last wrote them (they are fresher
253
+ * than the backup). A file left with nothing in it is deleted.
254
+ */
255
+ async function removeCodexAuth(file, ctx, ownership) {
256
+ if (!(await fs.pathExists(file)))
257
+ return { changed: false, message: 'no ScaleMax configuration found' };
258
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
259
+ let current;
260
+ try {
261
+ const parsed = JSON.parse(text);
262
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
263
+ throw new Error('not an object');
264
+ current = parsed;
265
+ }
266
+ catch {
122
267
  return { changed: false, message: 'left unmanaged configuration unchanged' };
123
- const restore = await latestRestorableBackup(file, ctx.home);
124
- if (restore) {
125
- await atomicWrite(file, await fs.readFile(restore, 'utf8'), 0o600, ctx.platform);
126
- return { changed: true, message: `restored backup ${restore}` };
127
268
  }
128
- await fs.remove(file);
129
- return { changed: true, message: 'removed ScaleMax configuration' };
269
+ const key = typeof current.OPENAI_API_KEY === 'string' ? current.OPENAI_API_KEY : undefined;
270
+ const owned = (ownership.authHash !== undefined && contentSha256(text) === ownership.authHash)
271
+ || (key !== undefined && typeof ctx.apiKey === 'string' && ctx.apiKey.length > 0 && key === ctx.apiKey)
272
+ || (key !== undefined && ownership.keyHash !== undefined && contentSha256(key) === ownership.keyHash)
273
+ || (ownership.authHash === undefined && ownership.keyHash === undefined && ownership.legacyStructural && isManagedCodexAuth(text));
274
+ if (!owned)
275
+ return { changed: false, message: 'left unmanaged configuration unchanged' };
276
+ let original;
277
+ const backup = await latestRestorableBackup(file, ctx.home);
278
+ if (backup) {
279
+ try {
280
+ const parsed = JSON.parse(await fs.readFile(backup, 'utf8'));
281
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
282
+ original = parsed;
283
+ }
284
+ catch { /* an unreadable backup restores nothing */ }
285
+ }
286
+ const next = { ...current };
287
+ for (const field of ['auth_mode', 'OPENAI_API_KEY']) {
288
+ if (original && Object.prototype.hasOwnProperty.call(original, field))
289
+ next[field] = original[field];
290
+ else
291
+ delete next[field];
292
+ }
293
+ const meaningful = Object.values(next).some(value => value !== null && value !== undefined
294
+ && !(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0));
295
+ if (!original && !meaningful) {
296
+ await fs.remove(file);
297
+ return { changed: true, message: 'removed ScaleMax configuration' };
298
+ }
299
+ await atomicWrite(file, JSON.stringify(next, null, 2) + '\n', 0o600, ctx.platform);
300
+ return {
301
+ changed: true,
302
+ message: original ? 'removed the ScaleMax key and restored your previous sign-in' : 'removed the ScaleMax key',
303
+ };
130
304
  }
131
305
  export async function verifyCodexConfiguration(ctx) {
132
306
  const configPath = codexConfigPath(ctx.home);
@@ -261,7 +435,7 @@ export async function configure(ctx) {
261
435
  }
262
436
  catch { }
263
437
  const nextAuth = codexAuth(ctx.apiKey, existingAuth);
264
- const nextConfig = mergeCodexConfig(originalConfig, ctx.baseUrl, selectedModel(ctx), contentSha256(nextAuth));
438
+ const nextConfig = mergeCodexConfig(originalConfig, ctx.baseUrl, selectedModel(ctx), contentSha256(nextAuth), contentSha256(ctx.apiKey));
265
439
  const changed = originalConfig !== nextConfig || originalAuth !== nextAuth;
266
440
  const alreadyManaged = originalConfig.includes(GENERATED_HEADER);
267
441
  let verification;
@@ -312,24 +486,18 @@ export async function configure(ctx) {
312
486
  export async function remove(ctx) {
313
487
  const configPath = codexConfigPath(ctx.home);
314
488
  const authPath = codexAuthPath(ctx.home);
315
- const [configText, authText] = await Promise.all([
316
- fs.readFile(configPath, 'utf8').catch(() => ''),
317
- fs.readFile(authPath, 'utf8').catch(() => ''),
318
- ]);
319
- const expectedAuthHash = managedAuthHash(configText);
320
- // New generated configs bind ownership to the exact auth content. A hash
321
- // mismatch means the user signed in or edited auth.json after setup, so
322
- // removal must leave that current authentication state untouched. Legacy
323
- // configs without the marker retain the conservative structural fallback.
324
- const authManagedOverride = expectedAuthHash
325
- ? contentSha256(authText) === expectedAuthHash
326
- : configText.includes(GENERATED_HEADER)
327
- ? undefined
328
- : false;
489
+ // Read the ownership markers BEFORE config.toml is rewritten below.
490
+ const configText = await fs.readFile(configPath, 'utf8').catch(() => '');
491
+ const ownership = {
492
+ authHash: managedAuthHash(configText),
493
+ keyHash: markerHash(configText, managedKeyHashPrefix),
494
+ // Configs written before either marker existed: the original structural rule.
495
+ legacyStructural: configText.includes(GENERATED_HEADER),
496
+ };
329
497
  const snapshots = await captureFileSnapshots([configPath, authPath]);
330
498
  try {
331
- const config = await restoreOrRemove(configPath, ctx);
332
- const auth = await restoreOrRemove(authPath, ctx, authManagedOverride);
499
+ const config = await removeCodexConfig(configPath, ctx);
500
+ const auth = await removeCodexAuth(authPath, ctx, ownership);
333
501
  return {
334
502
  id,
335
503
  name,
@@ -15,9 +15,8 @@ const preferredOpenAi = [
15
15
  'gpt-5.6-sol',
16
16
  'gpt-6-sol',
17
17
  'gpt-6-luna',
18
- 'gpt-5.5',
19
- 'gpt-5.4',
20
- 'gpt-5.4-mini',
18
+ // gpt-5.5 / gpt-5.4 / gpt-5.4-mini retired 2026-09-30.
19
+ 'gpt-5.6-luna',
21
20
  'gpt-5.3-codex',
22
21
  'codex-auto-review',
23
22
  // Gemini / Grok / Qwen ride the same OpenAI-compatible route, so they are
@@ -126,7 +125,8 @@ export function fastModel(ctx) {
126
125
  }
127
126
  export function fastOpenAiModel(ctx) {
128
127
  const ids = openAiModelIds(ctx);
129
- return ids.find(id => id === 'gpt-5.4-mini')
128
+ return ids.find(id => id === 'gpt-5.6-luna')
129
+ || ids.find(id => id === 'gpt-6-luna')
130
130
  || ids.find(id => /(^|[-.])(mini|nano)([-.]|$)/i.test(id));
131
131
  }
132
132
  /** The lightweight model for an OpenAI-compatible chat client, if one exists. */
@@ -117,7 +117,7 @@ async function scrubGeneratedPlaintextCredentials(ctx, files) {
117
117
  return changed;
118
118
  }
119
119
  export async function configure(ctx) {
120
- const file = clientConfigPath(id, ctx.home, ctx.platform);
120
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
121
121
  const credentialFile = secretPath(ctx.home);
122
122
  const userAgentFile = userAgentPath(ctx.home);
123
123
  const legacyFile = path.join(ctx.home, '.openclaw', 'config.json');
@@ -272,7 +272,7 @@ export async function configure(ctx) {
272
272
  }
273
273
  }
274
274
  export async function verify(ctx) {
275
- const file = clientConfigPath(id, ctx.home, ctx.platform);
275
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
276
276
  const config = await fs.readJson(file);
277
277
  const codex = config?.models?.providers?.[codexProviderName];
278
278
  const claude = config?.models?.providers?.[claudeProviderName];
@@ -319,7 +319,7 @@ export async function verify(ctx) {
319
319
  return { id, name, changed: false, path: file, message: `verified ${codexIds.length} Responses, ${deepSeekIds.length} ${chatLabel}, and ${claudeIds.length} Claude models` };
320
320
  }
321
321
  export async function remove(ctx) {
322
- const file = clientConfigPath(id, ctx.home, ctx.platform);
322
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
323
323
  if (!(await fs.pathExists(file))) {
324
324
  const removed = await removeClientSecret(secretPath(ctx.home), ctx);
325
325
  const removedUserAgent = await removeClientSecret(userAgentPath(ctx.home), ctx);
@@ -38,7 +38,7 @@ function providerConfig(ctx) {
38
38
  };
39
39
  }
40
40
  export async function configure(ctx) {
41
- const file = clientConfigPath(id, ctx.home, ctx.platform);
41
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
42
42
  const selected = `scalemax/${preferredChatCompletionsModel(ctx)}`;
43
43
  const quick = fastChatCompletionsModel(ctx);
44
44
  const credentialFile = secretPath(ctx.home);
@@ -78,7 +78,7 @@ export async function configure(ctx) {
78
78
  };
79
79
  }
80
80
  export async function verify(ctx) {
81
- const file = clientConfigPath(id, ctx.home, ctx.platform);
81
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
82
82
  const config = await fs.readJson(file);
83
83
  const provider = config?.provider?.scalemax;
84
84
  if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !== secretReference) {
@@ -98,7 +98,7 @@ export async function verify(ctx) {
98
98
  return { id, name, changed: false, path: file, message: `verified ${configured.length} ScaleMax models` };
99
99
  }
100
100
  export async function remove(ctx) {
101
- const file = clientConfigPath(id, ctx.home, ctx.platform);
101
+ const file = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
102
102
  if (!(await fs.pathExists(file))) {
103
103
  const removed = await removeClientSecret(secretPath(ctx.home), ctx);
104
104
  return { id, name, changed: removed, path: file, message: removed ? 'removed orphaned ScaleMax OpenCode credential' : 'no ScaleMax configuration found' };
@@ -5,6 +5,7 @@ import { atomicWrite, backupFile, captureFileSnapshots, restoreFileSnapshots, sn
5
5
  import { clientConfigPath } from './paths.js';
6
6
  import { anthropicModelIds, deepSeekChatModelIds, isDeepSeekChatModelId, modelName, modelSupportsTools, openAiBaseUrl, openAiResponsesModelIds, preferredAnthropicModel, preferredChatCompletionsModel, preferredOpenAiResponsesModel } from './models.js';
7
7
  import { isAnthropicModel } from '../utils/api.js';
8
+ import { rooRipgrepRepairNote } from '../repair/roo-ripgrep.js';
8
9
  const id = 'roo-code';
9
10
  const name = 'Roo Code (VS Code)';
10
11
  const importSetting = 'roo-cline.autoImportSettingsPath';
@@ -149,6 +150,14 @@ function handoff(ctx) {
149
150
  }, null, 2) + '\n';
150
151
  }
151
152
  export async function configure(ctx) {
153
+ const result = await configureHandoff(ctx);
154
+ // Roo Code 3.54 cannot find newer VS Code's relocated ripgrep, so every task
155
+ // hangs at "API Request…" whatever the provider. Patch the installed
156
+ // extension too; this is non-fatal and independent of the handoff above.
157
+ const note = await rooRipgrepRepairNote(ctx.home);
158
+ return note ? { ...result, note } : result;
159
+ }
160
+ async function configureHandoff(ctx) {
152
161
  const settingsFile = clientConfigPath(id, ctx.home, ctx.platform, ctx.env);
153
162
  const profileFile = importPath(ctx.home);
154
163
  const snapshots = await captureFileSnapshots([settingsFile, profileFile]);
@@ -11,7 +11,7 @@ export function jsonConfig(client, apiKey, baseUrl, extra = {}) {
11
11
  }, null, 2) + '\n';
12
12
  }
13
13
  export function continueConfig(apiKey, baseUrl) {
14
- const models = ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'codex-auto-review', 'deepseek-v4-flash'];
14
+ const models = ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.3-codex', 'codex-auto-review', 'deepseek-v4-flash'];
15
15
  return JSON.stringify({
16
16
  models: models.map(model => ({
17
17
  title: model === 'deepseek-v4-flash' ? 'ScaleMax DeepSeek V4 Flash' : `ScaleMax ${model}`,
@@ -21,7 +21,7 @@ export function continueConfig(apiKey, baseUrl) {
21
21
  apiKey,
22
22
  ...(model === 'deepseek-v4-flash' ? { useResponsesApi: false } : {})
23
23
  })),
24
- tabAutocompleteModel: { title: 'ScaleMax gpt-5.4-mini', provider: 'openai', model: 'gpt-5.4-mini', apiBase: baseUrl, apiKey },
24
+ tabAutocompleteModel: { title: 'ScaleMax gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna', apiBase: baseUrl, apiKey },
25
25
  contextProviders: [{ name: 'code' }, { name: 'docs' }, { name: 'diff' }, { name: 'terminal' }]
26
26
  }, null, 2) + '\n';
27
27
  }
@@ -1,2 +1,29 @@
1
1
  import { DetectContext, DetectionResult } from '../types/client.js';
2
+ export declare const ROO_EXTENSION_PATTERN: RegExp;
3
+ export interface EditorExtensionRoot {
4
+ /** Human label used in CLI output, e.g. "VS Code" or "Cursor". */
5
+ editor: string;
6
+ /** Path segments below the home folder. */
7
+ segments: readonly string[];
8
+ }
9
+ /**
10
+ * VS Code's own per-user extension folders. Detection stays limited to these
11
+ * because configure writes VS Code's settings.json, which forks do not read.
12
+ */
13
+ export declare const VSCODE_EXTENSION_ROOTS: readonly EditorExtensionRoot[];
14
+ /**
15
+ * Every per-user extension folder Roo Code can be installed into: VS Code,
16
+ * its forks, and their remote (SSH/WSL) servers. Used by repairs that fix the
17
+ * installed extension itself rather than writing editor settings.
18
+ */
19
+ export declare const ALL_EDITOR_EXTENSION_ROOTS: readonly EditorExtensionRoot[];
20
+ export interface RooExtensionDir {
21
+ editor: string;
22
+ /** Absolute extension folder, e.g. ~/.vscode/extensions/rooveterinaryinc.roo-cline-3.54.0 */
23
+ dir: string;
24
+ /** Version parsed from the folder name ("3.54.0"), when present. */
25
+ version?: string;
26
+ }
27
+ /** Roo Code extension folders under the given roots; missing roots are skipped. */
28
+ export declare function findRooExtensionDirs(home: string, roots?: readonly EditorExtensionRoot[]): Promise<RooExtensionDir[]>;
2
29
  export declare function detect(ctx: DetectContext): Promise<DetectionResult>;
@@ -3,20 +3,46 @@ import fs from 'fs-extra';
3
3
  import JSON5 from 'json5';
4
4
  import { commandExists } from '../utils/exec.js';
5
5
  import { clientConfigPath } from '../configure/paths.js';
6
- const extensionPattern = /^rooveterinaryinc\.roo-cline-/i;
7
- async function extensionPaths(home) {
8
- const roots = [path.join(home, '.vscode', 'extensions'), path.join(home, '.vscode-insiders', 'extensions')];
6
+ export const ROO_EXTENSION_PATTERN = /^rooveterinaryinc\.roo-cline-/i;
7
+ /**
8
+ * VS Code's own per-user extension folders. Detection stays limited to these
9
+ * because configure writes VS Code's settings.json, which forks do not read.
10
+ */
11
+ export const VSCODE_EXTENSION_ROOTS = [
12
+ { editor: 'VS Code', segments: ['.vscode', 'extensions'] },
13
+ { editor: 'VS Code Insiders', segments: ['.vscode-insiders', 'extensions'] },
14
+ ];
15
+ /**
16
+ * Every per-user extension folder Roo Code can be installed into: VS Code,
17
+ * its forks, and their remote (SSH/WSL) servers. Used by repairs that fix the
18
+ * installed extension itself rather than writing editor settings.
19
+ */
20
+ export const ALL_EDITOR_EXTENSION_ROOTS = [
21
+ ...VSCODE_EXTENSION_ROOTS,
22
+ { editor: 'Cursor', segments: ['.cursor', 'extensions'] },
23
+ { editor: 'Windsurf', segments: ['.windsurf', 'extensions'] },
24
+ { editor: 'VSCodium', segments: ['.vscode-oss', 'extensions'] },
25
+ { editor: 'VS Code Server', segments: ['.vscode-server', 'extensions'] },
26
+ { editor: 'Cursor Server', segments: ['.cursor-server', 'extensions'] },
27
+ { editor: 'Trae', segments: ['.trae', 'extensions'] },
28
+ { editor: 'Kiro', segments: ['.kiro', 'extensions'] },
29
+ ];
30
+ /** Roo Code extension folders under the given roots; missing roots are skipped. */
31
+ export async function findRooExtensionDirs(home, roots = VSCODE_EXTENSION_ROOTS) {
9
32
  const hits = [];
10
33
  for (const root of roots) {
11
- const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
12
- for (const entry of entries)
13
- if (entry.isDirectory() && extensionPattern.test(entry.name))
14
- hits.push(path.join(root, entry.name));
34
+ const rootDir = path.join(home, ...root.segments);
35
+ const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
36
+ const names = entries.filter(entry => entry.isDirectory() && ROO_EXTENSION_PATTERN.test(entry.name)).map(entry => entry.name).sort();
37
+ for (const name of names) {
38
+ const version = name.replace(ROO_EXTENSION_PATTERN, '');
39
+ hits.push({ editor: root.editor, dir: path.join(rootDir, name), ...(version ? { version } : {}) });
40
+ }
15
41
  }
16
42
  return hits;
17
43
  }
18
44
  export async function detect(ctx) {
19
- const paths = await extensionPaths(ctx.home);
45
+ const paths = (await findRooExtensionDirs(ctx.home)).map(hit => hit.dir);
20
46
  const executable = await commandExists('code', ctx.env);
21
47
  if (executable)
22
48
  paths.unshift('command:code');
@@ -31,6 +57,6 @@ export async function detect(ctx) {
31
57
  }
32
58
  catch { }
33
59
  }
34
- const installed = paths.some(entry => entry === 'command:code' || extensionPattern.test(path.basename(entry)));
60
+ const installed = paths.some(entry => entry === 'command:code' || ROO_EXTENSION_PATTERN.test(path.basename(entry)));
35
61
  return { id: 'roo-code', name: 'Roo Code (VS Code)', installed, configured, executable, reason: installed ? undefined : 'Roo Code extension was not detected', paths };
36
62
  }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { updateCommand } from './commands/update.js';
13
13
  import { removeCommand } from './commands/remove.js';
14
14
  import { helperBotCommand } from './commands/helper-bot.js';
15
15
  import { claudeToolsCommand } from './commands/claude-tools.js';
16
+ import { fixRooCommand } from './commands/fix-roo.js';
16
17
  import { ensureInstallerApiKey } from './utils/installerConfig.js';
17
18
  import { MIN_NODE, isNodeSupported, nodeUpgradeHint } from './utils/runtime.js';
18
19
  import { runSystemAudit } from './utils/systemAudit.js';
@@ -38,6 +39,10 @@ function printHelp(command) {
38
39
  console.log('Usage: scalemax helper-bot [--model <id>] ["question"]\n\nExecute safe ScaleMax setup and verification actions, or open the ScaleMax-only troubleshooting assistant.');
39
40
  return;
40
41
  }
42
+ if (command === 'fix-roo') {
43
+ console.log('Usage: scalemax fix-roo [--undo]\n\nFix Roo Code tasks stuck at "API Request…" on recent VS Code versions ("Could not find ripgrep binary").\nPatches the installed Roo Code extension in your home folder (VS Code, Insiders, Cursor, Windsurf,\nVSCodium, Trae, Kiro, and remote servers) so it finds VS Code\'s relocated ripgrep. The VS Code app\nitself is not changed. The original extension file is kept next to it as extension.js.scalemax-orig.\nReload VS Code (Developer: Reload Window) afterwards.\n\n --undo Restore the original Roo Code extension files');
44
+ return;
45
+ }
41
46
  const details = { update: 'Revalidate the API key and reconfigure every detected client.', remove: 'Restore backups or remove only ScaleMax-managed client configuration.', doctor: 'Validate the API key and verify detected client configuration.', models: 'List models available to the stored API key.', login: 'Store or replace the ScaleMax API key.', logout: 'Remove the locally stored ScaleMax API key.', 'claude-tools': 'Install and verify bounded public-GitHub skill tools for Claude Desktop/Cowork and Claude Code.' };
42
47
  if (command && details[command]) {
43
48
  console.log(`Usage: scalemax ${command}\n\n${details[command]}`);
@@ -55,6 +60,7 @@ Commands:
55
60
  scalemax models List available models
56
61
  scalemax helper-bot Execute setup actions or open the ScaleMax Helper
57
62
  scalemax claude-tools Install bounded GitHub skill tools for Claude
63
+ scalemax fix-roo Fix Roo Code stuck at "API Request…" (recent VS Code)
58
64
  scalemax update Reconfigure detected clients
59
65
  scalemax remove Remove ScaleMax-generated configs
60
66
  scalemax --version Show installed version
@@ -86,7 +92,8 @@ async function main() {
86
92
  printHelp(command);
87
93
  return;
88
94
  }
89
- if (command !== 'helper-bot' && args.length)
95
+ const allowedOptions = { 'fix-roo': ['--undo'] };
96
+ if (command !== 'helper-bot' && args.some(arg => !(allowedOptions[command] || []).includes(arg)))
90
97
  throw new Error(`Unknown option for ${command}: ${args.join(' ')}`);
91
98
  switch (command) {
92
99
  case 'init':
@@ -111,6 +118,9 @@ async function main() {
111
118
  case 'claude-tools':
112
119
  await claudeToolsCommand();
113
120
  break;
121
+ case 'fix-roo':
122
+ await fixRooCommand(args);
123
+ break;
114
124
  case 'update':
115
125
  await updateCommand();
116
126
  break;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Roo Code (last release 3.54.0, 2026-05-15) finds ripgrep by probing fixed
3
+ * folders under `vscode.env.appRoot`. Its bundled dist/extension.js contains,
4
+ * minified (identifiers differ per build):
5
+ *
6
+ * async function fne(t){let e=async r=>{let a=Uwe.join(t,r,S8i);return await Ln(a)?a:void 0};
7
+ * return await e("node_modules/@vscode/ripgrep/bin/")||await e("node_modules/vscode-ripgrep/bin")||...}
8
+ *
9
+ * Newer VS Code releases (seen on 1.138 and 1.140) moved its ripgrep to
10
+ * `<appRoot>/node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/<platform>-<arch>/rg[.exe]`,
11
+ * so every probe misses, Roo throws "Could not find ripgrep binary" and every
12
+ * task hangs at "API Request…". We patch the user's INSTALLED extension (home
13
+ * folder, no admin rights) to try the new folders first and then fall through
14
+ * to the original probes, so older VS Code keeps working. The VS Code app
15
+ * bundle is never touched.
16
+ */
17
+ export declare const ROO_RIPGREP_FIX_MARKER = "/*scalemax-rg-fix-1*/";
18
+ export declare const ROO_RIPGREP_BACKUP_SUFFIX = ".scalemax-orig";
19
+ export type RooRipgrepPatchStatus = 'patched' | 'already_patched' | 'unsupported';
20
+ export interface RooRipgrepPatch {
21
+ status: RooRipgrepPatchStatus;
22
+ /** The rewritten source, or the input unchanged when nothing was patched. */
23
+ source: string;
24
+ /** How many ripgrep lookups were rewritten. */
25
+ occurrences: number;
26
+ }
27
+ /**
28
+ * Pure rewrite of Roo's minified ripgrep lookup. New candidate folders come
29
+ * first so newer VS Code resolves immediately; the original probes follow.
30
+ * Idempotent: patched text no longer matches, and the marker reports it.
31
+ */
32
+ export declare function patchRooRipgrepSource(src: string): RooRipgrepPatch;
33
+ export interface RooExtensionScript {
34
+ editor: string;
35
+ /** Roo Code version parsed from the extension folder name. */
36
+ version?: string;
37
+ extensionDir: string;
38
+ /** Absolute path of the extension's dist/extension.js. */
39
+ script: string;
40
+ }
41
+ /** Installed Roo Code bundles across VS Code, its forks, and remote servers. */
42
+ export declare function findRooExtensionScripts(home: string): Promise<RooExtensionScript[]>;
43
+ /** "Roo Code 3.54.0 (VS Code)" */
44
+ export declare function rooInstallLabel(target: Pick<RooExtensionScript, 'editor' | 'version'>): string;
45
+ export type RooRipgrepRepairStatus = RooRipgrepPatchStatus | 'error';
46
+ export interface RooRipgrepRepairResult extends RooExtensionScript {
47
+ /** With `dryRun`, 'patched' means the bundle still needs the fix; nothing was written. */
48
+ status: RooRipgrepRepairStatus;
49
+ occurrences?: number;
50
+ /** Byte-exact copy of the bundle as it was before ScaleMax first patched it. */
51
+ backup?: string;
52
+ error?: string;
53
+ }
54
+ export interface RooRipgrepRepairOptions {
55
+ /** Report what would change without writing anything (used by `scalemax doctor`). */
56
+ dryRun?: boolean;
57
+ }
58
+ /**
59
+ * Patch every installed Roo Code bundle. Writes `extension.js.scalemax-orig`
60
+ * once (never overwritten), then replaces the bundle atomically. One bad file
61
+ * is reported, never thrown.
62
+ */
63
+ export declare function repairRooRipgrep(home: string, opts?: RooRipgrepRepairOptions): Promise<RooRipgrepRepairResult[]>;
64
+ export type RooRipgrepRestoreStatus = 'restored' | 'not_patched' | 'no_backup' | 'mismatch' | 'error';
65
+ export interface RooRipgrepRestoreResult extends RooExtensionScript {
66
+ status: RooRipgrepRestoreStatus;
67
+ backup?: string;
68
+ error?: string;
69
+ }
70
+ /**
71
+ * Undo `repairRooRipgrep` from the backup. Restores only when re-patching the
72
+ * backup reproduces the current bundle exactly, so a bundle changed since (or
73
+ * a damaged backup) is never clobbered. The backup is removed once restored.
74
+ */
75
+ export declare function restoreRooRipgrep(home: string): Promise<RooRipgrepRestoreResult[]>;
76
+ /**
77
+ * One line for the Roo Code configure flow, or undefined when Roo Code is not
78
+ * installed in any editor. Never throws.
79
+ */
80
+ export declare function rooRipgrepRepairNote(home: string): Promise<string | undefined>;
@@ -0,0 +1,170 @@
1
+ import path from 'node:path';
2
+ import fsp from 'node:fs/promises';
3
+ import { atomicWrite } from '../utils/fs.js';
4
+ import { ALL_EDITOR_EXTENSION_ROOTS, findRooExtensionDirs } from '../detect/roo-code.js';
5
+ /**
6
+ * Roo Code (last release 3.54.0, 2026-05-15) finds ripgrep by probing fixed
7
+ * folders under `vscode.env.appRoot`. Its bundled dist/extension.js contains,
8
+ * minified (identifiers differ per build):
9
+ *
10
+ * async function fne(t){let e=async r=>{let a=Uwe.join(t,r,S8i);return await Ln(a)?a:void 0};
11
+ * return await e("node_modules/@vscode/ripgrep/bin/")||await e("node_modules/vscode-ripgrep/bin")||...}
12
+ *
13
+ * Newer VS Code releases (seen on 1.138 and 1.140) moved its ripgrep to
14
+ * `<appRoot>/node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/<platform>-<arch>/rg[.exe]`,
15
+ * so every probe misses, Roo throws "Could not find ripgrep binary" and every
16
+ * task hangs at "API Request…". We patch the user's INSTALLED extension (home
17
+ * folder, no admin rights) to try the new folders first and then fall through
18
+ * to the original probes, so older VS Code keeps working. The VS Code app
19
+ * bundle is never touched.
20
+ */
21
+ export const ROO_RIPGREP_FIX_MARKER = '/*scalemax-rg-fix-1*/';
22
+ export const ROO_RIPGREP_BACKUP_SUFFIX = '.scalemax-orig';
23
+ const LEGACY_LOOKUP = /return\s+await\s+([A-Za-z_$][\w$]*)\(\s*"node_modules\/@vscode\/ripgrep\/bin\/"\s*\)/g;
24
+ const PLATFORM_FOLDER = '"+process.platform+"-"+process.arch+"/';
25
+ function patchedLookup(probe) {
26
+ return `return await ${probe}(${ROO_RIPGREP_FIX_MARKER}"node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/${PLATFORM_FOLDER}")`
27
+ + `||await ${probe}("node_modules/@vscode/ripgrep-universal/bin/${PLATFORM_FOLDER}")`
28
+ + `||await ${probe}("node_modules/@vscode/ripgrep/bin/")`;
29
+ }
30
+ /**
31
+ * Pure rewrite of Roo's minified ripgrep lookup. New candidate folders come
32
+ * first so newer VS Code resolves immediately; the original probes follow.
33
+ * Idempotent: patched text no longer matches, and the marker reports it.
34
+ */
35
+ export function patchRooRipgrepSource(src) {
36
+ let occurrences = 0;
37
+ const source = src.replace(LEGACY_LOOKUP, (_match, probe) => {
38
+ occurrences++;
39
+ return patchedLookup(probe);
40
+ });
41
+ if (occurrences)
42
+ return { status: 'patched', source, occurrences };
43
+ return { status: src.includes(ROO_RIPGREP_FIX_MARKER) ? 'already_patched' : 'unsupported', source: src, occurrences: 0 };
44
+ }
45
+ /** Installed Roo Code bundles across VS Code, its forks, and remote servers. */
46
+ export async function findRooExtensionScripts(home) {
47
+ const scripts = [];
48
+ for (const hit of await findRooExtensionDirs(home, ALL_EDITOR_EXTENSION_ROOTS)) {
49
+ const script = path.join(hit.dir, 'dist', 'extension.js');
50
+ const stat = await fsp.lstat(script).catch(() => undefined);
51
+ if (!stat)
52
+ continue;
53
+ scripts.push({ editor: hit.editor, ...(hit.version ? { version: hit.version } : {}), extensionDir: hit.dir, script });
54
+ }
55
+ return scripts;
56
+ }
57
+ /** "Roo Code 3.54.0 (VS Code)" */
58
+ export function rooInstallLabel(target) {
59
+ return `Roo Code${target.version ? ` ${target.version}` : ''} (${target.editor})`;
60
+ }
61
+ function errorMessage(error) {
62
+ return error instanceof Error ? error.message : String(error);
63
+ }
64
+ async function readRegularFile(file) {
65
+ const stat = await fsp.lstat(file);
66
+ if (!stat.isFile())
67
+ throw new Error(`refusing to modify a non-regular file: ${file}`);
68
+ return { bytes: await fsp.readFile(file), mode: stat.mode & 0o777 };
69
+ }
70
+ /** Bundles are handled as latin1 so every byte round-trips exactly; the inserted text is ASCII. */
71
+ const asText = (bytes) => bytes.toString('latin1');
72
+ const asBytes = (text) => Buffer.from(text, 'latin1');
73
+ async function writeBackupOnce(backup, bytes, mode) {
74
+ try {
75
+ await fsp.writeFile(backup, bytes, { flag: 'wx', mode });
76
+ }
77
+ catch (error) {
78
+ // Never overwrite: an existing backup is the pristine pre-ScaleMax bundle.
79
+ if (error.code !== 'EEXIST')
80
+ throw error;
81
+ }
82
+ }
83
+ /**
84
+ * Patch every installed Roo Code bundle. Writes `extension.js.scalemax-orig`
85
+ * once (never overwritten), then replaces the bundle atomically. One bad file
86
+ * is reported, never thrown.
87
+ */
88
+ export async function repairRooRipgrep(home, opts = {}) {
89
+ const results = [];
90
+ for (const target of await findRooExtensionScripts(home)) {
91
+ try {
92
+ const { bytes, mode } = await readRegularFile(target.script);
93
+ const patch = patchRooRipgrepSource(asText(bytes));
94
+ if (patch.status !== 'patched' || opts.dryRun) {
95
+ results.push({ ...target, status: patch.status, occurrences: patch.occurrences });
96
+ continue;
97
+ }
98
+ const backup = target.script + ROO_RIPGREP_BACKUP_SUFFIX;
99
+ await writeBackupOnce(backup, bytes, mode);
100
+ await atomicWrite(target.script, asBytes(patch.source), mode);
101
+ results.push({ ...target, status: 'patched', occurrences: patch.occurrences, backup });
102
+ }
103
+ catch (error) {
104
+ results.push({ ...target, status: 'error', error: errorMessage(error) });
105
+ }
106
+ }
107
+ return results;
108
+ }
109
+ /**
110
+ * Undo `repairRooRipgrep` from the backup. Restores only when re-patching the
111
+ * backup reproduces the current bundle exactly, so a bundle changed since (or
112
+ * a damaged backup) is never clobbered. The backup is removed once restored.
113
+ */
114
+ export async function restoreRooRipgrep(home) {
115
+ const results = [];
116
+ for (const target of await findRooExtensionScripts(home)) {
117
+ const backup = target.script + ROO_RIPGREP_BACKUP_SUFFIX;
118
+ try {
119
+ const { bytes, mode } = await readRegularFile(target.script);
120
+ const current = asText(bytes);
121
+ if (!current.includes(ROO_RIPGREP_FIX_MARKER)) {
122
+ results.push({ ...target, status: 'not_patched' });
123
+ continue;
124
+ }
125
+ const original = await fsp.readFile(backup).catch((error) => {
126
+ if (error.code === 'ENOENT')
127
+ return undefined;
128
+ throw error;
129
+ });
130
+ if (!original) {
131
+ results.push({ ...target, status: 'no_backup' });
132
+ continue;
133
+ }
134
+ if (patchRooRipgrepSource(asText(original)).source !== current) {
135
+ results.push({ ...target, status: 'mismatch', backup });
136
+ continue;
137
+ }
138
+ await atomicWrite(target.script, original, mode);
139
+ await fsp.rm(backup, { force: true });
140
+ results.push({ ...target, status: 'restored', backup });
141
+ }
142
+ catch (error) {
143
+ results.push({ ...target, status: 'error', backup, error: errorMessage(error) });
144
+ }
145
+ }
146
+ return results;
147
+ }
148
+ /**
149
+ * One line for the Roo Code configure flow, or undefined when Roo Code is not
150
+ * installed in any editor. Never throws.
151
+ */
152
+ export async function rooRipgrepRepairNote(home) {
153
+ let results;
154
+ try {
155
+ results = await repairRooRipgrep(home);
156
+ }
157
+ catch (error) {
158
+ return `Roo Code ripgrep fix skipped (${errorMessage(error)}); run \`npx scalemax@latest fix-roo\` to retry.`;
159
+ }
160
+ if (!results.length)
161
+ return undefined;
162
+ const failed = results.filter(result => result.status === 'error');
163
+ if (failed.length)
164
+ return `Could not apply the Roo Code ripgrep fix (${failed[0].error}); run \`npx scalemax@latest fix-roo\` for details.`;
165
+ if (results.some(result => result.status === 'patched'))
166
+ return 'Patched Roo Code to find newer VS Code\'s ripgrep (fixes tasks stuck at "API Request…"); reload VS Code to apply.';
167
+ if (results.some(result => result.status === 'already_patched'))
168
+ return 'Roo Code\'s ripgrep fix for newer VS Code is already applied.';
169
+ return 'Roo Code\'s ripgrep lookup was not recognised, so no ripgrep fix was applied (this Roo Code version may not need it).';
170
+ }
@@ -75,6 +75,12 @@ export interface ConfigureResult {
75
75
  message: string;
76
76
  /** One-line usage advice printed after a successful configure (e.g. which agent mode can run tools). */
77
77
  tip?: string;
78
+ /**
79
+ * One-line report of a side repair made while configuring (e.g. the Roo Code
80
+ * ripgrep fix). Printed right after the configure step, before verification,
81
+ * because the repair stands even if the configuration is later rolled back.
82
+ */
83
+ note?: string;
78
84
  /** Restore the exact state captured immediately before this configure call. */
79
85
  rollback?: () => Promise<void>;
80
86
  }
@@ -1,6 +1,6 @@
1
1
  export declare function exists(p: string): Promise<boolean>;
2
2
  export declare function syncParentDirectory(directory: string, platform?: NodeJS.Platform): Promise<void>;
3
- export declare function atomicWrite(file: string, content: string, mode?: number, platform?: NodeJS.Platform): Promise<void>;
3
+ export declare function atomicWrite(file: string, content: string | Uint8Array, mode?: number, platform?: NodeJS.Platform): Promise<void>;
4
4
  export declare function backupNameFor(file: string): string;
5
5
  export declare function backupFile(file: string, home: string): Promise<string | undefined>;
6
6
  export declare function listBackups(file: string, home: string): Promise<string[]>;
package/dist/utils/fs.js CHANGED
@@ -34,7 +34,8 @@ export async function atomicWrite(file, content, mode = 0o600, platform = proces
34
34
  let handle;
35
35
  try {
36
36
  handle = await fsp.open(tmp, 'wx', mode);
37
- await handle.writeFile(content, 'utf8');
37
+ // Strings are written as UTF-8; byte content (e.g. a patched bundle) is written verbatim.
38
+ await handle.writeFile(content, typeof content === 'string' ? 'utf8' : undefined);
38
39
  await handle.sync();
39
40
  await handle.close();
40
41
  handle = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {