Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cli/src/utils/__tests__/queue-panel-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
6 changes: 6 additions & 0 deletions cli/src/utils/keyboard-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
11 changes: 5 additions & 6 deletions common/src/__tests__/freebuff-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 7 additions & 8 deletions common/src/constants/freebuff-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
])

/**
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/code-map/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/native/ripgrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
)
}
93 changes: 68 additions & 25 deletions sdk/src/tools/run-terminal-command.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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'

Expand Down Expand Up @@ -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<number | null>((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<number | null> =>
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
}
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -409,6 +447,7 @@ export function runTerminalCommand({

let shell: string
let shellArgs: string[]
let windowsBashFallbacks: string[] = []
const brokerOwnsShell = terminalCommandBroker?.ownsShell === true

if (brokerOwnsShell) {
Expand All @@ -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']
Expand Down Expand Up @@ -471,7 +514,7 @@ export function runTerminalCommand({
}
childProcess = terminalCommandBroker
? terminalCommandBroker.start(request)
: spawnDirectTerminalCommand(request)
: spawnDirectTerminalCommand(request, windowsBashFallbacks)
} catch (error) {
reject(
spawnFailure(
Expand Down
9 changes: 9 additions & 0 deletions sdk/src/tools/windows-bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading