From c5912b625e8415f2b53d6d6390d780a71c0f3e2f Mon Sep 17 00:00:00 2001 From: x1xhlol Date: Wed, 1 Apr 2026 17:12:45 +0200 Subject: [PATCH] perf improvements + /login fix --- PERFORMANCE.md | 61 ++++ README.md | 15 +- package.json | 5 +- scripts/perf.ts | 163 ++++++++++ src/cli/print.ts | 16 +- src/commands/login/login.tsx | 297 ++++++++++++------- src/components/Messages.tsx | 21 +- src/components/OpenRouterLoginFlow.tsx | 88 ++++++ src/main.tsx | 74 +++-- src/screens/REPL.tsx | 78 ++++- src/services/tools/StreamingToolExecutor.ts | 38 ++- src/services/tools/toolConcurrency.ts | 8 + src/services/tools/toolOrchestration.test.ts | 33 +++ src/services/tools/toolOrchestration.ts | 10 +- src/utils/computerUse/drainRunLoop.ts | 9 +- src/utils/cronScheduler.test.ts | 15 + src/utils/cronScheduler.ts | 72 ++++- src/utils/queryProfiler.ts | 28 +- src/utils/task/TaskOutput.test.ts | 19 ++ src/utils/task/TaskOutput.ts | 74 ++++- src/utils/task/taskOutputPolling.ts | 16 + 21 files changed, 942 insertions(+), 198 deletions(-) create mode 100644 PERFORMANCE.md create mode 100644 scripts/perf.ts create mode 100644 src/components/OpenRouterLoginFlow.tsx create mode 100644 src/services/tools/toolConcurrency.ts create mode 100644 src/services/tools/toolOrchestration.test.ts create mode 100644 src/utils/cronScheduler.test.ts create mode 100644 src/utils/task/TaskOutput.test.ts create mode 100644 src/utils/task/taskOutputPolling.ts diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 00000000..cfd973e2 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,61 @@ +# Performance Workflow + +This repository includes a lightweight performance workflow built around the +existing startup and query profilers. + +## Build Once + +```bash +bun run build +``` + +## Baseline Scenarios + +1. Cold start and command latency + +```bash +bun run perf:startup -- --help +``` + +2. Interactive startup in a real session + +```bash +bun run perf:startup -- +``` + +Use your OS task manager while the app is idle to inspect CPU, RSS, and handle +count after first render. + +3. Headless query / TTFT + +```bash +bun run perf:query -- --print "Summarize the current directory." +``` + +This requires whatever auth/config is normally needed for the chosen provider. + +## Artifacts + +Each run writes an isolated artifact bundle under `.perf-artifacts/`: + +- `summary.json`: wall-clock timing and parsed profiler highlights +- `config/startup-perf/*`: startup profiler output +- `config/query-perf/*`: query profiler output +- `debug/*`: debug logs for runs that need them + +## Regression Checks + +Run the focused regression checks for the new performance helpers with: + +```bash +bun run perf:regression +``` + +## Suggested Before/After Loop + +1. Run the startup baseline. +2. Run the headless query baseline. +3. If you are changing long-running behavior, also launch an interactive session and + watch idle CPU and memory for a few minutes. +4. Compare the new `.perf-artifacts` summary against the previous run before and + after each optimization pass. diff --git a/README.md b/README.md index f34743a1..7c2b5bc7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Better-Clawd exists because the original had a genuinely great core idea and too many self-inflicted problems around it. This fork keeps what worked, fixes what did not, and gives people real choice over how they log in, which provider they use, and how much of the product they actually want phoning home. -No telemetry. No vendor lock-in. Better long-session performance. Less corporate baggage. +No telemetry. No vendor lock-in. Faster startup, lower idle overhead, and better long-session performance than the original Claude Code. Less corporate baggage. [NPM package](https://www.npmjs.com/package/better-clawd) @@ -29,9 +29,18 @@ Better-Clawd is the response to that. - It keeps the good parts of the original UX - It removes telemetry and unnecessary phone-home behavior - It supports multiple providers without turning setup into a science project -- It improves performance over the original Claude Code, especially in longer sessions +- It improves performance over the original Claude Code, with better startup behavior, less idle background churn, and smoother long sessions - It is easier to inspect, modify, and run on your own terms +## Performance + +Better-Clawd is intentionally tuned to be leaner than upstream Claude Code: + +- Lower startup and initialization cost +- Less background polling and idle CPU churn +- Better memory and render behavior during long transcript-heavy sessions +- Focused performance workflow and regression checks in `PERFORMANCE.md` + ## What Better-Clawd Changes - Full Better-Clawd rebrand across the CLI, UI, config paths, installers, and app identity @@ -88,7 +97,7 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 ## What You Get - Better provider freedom -- Better performance than the original Claude Code +- Better performance than the original Claude Code across startup, idle usage, and long sessions - OpenAI and OpenRouter support without weird bolt-on hacks - Less phone-home behavior - A CLI that feels more practical, more open, and more yours diff --git a/package.json b/package.json index 3bf3fb6a..65f7fef0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "better-clawd", - "version": "0.1.0", + "version": "0.1.1", "description": "Claude Code, but better.", "type": "module", "bin": { @@ -16,6 +16,9 @@ "build": "bun run scripts/build.ts", "start": "node dist/cli.mjs", "typecheck": "tsc --noEmit", + "perf:startup": "bun run build && bun run scripts/perf.ts startup --", + "perf:query": "bun run build && bun run scripts/perf.ts query --", + "perf:regression": "bun test src/utils/task/TaskOutput.test.ts src/utils/cronScheduler.test.ts src/services/tools/toolOrchestration.test.ts", "smoke": "bun run build && node dist/cli.mjs --version", "prepack": "npm run build" }, diff --git a/scripts/perf.ts b/scripts/perf.ts new file mode 100644 index 00000000..4c13ffee --- /dev/null +++ b/scripts/perf.ts @@ -0,0 +1,163 @@ +import { spawn } from 'child_process' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'fs' +import { join, resolve } from 'path' + +type Mode = 'startup' | 'query' + +type RunSummary = { + mode: Mode + command: string[] + artifactDir: string + startupReportPath: string | null + queryReportPath: string | null + wallTimeMs: number + exitCode: number + startupTotalMs: number | null + queryTtftMs: number | null + queryTotalTimeMs: number | null + queryPreApiOverheadMs: number | null +} + +function fail(message: string): never { + process.stderr.write(`${message}\n`) + process.exit(1) +} + +function parseArgs(argv: string[]): { mode: Mode; cliArgs: string[] } { + const [mode, ...rest] = argv + if (mode !== 'startup' && mode !== 'query') { + fail( + 'Usage: bun run scripts/perf.ts -- ', + ) + } + + const separatorIdx = rest.indexOf('--') + const cliArgs = separatorIdx === -1 ? rest : rest.slice(separatorIdx + 1) + return { mode, cliArgs } +} + +function ensureBuilt(): void { + if (!existsSync(resolve('dist/cli.mjs'))) { + fail('Missing dist/cli.mjs. Run `bun run build` before running perf scripts.') + } +} + +function getRunId(): string { + return new Date().toISOString().replace(/[:.]/g, '-') +} + +function getLatestFile(dir: string): string | null { + if (!existsSync(dir)) return null + const files = readdirSync(dir) + .map(name => join(dir, name)) + .filter(path => statSync(path).isFile()) + .sort( + (a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs, + ) + return files[0] ?? null +} + +function extractMs(report: string, regex: RegExp): number | null { + const match = report.match(regex) + if (!match?.[1]) return null + const value = Number.parseFloat(match[1]) + return Number.isFinite(value) ? value : null +} + +function readMaybe(path: string | null): string | null { + if (!path) return null + return readFileSync(path, 'utf8') +} + +async function runCommand( + mode: Mode, + cliArgs: string[], + artifactDir: string, +): Promise { + const configDir = join(artifactDir, 'config') + const debugDir = join(artifactDir, 'debug') + mkdirSync(configDir, { recursive: true }) + mkdirSync(debugDir, { recursive: true }) + + const childArgs = ['dist/cli.mjs', ...cliArgs] + if ( + mode === 'query' && + !cliArgs.some( + arg => arg === '--debug-file' || arg.startsWith('--debug-file='), + ) + ) { + childArgs.splice(1, 0, '--debug-file', join(debugDir, 'query-debug.txt')) + } + + const env = { + ...process.env, + BETTER_CLAWD_CONFIG_DIR: configDir, + CLAUDE_CODE_DEBUG_LOGS_DIR: debugDir, + CLAUDE_CODE_PROFILE_STARTUP: mode === 'startup' ? '1' : '0', + CLAUDE_CODE_PROFILE_QUERY: mode === 'query' ? '1' : '0', + } + + const startedAt = Date.now() + const exitCode = await new Promise((resolveExit, reject) => { + const child = spawn('node', childArgs, { + cwd: resolve('.'), + env, + stdio: 'inherit', + }) + + child.on('error', reject) + child.on('exit', code => resolveExit(code ?? 1)) + }) + const wallTimeMs = Date.now() - startedAt + + const startupReportPath = getLatestFile(join(configDir, 'startup-perf')) + const queryReportPath = getLatestFile(join(configDir, 'query-perf')) + const startupReport = readMaybe(startupReportPath) + const queryReport = readMaybe(queryReportPath) + + return { + mode, + command: ['node', ...childArgs], + artifactDir, + startupReportPath, + queryReportPath, + wallTimeMs, + exitCode, + startupTotalMs: startupReport + ? extractMs(startupReport, /Total startup time:\s+([0-9.]+)ms/) + : null, + queryTtftMs: queryReport + ? extractMs(queryReport, /Total TTFT:\s+([0-9.]+)ms/) + : null, + queryTotalTimeMs: queryReport + ? extractMs(queryReport, /Total time:\s+([0-9.]+)ms/) + : null, + queryPreApiOverheadMs: queryReport + ? extractMs(queryReport, /Total pre-API overhead\s+([0-9.]+)ms/) + : null, + } +} + +const { mode, cliArgs } = parseArgs(process.argv.slice(2)) +ensureBuilt() + +const artifactDir = resolve('.perf-artifacts', `${mode}-${getRunId()}`) +mkdirSync(artifactDir, { recursive: true }) + +const summary = await runCommand(mode, cliArgs, artifactDir) +const summaryPath = join(artifactDir, 'summary.json') +writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + +process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`) +process.stdout.write(`Summary written to ${summaryPath}\n`) + +if (summary.exitCode !== 0) { + process.exit(summary.exitCode) +} diff --git a/src/cli/print.ts b/src/cli/print.ts index 60472575..1aa63186 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -544,9 +544,21 @@ export async function runHeadless( proactiveModule.activateProactive('command') } - // Periodically force a full GC to keep memory usage in check + // Headless sessions can run for a long time, but forcing a full GC every + // second burns CPU even when memory is healthy. Poll less often and only + // collect aggressively once the heap is actually elevated, unless the user + // explicitly opts into the old behavior for profiling/debugging. if (typeof Bun !== 'undefined') { - const gcTimer = setInterval(Bun.gc, 1000) + const GC_INTERVAL_MS = 15_000 + const GC_HEAP_THRESHOLD_BYTES = 768 * 1024 * 1024 + const gcTimer = setInterval(() => { + if ( + isEnvTruthy(process.env.CLAUDE_CODE_FORCE_PERIODIC_GC) || + process.memoryUsage().heapUsed >= GC_HEAP_THRESHOLD_BYTES + ) { + Bun.gc(true) + } + }, GC_INTERVAL_MS) gcTimer.unref() } diff --git a/src/commands/login/login.tsx b/src/commands/login/login.tsx index ef8ec65e..a7419473 100644 --- a/src/commands/login/login.tsx +++ b/src/commands/login/login.tsx @@ -1,108 +1,193 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { resetCostState } from '../../bootstrap/state.js'; -import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js'; -import type { LocalJSXCommandContext } from '../../commands.js'; -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; -import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'; -import { Dialog } from '../../components/design-system/Dialog.js'; -import { OpenAILoginFlow } from '../../components/OpenAILoginFlow.js'; -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; -import { Text } from '../../ink.js'; -import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'; -import { refreshPolicyLimits } from '../../services/policyLimits/index.js'; -import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'; -import { getConfiguredAuthProvider } from '../../utils/auth.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -import { stripSignatureBlocks } from '../../utils/messages.js'; -import { checkAndDisableAutoModeIfNeeded, checkAndDisableBypassPermissionsIfNeeded, resetAutoModeGateCheck, resetBypassPermissionsCheck } from '../../utils/permissions/bypassPermissionsKillswitch.js'; -import { resetUserCache } from '../../utils/user.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { - return { - context.onChangeAPIKey(); - // Signature-bearing blocks (thinking, connector_text) are bound to the API key — - // strip them so the new key doesn't reject stale signatures. - context.setMessages(stripSignatureBlocks); - if (success) { - // Post-login refresh logic. Keep in sync with onboarding in src/interactiveHelpers.tsx - // Reset cost state when switching accounts - resetCostState(); - // Refresh remotely managed settings after login (non-blocking) - void refreshRemoteManagedSettings(); - // Refresh policy limits after login (non-blocking) - void refreshPolicyLimits(); - // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache(); - // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) - refreshGrowthBookAfterAuthChange(); - // Clear any stale trusted device token from a previous account before - // re-enrolling — prevents sending the old token on bridge calls while - // the async enrollTrustedDevice() is in-flight. - clearTrustedDeviceToken(); - // Enroll as a trusted device for Remote Control (10-min fresh-session window) - void enrollTrustedDevice(); - // Reset killswitch gate checks and re-run with new org - resetBypassPermissionsCheck(); - const appState = context.getAppState(); - void checkAndDisableBypassPermissionsIfNeeded(appState.toolPermissionContext, context.setAppState); - if (feature('TRANSCRIPT_CLASSIFIER')) { - resetAutoModeGateCheck(); - void checkAndDisableAutoModeIfNeeded(appState.toolPermissionContext, context.setAppState, appState.fastMode); +import { feature } from 'bun:bundle' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { resetCostState } from '../../bootstrap/state.js' +import { + clearTrustedDeviceToken, + enrollTrustedDevice, +} from '../../bridge/trustedDevice.js' +import type { LocalJSXCommandContext } from '../../commands.js' +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' +import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js' +import { Select } from '../../components/CustomSelect/select.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { OpenAILoginFlow } from '../../components/OpenAILoginFlow.js' +import { OpenRouterLoginFlow } from '../../components/OpenRouterLoginFlow.js' +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import { Box, Text } from '../../ink.js' +import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js' +import { refreshPolicyLimits } from '../../services/policyLimits/index.js' +import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import { getConfiguredAuthProvider } from '../../utils/auth.js' +import { stripSignatureBlocks } from '../../utils/messages.js' +import { + checkAndDisableAutoModeIfNeeded, + checkAndDisableBypassPermissionsIfNeeded, + resetAutoModeGateCheck, + resetBypassPermissionsCheck, +} from '../../utils/permissions/bypassPermissionsKillswitch.js' +import { resetUserCache } from '../../utils/user.js' + +type AuthProviderChoice = 'anthropic' | 'openai' | 'openrouter' + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, +): Promise { + return ( + { + context.onChangeAPIKey() + // Signature-bearing blocks are tied to the active auth state. + context.setMessages(stripSignatureBlocks) + + if (success) { + resetCostState() + void refreshRemoteManagedSettings() + void refreshPolicyLimits() + resetUserCache() + refreshGrowthBookAfterAuthChange() + clearTrustedDeviceToken() + void enrollTrustedDevice() + + resetBypassPermissionsCheck() + const appState = context.getAppState() + void checkAndDisableBypassPermissionsIfNeeded( + appState.toolPermissionContext, + context.setAppState, + ) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + resetAutoModeGateCheck() + void checkAndDisableAutoModeIfNeeded( + appState.toolPermissionContext, + context.setAppState, + appState.fastMode, + ) + } + + context.setAppState(prev => ({ + ...prev, + authVersion: prev.authVersion + 1, + })) + } + + onDone(success ? 'Login successful' : 'Login interrupted') + }} + /> + ) +} + +export function Login(props: { + onDone: (success: boolean, mainLoopModel: string) => void + startingMessage?: string +}): React.ReactNode { + const mainLoopModel = useMainLoopModel() + const configuredAuthProvider = getConfiguredAuthProvider() + const [selectedProvider, setSelectedProvider] = + useState(null) + + const providerOptions = useMemo( + () => [ + { + label: ( + + Anthropic{' '} + + Subscription login, Console API billing, or Bedrock/Foundry/Vertex + + {'\n'} + + ), + value: 'anthropic', + }, + { + label: ( + + OpenAI / Codex{' '} + + Codex login, Codex auth import, or OpenAI API key + + {'\n'} + + ), + value: 'openai', + }, + { + label: ( + + OpenRouter{' '} + OpenRouter API key via Responses API + {'\n'} + + ), + value: 'openrouter', + }, + ], + [], + ) + + const onCancel = () => props.onDone(false, mainLoopModel) + const onFlowDone = () => props.onDone(true, mainLoopModel) + + const body = + selectedProvider === null ? ( + + + {props.startingMessage ?? + 'Choose which provider you want Better-Clawd to use.'} + + + Current default: {configuredAuthProvider}. Pick a provider first, then + choose the login method inside that flow. + + Select provider: + +