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' } 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/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', 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.`, ) } 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)