From 0b004fbc3dfa65a77aeedccba8612c4f2274ad6d Mon Sep 17 00:00:00 2001 From: vincenzosco <131459463+vincenzosco@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:20:53 +0100 Subject: [PATCH 1/4] Lower tree-sitter parse limits to cut CLI memory The CLI parses the whole project with tree-sitter on every run, which pushed client RAM toward 2GB. Lower the default file count, per-file size and total byte budget so typical projects stay well under 500MB. The CODEBUFF_MAX_PARSE_* env vars still override these defaults. --- packages/code-map/src/parse.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/code-map/src/parse.ts b/packages/code-map/src/parse.ts index 8ddf3337a9..477dd15f65 100644 --- a/packages/code-map/src/parse.ts +++ b/packages/code-map/src/parse.ts @@ -9,9 +9,9 @@ import type { Parser, Query } from 'web-tree-sitter' export const DEBUG_PARSING = false const IGNORE_TOKENS = ['__init__', '__post_init__', '__call__', 'constructor'] const MAX_CALLERS = 25 -const DEFAULT_MAX_PARSE_FILES = 10_000 -const DEFAULT_MAX_PARSE_FILE_BYTES = 1_000_000 -const DEFAULT_MAX_TOTAL_PARSE_BYTES = 500_000_000 +const DEFAULT_MAX_PARSE_FILES = 2_000 +const DEFAULT_MAX_PARSE_FILE_BYTES = 300_000 +const DEFAULT_MAX_TOTAL_PARSE_BYTES = 60_000_000 const MAX_PARSE_FILES = getPositiveIntegerEnv( 'CODEBUFF_MAX_PARSE_FILES', From 3a25c6e5aff44d8e985a4c31750b835ba8016809 Mon Sep 17 00:00:00 2001 From: vincenzosco <131459463+vincenzosco@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:28:31 +0100 Subject: [PATCH 2/4] Fall back to another Git Bash when policy blocks the bundled one When antivirus or ASR blocks the bundled bash.exe, spawn fails with EPERM as an async error event, so the command never ran. Retry the same command with the next installed bash, keeping one set of stdout/stderr streams. --- sdk/src/tools/run-terminal-command.ts | 93 ++++++++++++++++++++------- sdk/src/tools/windows-bash.ts | 9 +++ 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index 4736419beb..9881fd9b6e 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -1,6 +1,6 @@ import { spawn, spawnSync } from 'child_process' import { closeSync, mkdtempSync, openSync, statSync, writeFileSync } from 'fs' -import { Readable as NodeReadable } from 'stream' +import { PassThrough, Readable as NodeReadable } from 'stream' import * as os from 'os' import * as path from 'path' @@ -12,7 +12,11 @@ import type { Readable } from 'stream' import { stripColors } from '../../../common/src/util/string' import { getSystemProcessEnv } from '../env' -import { createWindowsBashNotFoundError, findWindowsBash } from './windows-bash' +import { + createWindowsBashNotFoundError, + findAllWindowsBash, + findWindowsBash, +} from './windows-bash' import type { CodebuffToolOutput } from '../../../common/src/tools/list' @@ -290,27 +294,57 @@ export function missingDirectoryError( } } +/** + * Spawn the command. On Windows, when policy blocks a bash (EPERM on spawn, reported as an async + * `error` event), the next install is tried in the same call so the caller still gets one process. + */ function spawnDirectTerminalCommand( request: TerminalCommandSpawnRequest, + bashFallbacks: string[] = [], ): TerminalCommandProcess { - const child: ChildProcessWithoutNullStreams = spawn( - request.executable, - request.args, - { - cwd: request.cwd, - env: request.env, - stdio: 'pipe', - // The direct SDK runner owns a process group. Interactive hosts should - // provide a broker instead, so the shell never shares their console. - detached: true, - windowsHide: true, - }, - ) - const closed = new Promise((resolve, reject) => { - child.once('error', reject) - child.once('close', resolve) - }) + // Stable streams: the caller keeps these even if a fallback bash replaces the first one. + const stdout = new PassThrough() + const stderr = new PassThrough() + let current: ChildProcessWithoutNullStreams | undefined + const launch = ( + executable: string, + fallbacks: string[], + ): Promise => + new Promise((resolve, reject) => { + const child: ChildProcessWithoutNullStreams = spawn( + executable, + request.args, + { + cwd: request.cwd, + env: request.env, + stdio: 'pipe', + // The direct SDK runner owns a process group. Interactive hosts should + // provide a broker instead, so the shell never shares their console. + detached: true, + windowsHide: true, + }, + ) + current = child + child.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPERM' && fallbacks.length > 0) { + // Policy blocked this bash; try the next install with the same streams. + const [next, ...rest] = fallbacks + launch(next, rest).then(resolve, reject) + return + } + reject(error) + }) + child.stdout.pipe(stdout, { end: false }) + child.stderr.pipe(stderr, { end: false }) + child.once('close', (code) => { + stdout.end() + stderr.end() + resolve(code) + }) + }) + const closed = launch(request.executable, bashFallbacks) const completion = closed.then(async (exitCode) => { + const child = current as ChildProcessWithoutNullStreams if (os.platform() === 'win32' || !isProcessGroupAlive(child)) { return exitCode } @@ -336,12 +370,16 @@ function spawnDirectTerminalCommand( }) return { - pid: child.pid, - stdout: child.stdout, - stderr: child.stderr, + get pid() { + return current?.pid + }, + stdout, + stderr, completion, - kill: (signal) => killProcessGroup(child, signal), - isAlive: () => isProcessGroupAlive(child), + kill: (signal) => { + if (current) killProcessGroup(current, signal) + }, + isAlive: () => (current ? isProcessGroupAlive(current) : false), } } @@ -409,6 +447,7 @@ export function runTerminalCommand({ let shell: string let shellArgs: string[] + let windowsBashFallbacks: string[] = [] const brokerOwnsShell = terminalCommandBroker?.ownsShell === true if (brokerOwnsShell) { @@ -426,6 +465,10 @@ export function runTerminalCommand({ } shell = bashPath shellArgs = ['-c'] + // Policy can block one install's bash.exe (EPERM on spawn). The others are fallbacks. + windowsBashFallbacks = findAllWindowsBash(processEnv).filter( + (candidate) => candidate !== bashPath, + ) } else { shell = 'bash' shellArgs = ['-c'] @@ -471,7 +514,7 @@ export function runTerminalCommand({ } childProcess = terminalCommandBroker ? terminalCommandBroker.start(request) - : spawnDirectTerminalCommand(request) + : spawnDirectTerminalCommand(request, windowsBashFallbacks) } catch (error) { reject( spawnFailure( diff --git a/sdk/src/tools/windows-bash.ts b/sdk/src/tools/windows-bash.ts index c762ddc9ba..18be48acde 100644 --- a/sdk/src/tools/windows-bash.ts +++ b/sdk/src/tools/windows-bash.ts @@ -203,6 +203,15 @@ export function findWindowsBash( return candidate && pathExists(candidate) ? candidate : null } +/** + * Every bash this machine has, in the order we would try them. A bundled copy that Windows policy + * blocks (Attack Surface Reduction answers EPERM on spawn) must not be the only one we can run, so + * the terminal falls through to the next install instead of reporting no shell at all. + */ +export function findAllWindowsBash(env: NodeJS.ProcessEnv): string[] { + return windowsBashCandidates(env).filter(pathExists) +} + function listDirectory(dir: string): string[] { try { return fs.readdirSync(dir) From f73b9174ffbfd2650d61c65a9d28092b0a4daa34 Mon Sep 17 00:00:00 2001 From: vincenzosco <131459463+vincenzosco@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:55:19 +0100 Subject: [PATCH 3/4] Lock Muse Spark 1.3 behind a paid plan and fix missing-ripgrep message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muse Spark 1.3 now requires a plan again, matching the README (#1525). The ripgrep error no longer prints "undefined" when the bundled binary is missing (#1493). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- common/src/__tests__/freebuff-models.test.ts | 11 +++++------ common/src/constants/freebuff-models.ts | 15 +++++++-------- sdk/src/native/ripgrep.ts | 2 +- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/common/src/__tests__/freebuff-models.test.ts b/common/src/__tests__/freebuff-models.test.ts index 897ddabe17..a9239dbfa1 100644 --- a/common/src/__tests__/freebuff-models.test.ts +++ b/common/src/__tests__/freebuff-models.test.ts @@ -2066,18 +2066,17 @@ describe('limited-offer models (Claude Fable 5.1)', () => { describe('Meta Muse Spark 1.3 Contributor', () => { const ID = FREEBUFF_MUSE_SPARK_13_CONTRIBUTOR_MODEL_ID - test('is the offered Muse Spark row, open at full access and plan-only at limited access', () => { + test('is the offered Muse Spark row, plan-only at every access tier', () => { // Paused 2026-09-07 (`404 model_not_found` on every key), back on - // 2026-09-28 on a new key as a paid-only row, and open to every - // full-access account without a plan from 2026-10-09 (Luna's and MiMo 2.6 - // Pro's terms): a plan is still what unlocks it at limited access. + // 2026-09-28 on a new key as a paid-only row. A paid plan is required on + // every surface and access tier (#1525), matching the README. expect(isFreebuffPausedFreeModelId(ID)).toBe(false) expect(FREEBUFF_MODELS.map((model) => model.id)).toContain(ID) expect(FREEBUFF_WEB_MODELS.map((model) => model.id)).toContain(ID) expect(isFreebuffWebSelectableModelId(ID)).toBe(true) expect(isFreebuffSessionModelId(ID)).toBe(true) - expect(isFreebuffProOnlyCatalogModelId(ID)).toBe(false) - expect(isFreebuffProOnlyEverySurfaceModelId(ID)).toBe(false) + expect(isFreebuffProOnlyCatalogModelId(ID)).toBe(true) + expect(isFreebuffProOnlyEverySurfaceModelId(ID)).toBe(true) expect(isFreebuffLimitedTierPlanOnlyModelId(ID)).toBe(true) expect(FREEBUFF_PLAN_METERED_CATALOG_MODEL_IDS).toContain(ID) // Out of the premium pool since 2026-10-10, when Meta lifted the diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index b048108e88..55bf6b6ea2 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -4370,13 +4370,10 @@ export const FREEBUFF_PRO_ONLY_CATALOG_MODEL_IDS: readonly string[] = // exemption. FREEBUFF_GPT_61_SOL_MODEL_ID, FREEBUFF_GEMINI_4_ARGON_MODEL_ID, - // Muse Spark 1.3 (and 1.2 with it) was paid-only on every surface from - // 2026-09-28 and LEFT this list on 2026-10-09, by product decision, on - // MiMo 2.6 Pro's and Luna's terms below: open to every full-access - // account at 15 Freebucks, plan-only at limited access - // (FREEBUFF_LIMITED_TIER_PLAN_ONLY_MODEL_IDS). Re-locking it for everyone - // is a one-line move back here and in the every-surface list, or without a - // deploy a `locked` full-access entry in the server model catalog. + // Muse Spark 1.3 re-locked for every account without a plan (#1525): the + // README and release docs say a paid plan is required. It had left this + // list on 2026-10-09 (open to full-access accounts at 15 Freebucks). + FREEBUFF_MUSE_SPARK_13_CONTRIBUTOR_MODEL_ID, // // MiMo 2.6 Pro and GPT-6 Luna LEFT this list on 2026-09-25, by product // decision: every FULL-ACCESS account opens them with no plan (metered in @@ -4407,6 +4404,9 @@ export const FREEBUFF_PRO_ONLY_EVERY_SURFACE_MODEL_IDS: readonly string[] = // Desktop list them. FREEBUFF_GPT_61_SOL_MODEL_ID, FREEBUFF_GEMINI_4_ARGON_MODEL_ID, + // Muse Spark 1.3 re-locked for every account without a plan (#1525): the + // README and release docs say a paid plan is required. + FREEBUFF_MUSE_SPARK_13_CONTRIBUTOR_MODEL_ID, ]) /** @@ -4493,7 +4493,6 @@ export const FREEBUFF_LIMITED_TIER_PLAN_ONLY_MODEL_IDS: readonly string[] = // plan at limited access, like Luna and MiMo 2.6 Pro. 1.2 is not listed: // it is not offered at limited access at all (not even with a plan), only // kept for released binaries at full access. - FREEBUFF_MUSE_SPARK_13_CONTRIBUTOR_MODEL_ID, // GPT-6.1 Terra (2026-10-26): open to every full-access account, a paid // plan at limited access, on the same terms. FREEBUFF_GPT_61_TERRA_MODEL_ID, diff --git a/sdk/src/native/ripgrep.ts b/sdk/src/native/ripgrep.ts index 399487b1e8..8b604541b5 100644 --- a/sdk/src/native/ripgrep.ts +++ b/sdk/src/native/ripgrep.ts @@ -138,7 +138,7 @@ export function getBundledRgPath( // No fallback available - bundled binaries are required throw new Error( `Ripgrep binary not found for ${platform}-${arch}. ` + - `Expected at: ${vendorPath} or ${distVendorPath}. ` + + `Expected at: ${distVendorPath}${vendorPath ? ` or ${vendorPath}` : ''}. ` + `Please run 'npm run fetch-ripgrep' or set CODEBUFF_RG_PATH environment variable.`, ) } From 69de8706e941937467dd550c0eacdb2f82eceb34 Mon Sep 17 00:00:00 2001 From: Vincenzo <131459463+vincenzosco@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:33:20 +0100 Subject: [PATCH 4/4] Absorb Ctrl+Z in the chat composer Ctrl+Z fell through every rule in resolveChatKeyboardAction and ended in the final "none" branch, so nothing in the composer handled it. Handle it explicitly as a no-op so it cannot reach any other handler, and cover it with a test next to the Ctrl+Q tests. --- cli/src/utils/__tests__/queue-panel-actions.test.ts | 11 +++++++++++ cli/src/utils/keyboard-actions.ts | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/cli/src/utils/__tests__/queue-panel-actions.test.ts b/cli/src/utils/__tests__/queue-panel-actions.test.ts index 4600ebcb0c..68a840a482 100644 --- a/cli/src/utils/__tests__/queue-panel-actions.test.ts +++ b/cli/src/utils/__tests__/queue-panel-actions.test.ts @@ -108,6 +108,17 @@ describe('resolveQueuePanelAction', () => { }) }) +describe('ctrl+z is absorbed by the composer', () => { + test('does not exit or change state', () => { + expect( + resolveChatKeyboardAction( + createKey({ name: 'z', ctrl: true }), + createDefaultChatKeyboardState(), + ), + ).toEqual({ type: 'none' }) + }) +}) + describe('ctrl+q opens the queue editor', () => { const ctrlQ = createKey({ name: 'q', ctrl: true }) diff --git a/cli/src/utils/keyboard-actions.ts b/cli/src/utils/keyboard-actions.ts index 6d6b0ae08c..5e8ff28cad 100644 --- a/cli/src/utils/keyboard-actions.ts +++ b/cli/src/utils/keyboard-actions.ts @@ -283,6 +283,12 @@ export function resolveChatKeyboardAction( : { type: 'none' } } + // Ctrl+Z is not a suspend key inside the composer. Absorb it here so it never + // falls through to the exit rules below. + if (key.ctrl && key.name === 'z' && !key.meta && !key.option) { + return { type: 'none' } + } + // Priority 3: Clear input with ctrl-c when there's text if (isCtrlC && state.inputValue.trim().length > 0) { return { type: 'clear-input' }