Skip to content
Merged
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
7 changes: 1 addition & 6 deletions src/browser/components/InstructionsTab/InstructionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type WorkspaceInstructions,
} from "@/common/types/instructions";
import { getErrorMessage } from "@/common/utils/errors";
import { formatBytes } from "@/common/utils/formatBytes";

interface InstructionsTabProps {
workspaceId: string;
Expand Down Expand Up @@ -314,9 +315,3 @@ function formatTokens(n: number): string {
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
return `${Math.round(n / 1000)}k`;
}

function formatBytes(n: number): string {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
}
23 changes: 18 additions & 5 deletions src/common/types/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,23 @@ export function getAnthropicEffort(level: ThinkingLevel): AnthropicEffortLevel {
return ANTHROPIC_EFFORT[level];
}

/**
* Normalize a model string to its bare model id for capability matching:
* trims, lowercases, and strips both a `provider:` prefix (e.g. `anthropic:`)
* and a `namespace/` segment (e.g. gateway-wrapped `openai/gpt-5.5-pro`).
*
* Kept in one place so capability predicates that key off the bare id
* (see `anthropicSupportsNativeXhigh` and the thinking policy resolver) stay
* consistent about how provider prefixes and gateway namespaces are removed.
*/
export function stripModelProviderPrefixes(modelString: string): string {
return modelString
.trim()
.toLowerCase()
.replace(/^[a-z0-9_-]+:\s*/, "")
.replace(/^[a-z0-9_-]+\//, "");
}

/**
* Whether the given Anthropic model supports the native "xhigh" API effort level
* (distinct from "max").
Expand All @@ -210,11 +227,7 @@ export function getAnthropicEffort(level: ThinkingLevel): AnthropicEffortLevel {
* - Mythos-class models (`claude-fable-*`, `claude-mythos-*`), the tier above Opus.
*/
export function anthropicSupportsNativeXhigh(modelString: string): boolean {
const withoutPrefix = modelString
.trim()
.toLowerCase()
.replace(/^[a-z0-9_-]+:\s*/, "")
.replace(/^[a-z0-9_-]+\//, "");
const withoutPrefix = stripModelProviderPrefixes(modelString);
// Opus 4.7+ (4-7, 4-8, 4-9, 4-10, 4-11, ...) or any Opus 5+, Sonnet 5+ (5, 6, ... 10+),
// plus the Mythos-class Fable / Mythos models that sit above Opus.
return (
Expand Down
12 changes: 5 additions & 7 deletions src/common/utils/thinking/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
DEFAULT_THINKING_LEVEL,
THINKING_LEVEL_OFF,
anthropicSupportsNativeXhigh,
stripModelProviderPrefixes,
type ThinkingLevel,
type ParsedThinkingInput,
} from "@/common/types/thinking";
Expand Down Expand Up @@ -78,13 +79,10 @@ const DEFAULT_THINKING_POLICY: ThinkingPolicy = ["off", "low", "medium", "high"]
* which is the signal used to decide whether to apply a default thinking floor.
*/
function getExplicitThinkingPolicy(modelString: string): ThinkingPolicy | null {
// Normalize to be robust to provider prefixes, whitespace, gateway wrappers, and version suffixes
const normalized = modelString.trim().toLowerCase();
const withoutPrefix = normalized.replace(/^[a-z0-9_-]+:\s*/, "");

// Many providers/proxies encode the upstream provider as a path segment:
// mux-gateway:openai/gpt-5.5-pro -> openai/gpt-5.5-pro -> gpt-5.5-pro
const withoutProviderNamespace = withoutPrefix.replace(/^[a-z0-9_-]+\//, "");
// Normalize to be robust to provider prefixes, whitespace, gateway wrappers, and version
// suffixes. Strips both a `provider:` prefix and any upstream-provider path segment that
// proxies encode (e.g. `mux-gateway:openai/gpt-5.5-pro` -> `gpt-5.5-pro`).
const withoutProviderNamespace = stripModelProviderPrefixes(modelString);

// Opus 4.7+ supports all 6 levels: xhigh is a native API effort level distinct from max.
if (anthropicSupportsNativeXhigh(modelString)) {
Expand Down
22 changes: 6 additions & 16 deletions src/node/services/backgroundProcessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getErrorMessage } from "@/common/utils/errors";
import { log } from "./log";
import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex";
import { BASH_MAX_LINE_BYTES } from "@/common/constants/toolLimits";
import { stripAnsiControlChars } from "@/node/utils/ansi";
import { LocalBaseRuntime } from "@/node/runtime/LocalBaseRuntime";

const DEFAULT_BACKGROUND_BASH_TAIL_BYTES = 64_000;
Expand All @@ -16,10 +17,6 @@ const MONITOR_MAX_LAST_LINES = 20;
const MONITOR_MAX_PROMPT_LINE_BYTES = Math.min(BASH_MAX_LINE_BYTES, 8_192);
const MONITOR_MAX_INCOMPLETE_MATCH_BYTES = 1_000_000;
const MONITOR_TRUNCATION_MARKER = "… [truncated] …";
const ANSI_ESCAPE_PATTERN = new RegExp(
`[${String.fromCharCode(27)}${String.fromCharCode(155)}][[\\]\\()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?${String.fromCharCode(7)})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
"g"
);

export function computeTailStartOffset(fileSizeBytes: number, tailBytes: number): number {
assert(
Expand Down Expand Up @@ -383,12 +380,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
}

private sanitizeMonitorLine(line: string): string {
return [...line.replace(ANSI_ESCAPE_PATTERN, "")]
.filter((char) => {
const code = char.charCodeAt(0);
return code === 9 || code >= 32;
})
.join("");
return stripAnsiControlChars(line);
}

private truncateMonitorLine(line: string): string {
Expand Down Expand Up @@ -1003,9 +995,9 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
const rawWithBuffer = previousBuffer + accumulatedRaw;
const allLines = rawWithBuffer.split("\n");

// Last element is incomplete if content doesn't end with newline
const hasTrailingNewline = rawWithBuffer.endsWith("\n");
const completeLines = hasTrailingNewline ? allLines.slice(0, -1) : allLines.slice(0, -1);
// Drop the last element: it's either empty (content ended with "\n") or the incomplete
// trailing fragment, which is buffered for the next read -- so it's never a complete line.
const completeLines = allLines.slice(0, -1);

// When using filter_exclude, check if we have meaningful (non-excluded) output.
// We only consider complete lines as "meaningful" here; fragments are buffered for the next read.
Expand Down Expand Up @@ -1110,9 +1102,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
const linesToReturn =
currentStatus !== "running"
? allLines.filter((l) => l.length > 0) // Include all non-empty lines on exit
: hasTrailingNewline
? allLines.slice(0, -1)
: allLines.slice(0, -1);
: allLines.slice(0, -1); // While running, drop the trailing fragment (buffered for next read)

// Update buffer for next call (clear on exit, keep incomplete line otherwise)
proc.incompleteLineBuffer =
Expand Down
12 changes: 2 additions & 10 deletions src/node/services/bashMonitorWakeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,11 @@ import type { Config } from "@/node/config";
import { log } from "@/node/services/log";
import { isErrnoWithCode } from "@/node/utils/fs";
import { MutexMap } from "@/node/utils/concurrency/mutexMap";
import { stripAnsiControlChars } from "@/node/utils/ansi";

export const BASH_MONITOR_WAKE_DIR = "bash-monitor-wakes";
const MAX_WAKE_LINES = 50;
const MAX_WAKE_LINE_BYTES = 8_192;
const ANSI_ESCAPE_PATTERN = new RegExp(
`[${String.fromCharCode(27)}${String.fromCharCode(155)}][[\\]\\()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?${String.fromCharCode(7)})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
"g"
);

// Single-source the wake status enum so the exported TS type and the runtime
// Zod validator below can't drift. Mirrors the `as const` tuple pattern used by
Expand Down Expand Up @@ -87,12 +84,7 @@ function truncateUtf8Prefix(value: string, maxBytes: number): string {
}

export function sanitizeBashMonitorWakeLine(line: string): string {
const sanitized = [...line.replace(ANSI_ESCAPE_PATTERN, "")]
.filter((char) => {
const code = char.charCodeAt(0);
return code === 9 || code >= 32;
})
.join("");
const sanitized = stripAnsiControlChars(line);
if (Buffer.byteLength(sanitized, "utf8") <= MAX_WAKE_LINE_BYTES) return sanitized;
return `${truncateUtf8Prefix(sanitized, MAX_WAKE_LINE_BYTES)}… [truncated]`;
}
Expand Down
29 changes: 29 additions & 0 deletions src/node/utils/ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Shared terminal-output sanitization used when embedding raw program output in
* JSON wake records / prompt context. The bash-monitor wake store and the
* background process manager previously inlined this identical strip-ANSI +
* drop-control-chars logic (including a byte-for-byte copy of the escape
* pattern); single-sourcing it here keeps the two from drifting apart.
*/

// Matches ANSI/VT escape sequences (CSI, OSC, etc.). Module-level + global is
// safe to share across callers because String.prototype.replace resets the
// regex lastIndex on every call.
const ANSI_ESCAPE_PATTERN = new RegExp(
`[${String.fromCharCode(27)}${String.fromCharCode(155)}][[\\]\\()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?${String.fromCharCode(7)})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
"g"
);

/**
* Strip ANSI/VT escape sequences and drop non-printable control characters,
* keeping only tab (code 9) and any code point >= 0x20, so raw terminal output
* is safe to persist and re-render.
*/
export function stripAnsiControlChars(line: string): string {
return [...line.replace(ANSI_ESCAPE_PATTERN, "")]
.filter((char) => {
const code = char.charCodeAt(0);
return code === 9 || code >= 32;
})
.join("");
}
Loading