perf improvements + /login fix
This commit is contained in:
61
PERFORMANCE.md
Normal file
61
PERFORMANCE.md
Normal file
@@ -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.
|
||||||
15
README.md
15
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.
|
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)
|
[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 keeps the good parts of the original UX
|
||||||
- It removes telemetry and unnecessary phone-home behavior
|
- It removes telemetry and unnecessary phone-home behavior
|
||||||
- It supports multiple providers without turning setup into a science project
|
- 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
|
- 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
|
## What Better-Clawd Changes
|
||||||
|
|
||||||
- Full Better-Clawd rebrand across the CLI, UI, config paths, installers, and app identity
|
- 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
|
## What You Get
|
||||||
|
|
||||||
- Better provider freedom
|
- 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
|
- OpenAI and OpenRouter support without weird bolt-on hacks
|
||||||
- Less phone-home behavior
|
- Less phone-home behavior
|
||||||
- A CLI that feels more practical, more open, and more yours
|
- A CLI that feels more practical, more open, and more yours
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "better-clawd",
|
"name": "better-clawd",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Claude Code, but better.",
|
"description": "Claude Code, but better.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
"build": "bun run scripts/build.ts",
|
"build": "bun run scripts/build.ts",
|
||||||
"start": "node dist/cli.mjs",
|
"start": "node dist/cli.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"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",
|
"smoke": "bun run build && node dist/cli.mjs --version",
|
||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
|
|||||||
163
scripts/perf.ts
Normal file
163
scripts/perf.ts
Normal file
@@ -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 <startup|query> -- <better-clawd args...>',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RunSummary> {
|
||||||
|
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<number>((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)
|
||||||
|
}
|
||||||
@@ -544,9 +544,21 @@ export async function runHeadless(
|
|||||||
proactiveModule.activateProactive('command')
|
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') {
|
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()
|
gcTimer.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -377,7 +377,13 @@ const MessagesImpl = ({
|
|||||||
columns
|
columns
|
||||||
} = useTerminalSize();
|
} = useTerminalSize();
|
||||||
const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E');
|
const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E');
|
||||||
const normalizedMessages = useMemo(() => normalizeMessages(messages).filter(isNotEmptyMessage), [messages]);
|
// In the main-screen renderer, pre-compact history already lives in native
|
||||||
|
// terminal scrollback, so normalizing it again just adds O(n) work on every
|
||||||
|
// render. Fullscreen/transcript modes still need the full in-memory history.
|
||||||
|
const normalizationSourceMessages = useMemo(() => verbose || isFullscreenEnvEnabled() ? messages : getMessagesAfterCompactBoundary(messages, {
|
||||||
|
includeSnipped: true
|
||||||
|
}), [messages, verbose]);
|
||||||
|
const normalizedMessages = useMemo(() => normalizeMessages(normalizationSourceMessages).filter(isNotEmptyMessage), [normalizationSourceMessages]);
|
||||||
|
|
||||||
// Check if streaming thinking should be visible (streaming or within 30s timeout)
|
// Check if streaming thinking should be visible (streaming or within 30s timeout)
|
||||||
const isStreamingThinkingVisible = useMemo(() => {
|
const isStreamingThinkingVisible = useMemo(() => {
|
||||||
@@ -485,18 +491,7 @@ const MessagesImpl = ({
|
|||||||
hasTruncatedMessages: hasTruncatedMessages_0,
|
hasTruncatedMessages: hasTruncatedMessages_0,
|
||||||
hiddenMessageCount: hiddenMessageCount_0
|
hiddenMessageCount: hiddenMessageCount_0
|
||||||
} = useMemo(() => {
|
} = useMemo(() => {
|
||||||
// In fullscreen mode the alt buffer has no native scrollback, so the
|
const compactAwareMessages = normalizedMessages;
|
||||||
// compact-boundary filter just hides history the ScrollBox could
|
|
||||||
// otherwise scroll to. Main-screen mode keeps the filter — pre-compact
|
|
||||||
// rows live above the viewport in native scrollback there, and
|
|
||||||
// re-rendering them triggers full resets.
|
|
||||||
// includeSnipped: UI rendering keeps snipped messages for scrollback
|
|
||||||
// (this PR's core goal — full history in UI, filter only for the model).
|
|
||||||
// Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so
|
|
||||||
// projectSnippedView's check against original removedUuids would fail.
|
|
||||||
const compactAwareMessages = verbose || isFullscreenEnvEnabled() ? normalizedMessages : getMessagesAfterCompactBoundary(normalizedMessages, {
|
|
||||||
includeSnipped: true
|
|
||||||
});
|
|
||||||
const messagesToShowNotTruncated = reorderMessagesInUI(compactAwareMessages.filter((msg_2): msg_2 is Exclude<NormalizedMessage, ProgressMessageType> => msg_2.type !== 'progress')
|
const messagesToShowNotTruncated = reorderMessagesInUI(compactAwareMessages.filter((msg_2): msg_2 is Exclude<NormalizedMessage, ProgressMessageType> => msg_2.type !== 'progress')
|
||||||
// CC-724: drop attachment messages that AttachmentMessage renders as
|
// CC-724: drop attachment messages that AttachmentMessage renders as
|
||||||
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
|
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
|
||||||
|
|||||||
88
src/components/OpenRouterLoginFlow.tsx
Normal file
88
src/components/OpenRouterLoginFlow.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Text } from '../ink.js'
|
||||||
|
import { saveOpenRouterApiKey } from '../utils/auth.js'
|
||||||
|
import { Spinner } from './Spinner.js'
|
||||||
|
import TextInput from './TextInput.js'
|
||||||
|
|
||||||
|
type OpenRouterLoginFlowProps = {
|
||||||
|
onDone: () => void
|
||||||
|
startingMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenRouterLoginFlow({
|
||||||
|
onDone,
|
||||||
|
startingMessage,
|
||||||
|
}: OpenRouterLoginFlowProps): React.ReactNode {
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [cursorOffset, setCursorOffset] = useState(0)
|
||||||
|
|
||||||
|
async function handleSubmit(value: string): Promise<void> {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBusy(true)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
await saveOpenRouterApiKey(trimmed)
|
||||||
|
onDone()
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusy) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Spinner />
|
||||||
|
<Text>Configuring OpenRouter login for Better-Clawd...</Text>
|
||||||
|
</Box>
|
||||||
|
<Text dimColor={true}>
|
||||||
|
OpenRouter support uses your OpenRouter API key with the Responses API
|
||||||
|
endpoint.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text>
|
||||||
|
{startingMessage ??
|
||||||
|
'Better-Clawd can use OpenRouter with your OpenRouter API key.'}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor={true}>
|
||||||
|
Paste your OpenRouter key to use `https://openrouter.ai/api/v1` and the
|
||||||
|
Responses API compatibility layer.
|
||||||
|
</Text>
|
||||||
|
<Box>
|
||||||
|
<Text>Paste your OpenRouter API key:</Text>
|
||||||
|
<TextInput
|
||||||
|
value={inputValue}
|
||||||
|
onChange={setInputValue}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onExit={() => {
|
||||||
|
setInputValue('')
|
||||||
|
setCursorOffset(0)
|
||||||
|
}}
|
||||||
|
cursorOffset={cursorOffset}
|
||||||
|
onChangeCursorOffset={setCursorOffset}
|
||||||
|
columns={72}
|
||||||
|
mask="*"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{status ? <Text color="error">{status}</Text> : null}
|
||||||
|
<Text dimColor={true}>
|
||||||
|
Press <Text bold={true}>Enter</Text> to save, or <Text bold={true}>Esc</Text>{' '}
|
||||||
|
to cancel.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/main.tsx
74
src/main.tsx
@@ -37,7 +37,6 @@ import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterA
|
|||||||
import { fetchBootstrapData } from './services/api/bootstrap.js';
|
import { fetchBootstrapData } from './services/api/bootstrap.js';
|
||||||
import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js';
|
import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js';
|
||||||
import { prefetchPassesEligibility } from './services/api/referral.js';
|
import { prefetchPassesEligibility } from './services/api/referral.js';
|
||||||
import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js';
|
|
||||||
import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js';
|
import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js';
|
||||||
import { isPolicyAllowed, loadPolicyLimits, refreshPolicyLimits, waitForPolicyLimitsToLoad } from './services/policyLimits/index.js';
|
import { isPolicyAllowed, loadPolicyLimits, refreshPolicyLimits, waitForPolicyLimitsToLoad } from './services/policyLimits/index.js';
|
||||||
import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js';
|
import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js';
|
||||||
@@ -58,8 +57,6 @@ import { createSystemMessage, createUserMessage } from './utils/messages.js';
|
|||||||
import { getPlatform } from './utils/platform.js';
|
import { getPlatform } from './utils/platform.js';
|
||||||
import { getBaseRenderOptions } from './utils/renderOptions.js';
|
import { getBaseRenderOptions } from './utils/renderOptions.js';
|
||||||
import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js';
|
import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js';
|
||||||
import { settingsChangeDetector } from './utils/settings/changeDetector.js';
|
|
||||||
import { skillChangeDetector } from './utils/skills/skillChangeDetector.js';
|
|
||||||
import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js';
|
import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js';
|
||||||
import { computeInitialTeamContext } from './utils/swarm/reconnection.js';
|
import { computeInitialTeamContext } from './utils/swarm/reconnection.js';
|
||||||
import { initializeWarningHandler } from './utils/warningHandler.js';
|
import { initializeWarningHandler } from './utils/warningHandler.js';
|
||||||
@@ -90,12 +87,10 @@ import type { StatsStore } from './context/stats.js';
|
|||||||
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js';
|
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js';
|
||||||
import { SHOW_CURSOR } from './ink/termio/dec.js';
|
import { SHOW_CURSOR } from './ink/termio/dec.js';
|
||||||
import { exitWithError, exitWithMessage, getRenderContext, renderAndRun, showSetupScreens } from './interactiveHelpers.js';
|
import { exitWithError, exitWithMessage, getRenderContext, renderAndRun, showSetupScreens } from './interactiveHelpers.js';
|
||||||
import { initBuiltinPlugins } from './plugins/bundled/index.js';
|
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
import { checkQuotaStatus } from './services/claudeAiLimits.js';
|
import { checkQuotaStatus } from './services/claudeAiLimits.js';
|
||||||
import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js';
|
import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js';
|
||||||
import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js';
|
import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js';
|
||||||
import { initBundledSkills } from './skills/bundled/index.js';
|
|
||||||
import type { AgentColorName } from './tools/AgentTool/agentColorManager.js';
|
import type { AgentColorName } from './tools/AgentTool/agentColorManager.js';
|
||||||
import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAgent, isCustomAgent, parseAgentsFromJson } from './tools/AgentTool/loadAgentsDir.js';
|
import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAgent, isCustomAgent, parseAgentsFromJson } from './tools/AgentTool/loadAgentsDir.js';
|
||||||
import type { LogOption } from './types/logs.js';
|
import type { LogOption } from './types/logs.js';
|
||||||
@@ -119,8 +114,6 @@ import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelSt
|
|||||||
import { ensureModelStringsInitialized } from './utils/model/modelStrings.js';
|
import { ensureModelStringsInitialized } from './utils/model/modelStrings.js';
|
||||||
import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js';
|
import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js';
|
||||||
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
|
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
|
||||||
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
|
|
||||||
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
|
|
||||||
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
|
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
|
||||||
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
|
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
|
||||||
import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js';
|
import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js';
|
||||||
@@ -146,7 +139,6 @@ import { clearServerCache } from 'src/services/mcp/client.js';
|
|||||||
import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js';
|
import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js';
|
||||||
import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js';
|
import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js';
|
||||||
import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js';
|
import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js';
|
||||||
import { getRelevantTips } from 'src/services/tips/tipRegistry.js';
|
|
||||||
import { logContextMetrics } from 'src/utils/api.js';
|
import { logContextMetrics } from 'src/utils/api.js';
|
||||||
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js';
|
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js';
|
||||||
import { registerCleanup } from 'src/utils/cleanupRegistry.js';
|
import { registerCleanup } from 'src/utils/cleanupRegistry.js';
|
||||||
@@ -159,7 +151,6 @@ import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError }
|
|||||||
import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js';
|
import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js';
|
||||||
import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js';
|
import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js';
|
||||||
import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js';
|
import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js';
|
||||||
import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js';
|
|
||||||
import { peekForStdinData, writeToStderr } from 'src/utils/process.js';
|
import { peekForStdinData, writeToStderr } from 'src/utils/process.js';
|
||||||
import { setCwd } from 'src/utils/Shell.js';
|
import { setCwd } from 'src/utils/Shell.js';
|
||||||
import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js';
|
import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js';
|
||||||
@@ -379,6 +370,32 @@ function prefetchSystemContextIfSafe(): void {
|
|||||||
// Otherwise, don't prefetch - wait for trust to be established first
|
// Otherwise, don't prefetch - wait for trust to be established first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runDeferredStartupTask(
|
||||||
|
label: string,
|
||||||
|
task: () => Promise<void>,
|
||||||
|
): void {
|
||||||
|
void task().catch(error => {
|
||||||
|
logForDebugging(
|
||||||
|
`[STARTUP] deferred ${label} failed: ${errorMessage(error)}`,
|
||||||
|
{ level: 'warn' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPluginRuntimeBookkeeping(): Promise<void> {
|
||||||
|
const [
|
||||||
|
{ initializeVersionedPlugins },
|
||||||
|
{ cleanupOrphanedPluginVersionsInBackground },
|
||||||
|
] = await Promise.all([
|
||||||
|
import('./utils/plugins/installedPluginsManager.js'),
|
||||||
|
import('./utils/plugins/cacheUtils.js'),
|
||||||
|
]);
|
||||||
|
await initializeVersionedPlugins();
|
||||||
|
profileCheckpoint('action_after_plugins_init');
|
||||||
|
await cleanupOrphanedPluginVersionsInBackground();
|
||||||
|
void getGlobExclusionsForPluginCache();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start background prefetches and housekeeping that are NOT needed before first render.
|
* Start background prefetches and housekeeping that are NOT needed before first render.
|
||||||
* These are deferred from setup() to reduce event loop contention and child process
|
* These are deferred from setup() to reduce event loop contention and child process
|
||||||
@@ -404,7 +421,10 @@ export function startDeferredPrefetches(): void {
|
|||||||
void initUser();
|
void initUser();
|
||||||
void getUserContext();
|
void getUserContext();
|
||||||
prefetchSystemContextIfSafe();
|
prefetchSystemContextIfSafe();
|
||||||
void getRelevantTips();
|
runDeferredStartupTask('tips prefetch', async () => {
|
||||||
|
const { getRelevantTips } = await import('./services/tips/tipRegistry.js');
|
||||||
|
await getRelevantTips();
|
||||||
|
});
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
|
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
|
||||||
void prefetchAwsCredentialsAndBedRockInfoIfSafe();
|
void prefetchAwsCredentialsAndBedRockInfoIfSafe();
|
||||||
}
|
}
|
||||||
@@ -415,13 +435,25 @@ export function startDeferredPrefetches(): void {
|
|||||||
|
|
||||||
// Analytics and feature flag initialization
|
// Analytics and feature flag initialization
|
||||||
void initializeAnalyticsGates();
|
void initializeAnalyticsGates();
|
||||||
void prefetchOfficialMcpUrls();
|
runDeferredStartupTask('official MCP registry prefetch', async () => {
|
||||||
void refreshModelCapabilities();
|
const { prefetchOfficialMcpUrls } = await import('./services/mcp/officialRegistry.js');
|
||||||
|
await prefetchOfficialMcpUrls();
|
||||||
|
});
|
||||||
|
runDeferredStartupTask('model capabilities refresh', async () => {
|
||||||
|
const { refreshModelCapabilities } = await import('./utils/model/modelCapabilities.js');
|
||||||
|
await refreshModelCapabilities();
|
||||||
|
});
|
||||||
|
|
||||||
// File change detectors deferred from init() to unblock first render
|
// File change detectors deferred from init() to unblock first render
|
||||||
void settingsChangeDetector.initialize();
|
runDeferredStartupTask('settings change detector', async () => {
|
||||||
|
const { settingsChangeDetector } = await import('./utils/settings/changeDetector.js');
|
||||||
|
await settingsChangeDetector.initialize();
|
||||||
|
});
|
||||||
if (!isBareMode()) {
|
if (!isBareMode()) {
|
||||||
void skillChangeDetector.initialize();
|
runDeferredStartupTask('skill change detector', async () => {
|
||||||
|
const { skillChangeDetector } = await import('./utils/skills/skillChangeDetector.js');
|
||||||
|
await skillChangeDetector.initialize();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event loop stall detector — logs when the main thread is blocked >500ms
|
// Event loop stall detector — logs when the main thread is blocked >500ms
|
||||||
@@ -1921,6 +1953,10 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
// reads synchronously. Previously ran inside setup() after ~20ms of
|
// reads synchronously. Previously ran inside setup() after ~20ms of
|
||||||
// await points, so the parallel getCommands() memoized an empty list.
|
// await points, so the parallel getCommands() memoized an empty list.
|
||||||
if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') {
|
if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') {
|
||||||
|
const [{ initBuiltinPlugins }, { initBundledSkills }] = await Promise.all([
|
||||||
|
import('./plugins/bundled/index.js'),
|
||||||
|
import('./skills/bundled/index.js'),
|
||||||
|
]);
|
||||||
initBuiltinPlugins();
|
initBuiltinPlugins();
|
||||||
initBundledSkills();
|
initBundledSkills();
|
||||||
}
|
}
|
||||||
@@ -2556,17 +2592,11 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
// skip — no-op
|
// skip — no-op
|
||||||
} else if (isNonInteractiveSession) {
|
} else if (isNonInteractiveSession) {
|
||||||
// In headless mode, await to ensure plugin sync completes before CLI exits
|
// In headless mode, await to ensure plugin sync completes before CLI exits
|
||||||
await initializeVersionedPlugins();
|
await runPluginRuntimeBookkeeping();
|
||||||
profileCheckpoint('action_after_plugins_init');
|
|
||||||
void cleanupOrphanedPluginVersionsInBackground().then(() => getGlobExclusionsForPluginCache());
|
|
||||||
} else {
|
} else {
|
||||||
// In interactive mode, fire-and-forget — this is purely bookkeeping
|
// In interactive mode, fire-and-forget — this is purely bookkeeping
|
||||||
// that doesn't affect runtime behavior of the current session
|
// that doesn't affect runtime behavior of the current session
|
||||||
void initializeVersionedPlugins().then(async () => {
|
void runPluginRuntimeBookkeeping().catch(error => logError(toError(error)));
|
||||||
profileCheckpoint('action_after_plugins_init');
|
|
||||||
await cleanupOrphanedPluginVersionsInBackground();
|
|
||||||
void getGlobExclusionsForPluginCache();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null;
|
const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null;
|
||||||
if (initOnly) {
|
if (initOnly) {
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ import { useMergedTools } from '../hooks/useMergedTools.js';
|
|||||||
import { mergeAndFilterTools } from '../utils/toolPool.js';
|
import { mergeAndFilterTools } from '../utils/toolPool.js';
|
||||||
import { useMergedCommands } from '../hooks/useMergedCommands.js';
|
import { useMergedCommands } from '../hooks/useMergedCommands.js';
|
||||||
import { useSkillsChange } from '../hooks/useSkillsChange.js';
|
import { useSkillsChange } from '../hooks/useSkillsChange.js';
|
||||||
|
import { useSettingsChange } from '../hooks/useSettingsChange.js';
|
||||||
import { useManagePlugins } from '../hooks/useManagePlugins.js';
|
import { useManagePlugins } from '../hooks/useManagePlugins.js';
|
||||||
import { Messages } from '../components/Messages.js';
|
import { Messages } from '../components/Messages.js';
|
||||||
import { TaskListV2 } from '../components/TaskListV2.js';
|
import { TaskListV2 } from '../components/TaskListV2.js';
|
||||||
@@ -295,6 +296,32 @@ const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [];
|
|||||||
const EMPTY_TOOL_USE_CONFIRM_QUEUE: ToolUseConfirm[] = [];
|
const EMPTY_TOOL_USE_CONFIRM_QUEUE: ToolUseConfirm[] = [];
|
||||||
const EMPTY_IN_PROGRESS_TOOL_USE_IDS = new Set<string>();
|
const EMPTY_IN_PROGRESS_TOOL_USE_IDS = new Set<string>();
|
||||||
|
|
||||||
|
type TurnContextSnapshot = {
|
||||||
|
key: string;
|
||||||
|
defaultSystemPrompt: string[];
|
||||||
|
userContext: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
systemContext: {
|
||||||
|
[k: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMcpContextSignature(mcpClients: MCPServerConnection[]): string {
|
||||||
|
return mcpClients.map(client => {
|
||||||
|
if (client.type === 'connected') {
|
||||||
|
return [client.name, client.config.type ?? 'stdio', client.instructions ?? ''].join('::');
|
||||||
|
}
|
||||||
|
return [client.name, client.type].join('::');
|
||||||
|
}).join('||');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTurnContextCacheKey(model: string, tools: ReadonlyArray<{
|
||||||
|
name: string;
|
||||||
|
}>, additionalWorkingDirectories: readonly string[], mcpClients: MCPServerConnection[]): string {
|
||||||
|
return [model, tools.map(tool => tool.name).sort().join('|'), additionalWorkingDirectories.join('|'), getMcpContextSignature(mcpClients)].join('|||');
|
||||||
|
}
|
||||||
|
|
||||||
// Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new
|
// Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new
|
||||||
// function identity each render, which would break composedOnScroll's memo.
|
// function identity each render, which would break composedOnScroll's memo.
|
||||||
const HISTORY_STUB = {
|
const HISTORY_STUB = {
|
||||||
@@ -681,9 +708,16 @@ export function REPL({
|
|||||||
|
|
||||||
// Local state for commands (hot-reloadable when skill files change)
|
// Local state for commands (hot-reloadable when skill files change)
|
||||||
const [localCommands, setLocalCommands] = useState(initialCommands);
|
const [localCommands, setLocalCommands] = useState(initialCommands);
|
||||||
|
const turnContextCacheRef = useRef<TurnContextSnapshot | null>(null);
|
||||||
|
|
||||||
// Watch for skill file changes and reload all commands
|
// Watch for skill file changes and reload all commands
|
||||||
useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands);
|
useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands);
|
||||||
|
useEffect(() => {
|
||||||
|
turnContextCacheRef.current = null;
|
||||||
|
}, [localCommands]);
|
||||||
|
useSettingsChange(() => {
|
||||||
|
turnContextCacheRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
// Track proactive mode for tools dependency - SleepTool filters by proactive state
|
// Track proactive mode for tools dependency - SleepTool filters by proactive state
|
||||||
const proactiveActive = React.useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE);
|
const proactiveActive = React.useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE);
|
||||||
@@ -2531,6 +2565,23 @@ export function REPL({
|
|||||||
contentReplacementState: contentReplacementStateRef.current
|
contentReplacementState: contentReplacementStateRef.current
|
||||||
};
|
};
|
||||||
}, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]);
|
}, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]);
|
||||||
|
const loadTurnContext = useCallback(async (toolsForPrompt: typeof combinedInitialTools, mcpClientsForPrompt: MCPServerConnection[], model: string): Promise<TurnContextSnapshot> => {
|
||||||
|
const additionalWorkingDirectories = Array.from(toolPermissionContext.additionalWorkingDirectories.keys());
|
||||||
|
const cacheKey = buildTurnContextCacheKey(model, toolsForPrompt, additionalWorkingDirectories, mcpClientsForPrompt);
|
||||||
|
const cached = turnContextCacheRef.current;
|
||||||
|
if (cached?.key === cacheKey) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolsForPrompt, model, additionalWorkingDirectories, mcpClientsForPrompt), getUserContext(), getSystemContext()]);
|
||||||
|
const next = {
|
||||||
|
key: cacheKey,
|
||||||
|
defaultSystemPrompt,
|
||||||
|
userContext,
|
||||||
|
systemContext
|
||||||
|
};
|
||||||
|
turnContextCacheRef.current = next;
|
||||||
|
return next;
|
||||||
|
}, [toolPermissionContext]);
|
||||||
|
|
||||||
// Session backgrounding (Ctrl+B to background/foreground)
|
// Session backgrounding (Ctrl+B to background/foreground)
|
||||||
const handleBackgroundQuery = useCallback(() => {
|
const handleBackgroundQuery = useCallback(() => {
|
||||||
@@ -2542,7 +2593,11 @@ export function REPL({
|
|||||||
const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification');
|
const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification');
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel);
|
const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel);
|
||||||
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]);
|
const {
|
||||||
|
defaultSystemPrompt,
|
||||||
|
userContext,
|
||||||
|
systemContext
|
||||||
|
} = await loadTurnContext(toolUseContext.options.tools, toolUseContext.options.mcpClients, mainLoopModel);
|
||||||
const systemPrompt = buildEffectiveSystemPrompt({
|
const systemPrompt = buildEffectiveSystemPrompt({
|
||||||
mainThreadAgentDefinition,
|
mainThreadAgentDefinition,
|
||||||
toolUseContext,
|
toolUseContext,
|
||||||
@@ -2581,7 +2636,7 @@ export function REPL({
|
|||||||
agentDefinition: mainThreadAgentDefinition
|
agentDefinition: mainThreadAgentDefinition
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState]);
|
}, [abortController, mainLoopModel, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, loadTurnContext]);
|
||||||
const {
|
const {
|
||||||
handleBackgroundSession
|
handleBackgroundSession
|
||||||
} = useSessionBackgrounding({
|
} = useSessionBackgrounding({
|
||||||
@@ -2775,11 +2830,16 @@ export function REPL({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
queryCheckpoint('query_context_loading_start');
|
queryCheckpoint('query_context_loading_start');
|
||||||
const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
const [,, turnContext] = await Promise.all([
|
||||||
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
||||||
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
|
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
|
||||||
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
|
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
|
||||||
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]);
|
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, loadTurnContext(freshTools, freshMcpClients, mainLoopModelParam)]);
|
||||||
|
const {
|
||||||
|
defaultSystemPrompt,
|
||||||
|
userContext: baseUserContext,
|
||||||
|
systemContext
|
||||||
|
} = turnContext;
|
||||||
const userContext = {
|
const userContext = {
|
||||||
...baseUserContext,
|
...baseUserContext,
|
||||||
...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined),
|
...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined),
|
||||||
@@ -2861,7 +2921,7 @@ export function REPL({
|
|||||||
|
|
||||||
// Signal that a query turn has completed successfully
|
// Signal that a query turn has completed successfully
|
||||||
await onTurnComplete?.(messagesRef.current);
|
await onTurnComplete?.(messagesRef.current);
|
||||||
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled]);
|
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, loadTurnContext]);
|
||||||
const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>, input?: string, effort?: EffortValue): Promise<void> => {
|
const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>, input?: string, effort?: EffortValue): Promise<void> => {
|
||||||
// If this is a teammate, mark them as active when starting a turn
|
// If this is a teammate, mark them as active when starting a turn
|
||||||
if (isAgentSwarmsEnabled()) {
|
if (isAgentSwarmsEnabled()) {
|
||||||
@@ -4941,8 +5001,11 @@ export function REPL({
|
|||||||
}
|
}
|
||||||
const newAbortController = createAbortController();
|
const newAbortController = createAbortController();
|
||||||
const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel);
|
const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel);
|
||||||
const appState = context.getAppState();
|
const {
|
||||||
const defaultSysPrompt = await getSystemPrompt(context.options.tools, context.options.mainLoopModel, Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()), context.options.mcpClients);
|
defaultSystemPrompt: defaultSysPrompt,
|
||||||
|
userContext,
|
||||||
|
systemContext
|
||||||
|
} = await loadTurnContext(context.options.tools, context.options.mcpClients, context.options.mainLoopModel);
|
||||||
const systemPrompt = buildEffectiveSystemPrompt({
|
const systemPrompt = buildEffectiveSystemPrompt({
|
||||||
mainThreadAgentDefinition: undefined,
|
mainThreadAgentDefinition: undefined,
|
||||||
toolUseContext: context,
|
toolUseContext: context,
|
||||||
@@ -4950,7 +5013,6 @@ export function REPL({
|
|||||||
defaultSystemPrompt: defaultSysPrompt,
|
defaultSystemPrompt: defaultSysPrompt,
|
||||||
appendSystemPrompt: context.options.appendSystemPrompt
|
appendSystemPrompt: context.options.appendSystemPrompt
|
||||||
});
|
});
|
||||||
const [userContext, systemContext] = await Promise.all([getUserContext(), getSystemContext()]);
|
|
||||||
const result = await partialCompactConversation(compactMessages, messageIndex, context, {
|
const result = await partialCompactConversation(compactMessages, messageIndex, context, {
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
userContext,
|
userContext,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js'
|
|||||||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||||
import { createChildAbortController } from '../../utils/abortController.js'
|
import { createChildAbortController } from '../../utils/abortController.js'
|
||||||
|
import { getMaxToolUseConcurrency } from './toolConcurrency.js'
|
||||||
import { runToolUse } from './toolExecution.js'
|
import { runToolUse } from './toolExecution.js'
|
||||||
|
|
||||||
type MessageUpdate = {
|
type MessageUpdate = {
|
||||||
@@ -31,6 +32,20 @@ type TrackedTool = {
|
|||||||
contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>
|
contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EPHEMERAL_PROGRESS_TYPES = new Set([
|
||||||
|
'bash_progress',
|
||||||
|
'powershell_progress',
|
||||||
|
'mcp_progress',
|
||||||
|
'sleep_progress',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isEphemeralProgressMessage(message: Message): boolean {
|
||||||
|
return (
|
||||||
|
message.type === 'progress' &&
|
||||||
|
EPHEMERAL_PROGRESS_TYPES.has(message.data.type)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes tools as they stream in with concurrency control.
|
* Executes tools as they stream in with concurrency control.
|
||||||
* - Concurrent-safe tools can execute in parallel with other concurrent-safe tools
|
* - Concurrent-safe tools can execute in parallel with other concurrent-safe tools
|
||||||
@@ -128,10 +143,13 @@ export class StreamingToolExecutor {
|
|||||||
*/
|
*/
|
||||||
private canExecuteTool(isConcurrencySafe: boolean): boolean {
|
private canExecuteTool(isConcurrencySafe: boolean): boolean {
|
||||||
const executingTools = this.tools.filter(t => t.status === 'executing')
|
const executingTools = this.tools.filter(t => t.status === 'executing')
|
||||||
return (
|
if (executingTools.length === 0) {
|
||||||
executingTools.length === 0 ||
|
return true
|
||||||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
|
}
|
||||||
)
|
if (!isConcurrencySafe || !executingTools.every(t => t.isConcurrencySafe)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return executingTools.length < getMaxToolUseConcurrency()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -366,7 +384,17 @@ export class StreamingToolExecutor {
|
|||||||
if (update.message) {
|
if (update.message) {
|
||||||
// Progress messages go to pendingProgress for immediate yielding
|
// Progress messages go to pendingProgress for immediate yielding
|
||||||
if (update.message.type === 'progress') {
|
if (update.message.type === 'progress') {
|
||||||
tool.pendingProgress.push(update.message)
|
const lastPending = tool.pendingProgress.at(-1)
|
||||||
|
if (
|
||||||
|
isEphemeralProgressMessage(update.message) &&
|
||||||
|
lastPending?.type === 'progress' &&
|
||||||
|
lastPending.data.type === update.message.data.type
|
||||||
|
) {
|
||||||
|
tool.pendingProgress[tool.pendingProgress.length - 1] =
|
||||||
|
update.message
|
||||||
|
} else {
|
||||||
|
tool.pendingProgress.push(update.message)
|
||||||
|
}
|
||||||
// Signal that progress is available
|
// Signal that progress is available
|
||||||
if (this.progressAvailableResolve) {
|
if (this.progressAvailableResolve) {
|
||||||
this.progressAvailableResolve()
|
this.progressAvailableResolve()
|
||||||
|
|||||||
8
src/services/tools/toolConcurrency.ts
Normal file
8
src/services/tools/toolConcurrency.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const DEFAULT_MAX_TOOL_USE_CONCURRENCY = 4
|
||||||
|
|
||||||
|
export function getMaxToolUseConcurrency(): number {
|
||||||
|
const parsed = parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: DEFAULT_MAX_TOOL_USE_CONCURRENCY
|
||||||
|
}
|
||||||
33
src/services/tools/toolOrchestration.test.ts
Normal file
33
src/services/tools/toolOrchestration.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_TOOL_USE_CONCURRENCY,
|
||||||
|
getMaxToolUseConcurrency,
|
||||||
|
} from './toolConcurrency.js'
|
||||||
|
|
||||||
|
const ORIGINAL_CONCURRENCY = process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_CONCURRENCY === undefined) {
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = ORIGINAL_CONCURRENCY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('defaults tool concurrency to a bounded budget', () => {
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
|
||||||
|
expect(getMaxToolUseConcurrency()).toBe(DEFAULT_MAX_TOOL_USE_CONCURRENCY)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows an explicit positive concurrency override', () => {
|
||||||
|
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = '7'
|
||||||
|
expect(getMaxToolUseConcurrency()).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores invalid concurrency overrides', () => {
|
||||||
|
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = '0'
|
||||||
|
expect(getMaxToolUseConcurrency()).toBe(DEFAULT_MAX_TOOL_USE_CONCURRENCY)
|
||||||
|
|
||||||
|
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = 'not-a-number'
|
||||||
|
expect(getMaxToolUseConcurrency()).toBe(DEFAULT_MAX_TOOL_USE_CONCURRENCY)
|
||||||
|
})
|
||||||
@@ -4,12 +4,10 @@ import { findToolByName, type ToolUseContext } from '../../Tool.js'
|
|||||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||||
import { all } from '../../utils/generators.js'
|
import { all } from '../../utils/generators.js'
|
||||||
import { type MessageUpdateLazy, runToolUse } from './toolExecution.js'
|
import { type MessageUpdateLazy, runToolUse } from './toolExecution.js'
|
||||||
|
export {
|
||||||
function getMaxToolUseConcurrency(): number {
|
DEFAULT_MAX_TOOL_USE_CONCURRENCY,
|
||||||
return (
|
getMaxToolUseConcurrency,
|
||||||
parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
|
} from './toolConcurrency.js'
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageUpdate = {
|
export type MessageUpdate = {
|
||||||
message?: Message
|
message?: Message
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import { requireComputerUseSwift } from './swiftLoader.js'
|
|||||||
* promises hang. Electron drains it via CFRunLoop so Cowork doesn't need this.
|
* promises hang. Electron drains it via CFRunLoop so Cowork doesn't need this.
|
||||||
*
|
*
|
||||||
* One refcounted setInterval calls `_drainMainRunLoop` (RunLoop.main.run)
|
* One refcounted setInterval calls `_drainMainRunLoop` (RunLoop.main.run)
|
||||||
* every 1ms while any main-queue-dependent call is pending. Multiple
|
* every few milliseconds while any main-queue-dependent call is pending. Multiple
|
||||||
* concurrent drainRunLoop() calls share the single pump via retain/release.
|
* concurrent drainRunLoop() calls share the single pump via retain/release.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let pump: ReturnType<typeof setInterval> | undefined
|
let pump: ReturnType<typeof setInterval> | undefined
|
||||||
let pending = 0
|
let pending = 0
|
||||||
|
const DRAIN_INTERVAL_MS = Math.max(
|
||||||
|
1,
|
||||||
|
Number.parseInt(process.env.CLAUDE_CODE_COMPUTER_USE_PUMP_MS || '', 10) || 4,
|
||||||
|
)
|
||||||
|
|
||||||
function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
|
function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
|
||||||
cu._drainMainRunLoop()
|
cu._drainMainRunLoop()
|
||||||
@@ -24,7 +28,8 @@ function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
|
|||||||
function retain(): void {
|
function retain(): void {
|
||||||
pending++
|
pending++
|
||||||
if (pump === undefined) {
|
if (pump === undefined) {
|
||||||
pump = setInterval(drainTick, 1, requireComputerUseSwift())
|
pump = setInterval(drainTick, DRAIN_INTERVAL_MS, requireComputerUseSwift())
|
||||||
|
pump.unref?.()
|
||||||
logForDebugging('[drainRunLoop] pump started', { level: 'verbose' })
|
logForDebugging('[drainRunLoop] pump started', { level: 'verbose' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/utils/cronScheduler.test.ts
Normal file
15
src/utils/cronScheduler.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
import { getSchedulerCheckDelayMs } from './cronScheduler.js'
|
||||||
|
|
||||||
|
test('uses a slower idle cadence when no cron tasks are pending', () => {
|
||||||
|
expect(getSchedulerCheckDelayMs(null, 10_000)).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the minimum cadence for tasks that are due soon', () => {
|
||||||
|
expect(getSchedulerCheckDelayMs(10_500, 10_000)).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('backs off proportionally for tasks scheduled farther out', () => {
|
||||||
|
expect(getSchedulerCheckDelayMs(16_000, 10_000)).toBe(3000)
|
||||||
|
expect(getSchedulerCheckDelayMs(22_000, 10_000)).toBe(5000)
|
||||||
|
})
|
||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
import { logForDebugging } from './debug.js'
|
import { logForDebugging } from './debug.js'
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 1000
|
const CHECK_INTERVAL_MS = 1000
|
||||||
|
const NO_TASKS_CHECK_DELAY_MS = 2000
|
||||||
|
const MAX_CHECK_DELAY_MS = 5000
|
||||||
const FILE_STABILITY_MS = 300
|
const FILE_STABILITY_MS = 300
|
||||||
// How often a non-owning session re-probes the scheduler lock. Coarse
|
// How often a non-owning session re-probes the scheduler lock. Coarse
|
||||||
// because takeover only matters when the owning session has crashed.
|
// because takeover only matters when the owning session has crashed.
|
||||||
@@ -59,6 +61,31 @@ export function isRecurringTaskAged(
|
|||||||
return Boolean(t.recurring && !t.permanent && nowMs - t.createdAt >= maxAgeMs)
|
return Boolean(t.recurring && !t.permanent && nowMs - t.createdAt >= maxAgeMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSchedulerCheckDelayMs(
|
||||||
|
nextFireAtMs: number | null,
|
||||||
|
nowMs: number,
|
||||||
|
options?: {
|
||||||
|
minMs?: number
|
||||||
|
maxMs?: number
|
||||||
|
noTasksDelayMs?: number
|
||||||
|
},
|
||||||
|
): number {
|
||||||
|
const minMs = options?.minMs ?? CHECK_INTERVAL_MS
|
||||||
|
const maxMs = options?.maxMs ?? MAX_CHECK_DELAY_MS
|
||||||
|
const noTasksDelayMs = options?.noTasksDelayMs ?? NO_TASKS_CHECK_DELAY_MS
|
||||||
|
|
||||||
|
if (nextFireAtMs === null) {
|
||||||
|
return noTasksDelayMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const untilFireMs = nextFireAtMs - nowMs
|
||||||
|
if (untilFireMs <= minMs) {
|
||||||
|
return minMs
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(maxMs, Math.max(minMs, Math.floor(untilFireMs / 2)))
|
||||||
|
}
|
||||||
|
|
||||||
type CronSchedulerOptions = {
|
type CronSchedulerOptions = {
|
||||||
/** Called when a task fires (regular or missed-on-startup). */
|
/** Called when a task fires (regular or missed-on-startup). */
|
||||||
onFire: (prompt: string) => void
|
onFire: (prompt: string) => void
|
||||||
@@ -170,7 +197,7 @@ export function createCronScheduler(
|
|||||||
const inFlight = new Set<string>()
|
const inFlight = new Set<string>()
|
||||||
|
|
||||||
let enablePoll: ReturnType<typeof setInterval> | null = null
|
let enablePoll: ReturnType<typeof setInterval> | null = null
|
||||||
let checkTimer: ReturnType<typeof setInterval> | null = null
|
let checkTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let lockProbeTimer: ReturnType<typeof setInterval> | null = null
|
let lockProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let watcher: FSWatcher | null = null
|
let watcher: FSWatcher | null = null
|
||||||
let stopped = false
|
let stopped = false
|
||||||
@@ -189,7 +216,10 @@ export function createCronScheduler(
|
|||||||
// Recurring tasks are NOT surfaced or deleted — check() handles them
|
// Recurring tasks are NOT surfaced or deleted — check() handles them
|
||||||
// correctly (fires on first tick, reschedules forward). Only one-shot
|
// correctly (fires on first tick, reschedules forward). Only one-shot
|
||||||
// missed tasks need user input (run once now, or discard forever).
|
// missed tasks need user input (run once now, or discard forever).
|
||||||
if (!initial) return
|
if (!initial) {
|
||||||
|
scheduleCheck(CHECK_INTERVAL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const missed = findMissedTasks(next, now).filter(
|
const missed = findMissedTasks(next, now).filter(
|
||||||
@@ -225,6 +255,7 @@ export function createCronScheduler(
|
|||||||
`[ScheduledTasks] surfaced ${missed.length} missed one-shot task(s)`,
|
`[ScheduledTasks] surfaced ${missed.length} missed one-shot task(s)`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
scheduleCheck(CHECK_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function check() {
|
function check() {
|
||||||
@@ -393,6 +424,29 @@ export function createCronScheduler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNextFireTimeValue(): number | null {
|
||||||
|
let min = Infinity
|
||||||
|
for (const t of nextFireAt.values()) {
|
||||||
|
if (t < min) min = t
|
||||||
|
}
|
||||||
|
return min === Infinity ? null : min
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCheck(delayMs = CHECK_INTERVAL_MS): void {
|
||||||
|
if (stopped) return
|
||||||
|
if (checkTimer) {
|
||||||
|
clearTimeout(checkTimer)
|
||||||
|
checkTimer = null
|
||||||
|
}
|
||||||
|
checkTimer = setTimeout(() => {
|
||||||
|
checkTimer = null
|
||||||
|
if (stopped) return
|
||||||
|
check()
|
||||||
|
scheduleCheck(getSchedulerCheckDelayMs(getNextFireTimeValue(), Date.now()))
|
||||||
|
}, delayMs)
|
||||||
|
checkTimer.unref?.()
|
||||||
|
}
|
||||||
|
|
||||||
async function enable() {
|
async function enable() {
|
||||||
if (stopped) return
|
if (stopped) return
|
||||||
if (enablePoll) {
|
if (enablePoll) {
|
||||||
@@ -450,13 +504,11 @@ export function createCronScheduler(
|
|||||||
if (!stopped) {
|
if (!stopped) {
|
||||||
tasks = []
|
tasks = []
|
||||||
nextFireAt.clear()
|
nextFireAt.clear()
|
||||||
|
scheduleCheck(NO_TASKS_CHECK_DELAY_MS)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
checkTimer = setInterval(check, CHECK_INTERVAL_MS)
|
scheduleCheck(CHECK_INTERVAL_MS)
|
||||||
// Don't keep the process alive for the scheduler alone — in -p text mode
|
|
||||||
// the process should exit after the single turn even if a cron was created.
|
|
||||||
checkTimer.unref?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -503,7 +555,7 @@ export function createCronScheduler(
|
|||||||
enablePoll = null
|
enablePoll = null
|
||||||
}
|
}
|
||||||
if (checkTimer) {
|
if (checkTimer) {
|
||||||
clearInterval(checkTimer)
|
clearTimeout(checkTimer)
|
||||||
checkTimer = null
|
checkTimer = null
|
||||||
}
|
}
|
||||||
if (lockProbeTimer) {
|
if (lockProbeTimer) {
|
||||||
@@ -521,11 +573,7 @@ export function createCronScheduler(
|
|||||||
// nextFireAt uses Infinity for "never" (in-flight one-shots, bad cron
|
// nextFireAt uses Infinity for "never" (in-flight one-shots, bad cron
|
||||||
// strings). Filter those out so callers can distinguish "soon" from
|
// strings). Filter those out so callers can distinguish "soon" from
|
||||||
// "nothing pending".
|
// "nothing pending".
|
||||||
let min = Infinity
|
return getNextFireTimeValue()
|
||||||
for (const t of nextFireAt.values()) {
|
|
||||||
if (t < min) min = t
|
|
||||||
}
|
|
||||||
return min === Infinity ? null : min
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,13 @@
|
|||||||
* - query_end: End of query
|
* - query_end: End of query
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { join } from 'path'
|
||||||
|
import { getSessionId } from 'src/bootstrap/state.js'
|
||||||
import { logForDebugging } from './debug.js'
|
import { logForDebugging } from './debug.js'
|
||||||
import { isEnvTruthy } from './envUtils.js'
|
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
|
||||||
|
import { getFsImplementation } from './fsOperations.js'
|
||||||
import { formatMs, formatTimelineLine, getPerformance } from './profilerBase.js'
|
import { formatMs, formatTimelineLine, getPerformance } from './profilerBase.js'
|
||||||
|
import { writeFileSync_DEPRECATED } from './slowOperations.js'
|
||||||
|
|
||||||
// Module-level state - initialized once when the module loads
|
// Module-level state - initialized once when the module loads
|
||||||
// eslint-disable-next-line custom-rules/no-process-env-top-level
|
// eslint-disable-next-line custom-rules/no-process-env-top-level
|
||||||
@@ -210,6 +214,14 @@ function getQueryProfileReport(): string {
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getQueryPerfLogPath(queryNumber = queryCount): string {
|
||||||
|
return join(
|
||||||
|
getClaudeConfigHomeDir(),
|
||||||
|
'query-perf',
|
||||||
|
`${getSessionId()}-q${queryNumber}.txt`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get phase-based summary showing time spent in each major phase
|
* Get phase-based summary showing time spent in each major phase
|
||||||
*/
|
*/
|
||||||
@@ -297,5 +309,17 @@ function getPhaseSummary(
|
|||||||
*/
|
*/
|
||||||
export function logQueryProfileReport(): void {
|
export function logQueryProfileReport(): void {
|
||||||
if (!ENABLED) return
|
if (!ENABLED) return
|
||||||
logForDebugging(getQueryProfileReport())
|
const report = getQueryProfileReport()
|
||||||
|
logForDebugging(report)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = getQueryPerfLogPath()
|
||||||
|
getFsImplementation().mkdirSync(join(getClaudeConfigHomeDir(), 'query-perf'))
|
||||||
|
writeFileSync_DEPRECATED(path, report, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flush: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Best-effort artifact write. Debug output above still preserves the report.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/utils/task/TaskOutput.test.ts
Normal file
19
src/utils/task/TaskOutput.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
import { getNextTaskOutputPollIntervalMs } from './taskOutputPolling.js'
|
||||||
|
|
||||||
|
test('resets task output polling to the active interval on new activity', () => {
|
||||||
|
expect(getNextTaskOutputPollIntervalMs(4000, true)).toBe(250)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('backs task output polling off when the command is quiet', () => {
|
||||||
|
let intervalMs = getNextTaskOutputPollIntervalMs(250, false)
|
||||||
|
expect(intervalMs).toBe(1000)
|
||||||
|
|
||||||
|
intervalMs = getNextTaskOutputPollIntervalMs(intervalMs, false)
|
||||||
|
expect(intervalMs).toBe(2000)
|
||||||
|
|
||||||
|
intervalMs = getNextTaskOutputPollIntervalMs(intervalMs, false)
|
||||||
|
expect(intervalMs).toBe(4000)
|
||||||
|
|
||||||
|
expect(getNextTaskOutputPollIntervalMs(intervalMs, false)).toBe(4000)
|
||||||
|
})
|
||||||
@@ -5,9 +5,12 @@ import { readFileRange, tailFile } from '../fsOperations.js'
|
|||||||
import { getMaxOutputLength } from '../shell/outputLimits.js'
|
import { getMaxOutputLength } from '../shell/outputLimits.js'
|
||||||
import { safeJoinLines } from '../stringUtils.js'
|
import { safeJoinLines } from '../stringUtils.js'
|
||||||
import { DiskTaskOutput, getTaskOutputPath } from './diskOutput.js'
|
import { DiskTaskOutput, getTaskOutputPath } from './diskOutput.js'
|
||||||
|
import {
|
||||||
|
ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS,
|
||||||
|
getNextTaskOutputPollIntervalMs,
|
||||||
|
} from './taskOutputPolling.js'
|
||||||
|
|
||||||
const DEFAULT_MAX_MEMORY = 8 * 1024 * 1024 // 8MB
|
const DEFAULT_MAX_MEMORY = 8 * 1024 * 1024 // 8MB
|
||||||
const POLL_INTERVAL_MS = 1000
|
|
||||||
const PROGRESS_TAIL_BYTES = 4096
|
const PROGRESS_TAIL_BYTES = 4096
|
||||||
|
|
||||||
type ProgressCallback = (
|
type ProgressCallback = (
|
||||||
@@ -54,6 +57,8 @@ export class TaskOutput {
|
|||||||
/** Subset of #registry currently being polled (visibility-driven by React). */
|
/** Subset of #registry currently being polled (visibility-driven by React). */
|
||||||
static #activePolling = new Map<string, TaskOutput>()
|
static #activePolling = new Map<string, TaskOutput>()
|
||||||
static #pollInterval: ReturnType<typeof setInterval> | null = null
|
static #pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
static #pollIntervalMs = ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||||
|
static #tickInFlight = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@@ -84,10 +89,7 @@ export class TaskOutput {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
TaskOutput.#activePolling.set(taskId, instance)
|
TaskOutput.#activePolling.set(taskId, instance)
|
||||||
if (!TaskOutput.#pollInterval) {
|
TaskOutput.#setPollInterval(ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS)
|
||||||
TaskOutput.#pollInterval = setInterval(TaskOutput.#tick, POLL_INTERVAL_MS)
|
|
||||||
TaskOutput.#pollInterval.unref()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,20 +101,52 @@ export class TaskOutput {
|
|||||||
if (TaskOutput.#activePolling.size === 0 && TaskOutput.#pollInterval) {
|
if (TaskOutput.#activePolling.size === 0 && TaskOutput.#pollInterval) {
|
||||||
clearInterval(TaskOutput.#pollInterval)
|
clearInterval(TaskOutput.#pollInterval)
|
||||||
TaskOutput.#pollInterval = null
|
TaskOutput.#pollInterval = null
|
||||||
|
TaskOutput.#pollIntervalMs = ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #setPollInterval(intervalMs: number): void {
|
||||||
|
TaskOutput.#pollIntervalMs = intervalMs
|
||||||
|
if (TaskOutput.#pollInterval) {
|
||||||
|
clearInterval(TaskOutput.#pollInterval)
|
||||||
|
TaskOutput.#pollInterval = null
|
||||||
|
}
|
||||||
|
if (TaskOutput.#activePolling.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TaskOutput.#pollInterval = setInterval(TaskOutput.#tick, intervalMs)
|
||||||
|
TaskOutput.#pollInterval.unref()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared tick: reads the file tail for every actively-polled task.
|
* Shared tick: reads the file tail for every actively-polled task.
|
||||||
* Non-async body (.then) to avoid stacking if I/O is slow.
|
* Skip overlapping ticks so slow I/O does not stack more work.
|
||||||
*/
|
*/
|
||||||
static #tick(): void {
|
static #tick(): void {
|
||||||
for (const [, entry] of TaskOutput.#activePolling) {
|
if (TaskOutput.#tickInFlight) {
|
||||||
if (!entry.#onProgress) {
|
return
|
||||||
continue
|
}
|
||||||
}
|
TaskOutput.#tickInFlight = true
|
||||||
void tailFile(entry.path, PROGRESS_TAIL_BYTES).then(
|
void TaskOutput.#runTick().finally(() => {
|
||||||
({ content, bytesRead, bytesTotal }) => {
|
TaskOutput.#tickInFlight = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async #runTick(): Promise<void> {
|
||||||
|
let sawActivity = false
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(TaskOutput.#activePolling.values(), async entry => {
|
||||||
|
if (!entry.#onProgress) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { content, bytesRead, bytesTotal } = await tailFile(
|
||||||
|
entry.path,
|
||||||
|
PROGRESS_TAIL_BYTES,
|
||||||
|
)
|
||||||
|
const bytesChanged = bytesTotal !== entry.#totalBytes
|
||||||
|
sawActivity = sawActivity || bytesChanged
|
||||||
|
|
||||||
if (!entry.#onProgress) {
|
if (!entry.#onProgress) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,6 +154,7 @@ export class TaskOutput {
|
|||||||
// progress loop wakes up and can check for backgrounding.
|
// progress loop wakes up and can check for backgrounding.
|
||||||
// Commands like `git log -S` produce no output for long periods.
|
// Commands like `git log -S` produce no output for long periods.
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
entry.#totalBytes = bytesTotal
|
||||||
entry.#onProgress('', '', entry.#totalLines, bytesTotal, false)
|
entry.#onProgress('', '', entry.#totalLines, bytesTotal, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,11 +190,18 @@ export class TaskOutput {
|
|||||||
bytesTotal,
|
bytesTotal,
|
||||||
bytesRead < bytesTotal,
|
bytesRead < bytesTotal,
|
||||||
)
|
)
|
||||||
},
|
} catch {
|
||||||
() => {
|
|
||||||
// File may not exist yet
|
// File may not exist yet
|
||||||
},
|
}
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextIntervalMs = getNextTaskOutputPollIntervalMs(
|
||||||
|
TaskOutput.#pollIntervalMs,
|
||||||
|
sawActivity,
|
||||||
|
)
|
||||||
|
if (nextIntervalMs !== TaskOutput.#pollIntervalMs) {
|
||||||
|
TaskOutput.#setPollInterval(nextIntervalMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
src/utils/task/taskOutputPolling.ts
Normal file
16
src/utils/task/taskOutputPolling.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS = 250
|
||||||
|
const IDLE_TASK_OUTPUT_POLL_INTERVAL_MS = 1000
|
||||||
|
const MAX_TASK_OUTPUT_POLL_INTERVAL_MS = 4000
|
||||||
|
|
||||||
|
export function getNextTaskOutputPollIntervalMs(
|
||||||
|
currentIntervalMs: number,
|
||||||
|
sawActivity: boolean,
|
||||||
|
): number {
|
||||||
|
if (sawActivity) {
|
||||||
|
return ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||||
|
}
|
||||||
|
if (currentIntervalMs < IDLE_TASK_OUTPUT_POLL_INTERVAL_MS) {
|
||||||
|
return IDLE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||||
|
}
|
||||||
|
return Math.min(MAX_TASK_OUTPUT_POLL_INTERVAL_MS, currentIntervalMs * 2)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user