mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 添加工具类命令(teleport、recap、break-cache、env、tui 等)
- /teleport: 从 claude.ai 恢复会话 - /recap: 生成会话摘要 - /break-cache: 提示缓存管理(once/always/off/status) - /env: 环境信息展示(含密钥脱敏) - /tui: 无闪烁 TUI 模式管理 - /onboarding: 引导流程 - /perf-issue: 性能问题诊断 - /debug-tool-call: 工具调用调试 - /usage: 用量统计(合并 /cost 和 /stats 别名) Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
336
src/commands/break-cache/__tests__/break-cache.test.ts
Normal file
336
src/commands/break-cache/__tests__/break-cache.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
|
||||
// Dynamic envUtils mock — reads CLAUDE_CONFIG_DIR from process.env at call
|
||||
// time so it stays compatible across the full suite when other test files
|
||||
// also drive their own dirs via process.env.
|
||||
mock.module('src/utils/envUtils.js', () => ({
|
||||
getClaudeConfigHomeDir: () =>
|
||||
process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`,
|
||||
isEnvTruthy: (v: unknown) => Boolean(v),
|
||||
getTeamsDir: () =>
|
||||
join(process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`, 'teams'),
|
||||
hasNodeOption: () => false,
|
||||
isEnvDefinedFalsy: () => false,
|
||||
isBareMode: () => false,
|
||||
parseEnvVars: (s: string) => s,
|
||||
getAWSRegion: () => 'us-east-1',
|
||||
getDefaultVertexRegion: () => 'us-central1',
|
||||
shouldMaintainProjectWorkingDir: () => false,
|
||||
}))
|
||||
|
||||
async function invokeBreakCache(
|
||||
args: string,
|
||||
): Promise<{ type: string; value: string }> {
|
||||
const { callBreakCache } = await import('../index.js')
|
||||
return callBreakCache(args) as Promise<{ type: string; value: string }>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'break-cache-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any lingering marker files
|
||||
try {
|
||||
const { getBreakCacheMarkerPath } = require('../index.js')
|
||||
const markerPath = getBreakCacheMarkerPath()
|
||||
if (existsSync(markerPath)) unlinkSync(markerPath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
describe('break-cache command', () => {
|
||||
test('command has correct name and type', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('break-cache')
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
expect(cmd.argumentHint).toContain('status')
|
||||
|
||||
const nonInteractive = mod.breakCacheNonInteractive
|
||||
expect(nonInteractive.name).toBe('break-cache')
|
||||
expect(nonInteractive.type).toBe('local')
|
||||
expect(
|
||||
(nonInteractive as unknown as { supportsNonInteractive: boolean })
|
||||
.supportsNonInteractive,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('interactive and noninteractive entries are mutually gated', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const interactiveEnabled = mod.default.isEnabled?.()
|
||||
const nonInteractiveEnabled = mod.breakCacheNonInteractive.isEnabled?.()
|
||||
|
||||
expect(typeof interactiveEnabled).toBe('boolean')
|
||||
expect(nonInteractiveEnabled).toBe(!interactiveEnabled)
|
||||
})
|
||||
|
||||
test('writes marker file and confirms in message', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheMarkerPath } = mod
|
||||
const result = await invokeBreakCache('')
|
||||
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('Cache break scheduled')
|
||||
expect(result.value).toContain('next API call')
|
||||
}
|
||||
|
||||
// Marker file must exist under CLAUDE_CONFIG_DIR
|
||||
const markerPath = getBreakCacheMarkerPath()
|
||||
expect(markerPath).toContain('.next-request-no-cache')
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
|
||||
// Clean up
|
||||
unlinkSync(markerPath)
|
||||
})
|
||||
|
||||
test('--clear removes an existing marker', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheMarkerPath } = mod
|
||||
|
||||
// Set the marker first
|
||||
await invokeBreakCache('')
|
||||
const markerPath = getBreakCacheMarkerPath()
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
|
||||
// Now clear it
|
||||
const clearResult = await invokeBreakCache('--clear')
|
||||
expect(clearResult.type).toBe('text')
|
||||
if (clearResult.type === 'text') {
|
||||
expect(clearResult.value).toContain('cleared')
|
||||
}
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
})
|
||||
|
||||
test('--clear when no marker returns no-marker message', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheMarkerPath } = mod
|
||||
const markerPath = getBreakCacheMarkerPath()
|
||||
|
||||
// Ensure it does not exist
|
||||
if (existsSync(markerPath)) unlinkSync(markerPath)
|
||||
|
||||
const result = await invokeBreakCache('--clear')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('No cache-break marker')
|
||||
}
|
||||
})
|
||||
|
||||
test('getBreakCacheMarkerPath points inside CLAUDE_CONFIG_DIR', async () => {
|
||||
const { getBreakCacheMarkerPath } = await import('../index.js')
|
||||
const path = getBreakCacheMarkerPath()
|
||||
expect(path).toContain('.next-request-no-cache')
|
||||
// The path should be under claudeDir (CLAUDE_CONFIG_DIR)
|
||||
expect(path.startsWith(claudeDir)).toBe(true)
|
||||
})
|
||||
|
||||
test('"once" scope is same as empty args', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheMarkerPath } = mod
|
||||
const result = await invokeBreakCache('once')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('Cache break scheduled')
|
||||
}
|
||||
const markerPath = getBreakCacheMarkerPath()
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
test('"always" scope writes the always flag', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheAlwaysPath } = mod
|
||||
const result = await invokeBreakCache('always')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('Always-on')
|
||||
}
|
||||
expect(existsSync(getBreakCacheAlwaysPath())).toBe(true)
|
||||
// Clean up
|
||||
unlinkSync(getBreakCacheAlwaysPath())
|
||||
})
|
||||
|
||||
test('"off" scope clears both flags', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheMarkerPath, getBreakCacheAlwaysPath } = mod
|
||||
// Set both markers
|
||||
await invokeBreakCache('')
|
||||
await invokeBreakCache('always')
|
||||
expect(existsSync(getBreakCacheMarkerPath())).toBe(true)
|
||||
expect(existsSync(getBreakCacheAlwaysPath())).toBe(true)
|
||||
// Clear both
|
||||
const result = await invokeBreakCache('off')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('disabled')
|
||||
}
|
||||
expect(existsSync(getBreakCacheMarkerPath())).toBe(false)
|
||||
expect(existsSync(getBreakCacheAlwaysPath())).toBe(false)
|
||||
})
|
||||
|
||||
test('"status" scope shows current state', async () => {
|
||||
const result = await invokeBreakCache('status')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('Break-Cache Status')
|
||||
expect(result.value).toContain('Once marker')
|
||||
expect(result.value).toContain('Always mode')
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown scope returns usage text', async () => {
|
||||
const result = await invokeBreakCache('foobar')
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('Unknown scope')
|
||||
expect(result.value).toContain('Usage')
|
||||
}
|
||||
})
|
||||
|
||||
test('getBreakCacheAlwaysPath and getBreakCacheStatsPath are exported', async () => {
|
||||
const { getBreakCacheAlwaysPath, getBreakCacheStatsPath } = await import(
|
||||
'../index.js'
|
||||
)
|
||||
expect(typeof getBreakCacheAlwaysPath()).toBe('string')
|
||||
expect(typeof getBreakCacheStatsPath()).toBe('string')
|
||||
expect(getBreakCacheAlwaysPath()).toContain('.break-cache-always')
|
||||
// File was renamed to append-only JSONL (H3 fix: atomic append prevents RMW race)
|
||||
expect(getBreakCacheStatsPath()).toContain('break-cache-events.jsonl')
|
||||
})
|
||||
|
||||
// ── H3 regression: append-only stats log accumulates correctly ──
|
||||
test('H3: each /break-cache once appends one event; totalBreaks reflects all calls', async () => {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const mod = await import('../index.js')
|
||||
const { getBreakCacheStatsPath } = mod
|
||||
|
||||
// Call /break-cache once, twice
|
||||
await invokeBreakCache('once')
|
||||
await invokeBreakCache('once')
|
||||
await invokeBreakCache('once')
|
||||
|
||||
// Stats path should be a JSONL file with 3 'once' events
|
||||
const statsPath = getBreakCacheStatsPath()
|
||||
const lines = readFileSync(statsPath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
const events = lines.map(l => JSON.parse(l) as { kind: string })
|
||||
const onceEvents = events.filter(e => e.kind === 'once')
|
||||
expect(onceEvents.length).toBe(3)
|
||||
|
||||
// The status command should report totalBreaks = 3
|
||||
const statusResult = await invokeBreakCache('status')
|
||||
if (statusResult.type === 'text') {
|
||||
expect(statusResult.value).toContain('total_breaks: 3')
|
||||
}
|
||||
})
|
||||
|
||||
test('local-jsx no args renders action panel without completing', async () => {
|
||||
const { call } = await import('../panel.js')
|
||||
const messages: string[] = []
|
||||
|
||||
const node = await call(
|
||||
msg => {
|
||||
if (msg) messages.push(msg)
|
||||
},
|
||||
{} as never,
|
||||
'',
|
||||
)
|
||||
|
||||
expect(node).not.toBeNull()
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('local-jsx explicit args completes through onDone', async () => {
|
||||
const { call } = await import('../panel.js')
|
||||
const messages: string[] = []
|
||||
|
||||
const node = await call(
|
||||
msg => {
|
||||
if (msg) messages.push(msg)
|
||||
},
|
||||
{} as never,
|
||||
'status',
|
||||
)
|
||||
|
||||
expect(node).toBeNull()
|
||||
expect(messages.join('\n')).toContain('Break-Cache Status')
|
||||
})
|
||||
|
||||
test('readEvents skips malformed JSON lines (catch branch)', async () => {
|
||||
const { getBreakCacheStatsPath } = await import('../index.js')
|
||||
const statsPath = getBreakCacheStatsPath()
|
||||
mkdirSync(join(statsPath, '..'), { recursive: true })
|
||||
writeFileSync(
|
||||
statsPath,
|
||||
[
|
||||
'{not valid json',
|
||||
JSON.stringify({ kind: 'once', timestamp: Date.now() }),
|
||||
'',
|
||||
'{"truncated":',
|
||||
].join('\n') + '\n',
|
||||
)
|
||||
// Status read uses readEvents internally → exercises the JSON.parse catch.
|
||||
const result = await invokeBreakCache('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Break-Cache Status')
|
||||
})
|
||||
|
||||
test('breakCache (interactive): getBridgeInvocationError requires arg', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
const fn = (
|
||||
cmd as unknown as {
|
||||
getBridgeInvocationError?: (args: string) => string | undefined
|
||||
}
|
||||
).getBridgeInvocationError
|
||||
expect(typeof fn).toBe('function')
|
||||
if (fn) {
|
||||
expect(fn('')).toContain('Remote Control')
|
||||
expect(fn(' ')).toContain('Remote Control')
|
||||
expect(fn('once')).toBeUndefined()
|
||||
expect(fn('status')).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('breakCacheNonInteractive: load() returns call function', async () => {
|
||||
const { breakCacheNonInteractive } = await import('../index.js')
|
||||
expect(breakCacheNonInteractive.type).toBe('local')
|
||||
const loaded = await (
|
||||
breakCacheNonInteractive as unknown as {
|
||||
load: () => Promise<{ call: unknown }>
|
||||
}
|
||||
).load()
|
||||
expect(typeof loaded.call).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
275
src/commands/break-cache/index.ts
Normal file
275
src/commands/break-cache/index.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import type { Command, LocalCommandResult } from '../../types/command.js'
|
||||
|
||||
/**
|
||||
* Path to the next-request-no-cache marker file.
|
||||
* When this file exists, the main API call path should append a random
|
||||
* comment to the system prompt to bust the prefix-cache hash, then delete it.
|
||||
*
|
||||
* Convention: public so other modules (e.g. claude.ts) can check it.
|
||||
*/
|
||||
export function getBreakCacheMarkerPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), '.next-request-no-cache')
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the always-on break-cache flag file.
|
||||
* When this file exists, EVERY API request gets a cache-busting nonce
|
||||
* (instead of just the next one).
|
||||
*/
|
||||
export function getBreakCacheAlwaysPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), '.break-cache-always')
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the append-only JSONL log that records each cache-break event.
|
||||
*
|
||||
* Replaces the old read-modify-write stats JSON to avoid lost increments when
|
||||
* two concurrent `/break-cache once` invocations race. Each break appends one
|
||||
* line; `readStats()` aggregates at read time.
|
||||
*
|
||||
* Uses getClaudeConfigHomeDir() so that CLAUDE_CONFIG_DIR env var overrides
|
||||
* the path in test environments.
|
||||
*/
|
||||
export function getBreakCacheStatsPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'break-cache-events.jsonl')
|
||||
}
|
||||
|
||||
interface BreakCacheStats {
|
||||
totalBreaks: number
|
||||
lastBreakAt: string | null
|
||||
alwaysModeEnabled: boolean
|
||||
}
|
||||
|
||||
interface BreakCacheEvent {
|
||||
at: string
|
||||
kind: 'once' | 'always_on' | 'always_off'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads stats by aggregating the append-only event log.
|
||||
* Because we only append, concurrent writers cannot lose increments.
|
||||
*/
|
||||
function readStats(): BreakCacheStats {
|
||||
try {
|
||||
const raw = readFileSync(getBreakCacheStatsPath(), 'utf8')
|
||||
const events = raw
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
try {
|
||||
return JSON.parse(line) as BreakCacheEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((e): e is BreakCacheEvent => e !== null)
|
||||
|
||||
const onceBreaks = events.filter(e => e.kind === 'once')
|
||||
const lastEvent = events[events.length - 1]
|
||||
const alwaysEvents = events.filter(
|
||||
e => e.kind === 'always_on' || e.kind === 'always_off',
|
||||
)
|
||||
const lastAlways = alwaysEvents[alwaysEvents.length - 1]
|
||||
|
||||
return {
|
||||
totalBreaks: onceBreaks.length,
|
||||
lastBreakAt: lastEvent?.at ?? null,
|
||||
alwaysModeEnabled: lastAlways?.kind === 'always_on',
|
||||
}
|
||||
} catch {
|
||||
return { totalBreaks: 0, lastBreakAt: null, alwaysModeEnabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a single event line to the stats log.
|
||||
* append is atomic at the OS level for small writes, so concurrent callers
|
||||
* cannot overwrite each other's increments.
|
||||
*/
|
||||
function appendBreakEvent(kind: BreakCacheEvent['kind']): void {
|
||||
const statsPath = getBreakCacheStatsPath()
|
||||
mkdirSync(getClaudeConfigHomeDir(), { recursive: true })
|
||||
const event: BreakCacheEvent = { at: new Date().toISOString(), kind }
|
||||
appendFileSync(statsPath, JSON.stringify(event) + '\n', 'utf8')
|
||||
}
|
||||
|
||||
function incrementBreakCount(): void {
|
||||
appendBreakEvent('once')
|
||||
}
|
||||
|
||||
const USAGE_TEXT = [
|
||||
'Usage: /break-cache [scope]',
|
||||
'',
|
||||
' (no args) Schedule a one-time cache break for the next API call',
|
||||
' once Same as no args',
|
||||
' always Enable persistent cache-break mode (every request)',
|
||||
' off Disable always mode and clear any pending marker',
|
||||
' --clear Clear the pending once marker (cancel before next call)',
|
||||
' status Show current break-cache status and stats',
|
||||
'',
|
||||
'How it works:',
|
||||
' The Anthropic prompt cache keys on the system-prompt prefix hash.',
|
||||
' A unique nonce invalidates the hash, forcing a fresh compute.',
|
||||
' This is useful when you want to ensure a clean context window.',
|
||||
].join('\n')
|
||||
|
||||
export async function callBreakCache(
|
||||
args: string,
|
||||
): Promise<LocalCommandResult> {
|
||||
const scope = args.trim().toLowerCase()
|
||||
const markerPath = getBreakCacheMarkerPath()
|
||||
const alwaysPath = getBreakCacheAlwaysPath()
|
||||
|
||||
// ── status ──
|
||||
if (scope === 'status') {
|
||||
const stats = readStats()
|
||||
const onceActive = existsSync(markerPath)
|
||||
const alwaysActive = existsSync(alwaysPath)
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## Break-Cache Status',
|
||||
'',
|
||||
` Once marker: ${onceActive ? 'ACTIVE (next call will bust cache)' : 'not set'}`,
|
||||
` Always mode: ${alwaysActive ? 'ON (every call busts cache)' : 'off'}`,
|
||||
'',
|
||||
'## Stats',
|
||||
` total_breaks: ${stats.totalBreaks}`,
|
||||
` last_break_at: ${stats.lastBreakAt ?? 'never'}`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── off ──
|
||||
if (scope === 'off') {
|
||||
let cleared = false
|
||||
if (existsSync(markerPath)) {
|
||||
unlinkSync(markerPath)
|
||||
cleared = true
|
||||
}
|
||||
if (existsSync(alwaysPath)) {
|
||||
unlinkSync(alwaysPath)
|
||||
cleared = true
|
||||
}
|
||||
appendBreakEvent('always_off')
|
||||
return {
|
||||
type: 'text',
|
||||
value: cleared
|
||||
? 'Break-cache disabled. Removed once marker and/or always flag.'
|
||||
: 'Break-cache was not active.',
|
||||
}
|
||||
}
|
||||
|
||||
// ── --clear ──
|
||||
if (scope === '--clear') {
|
||||
if (existsSync(markerPath)) {
|
||||
unlinkSync(markerPath)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Cache-break marker cleared.\n \`${markerPath}\``,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'No cache-break marker was set.',
|
||||
}
|
||||
}
|
||||
|
||||
// ── always ──
|
||||
if (scope === 'always') {
|
||||
writeFileSync(alwaysPath, new Date().toISOString(), 'utf8')
|
||||
appendBreakEvent('always_on')
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## Always-on cache break enabled',
|
||||
'',
|
||||
`Flag written: \`${alwaysPath}\``,
|
||||
'',
|
||||
'Every API call will now append a random nonce to the system prompt,',
|
||||
'permanently preventing prompt-cache hits for this session.',
|
||||
'',
|
||||
'To disable: `/break-cache off`',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── once (legacy default, or explicit "once") ──
|
||||
if (scope === '' || scope === 'once') {
|
||||
const timestamp = new Date().toISOString()
|
||||
writeFileSync(markerPath, timestamp, 'utf8')
|
||||
incrementBreakCount()
|
||||
const stats = readStats()
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## Cache break scheduled',
|
||||
'',
|
||||
`Marker written: \`${markerPath}\``,
|
||||
`Timestamp: ${timestamp}`,
|
||||
'',
|
||||
'The next API call will append a random nonce to the system prompt,',
|
||||
'causing a cache miss. The marker is removed automatically after use.',
|
||||
'',
|
||||
'To cancel before the next call: `/break-cache --clear`',
|
||||
'For every call: `/break-cache always`',
|
||||
'',
|
||||
`Total breaks this session: ${stats.totalBreaks}`,
|
||||
'',
|
||||
'_How it works: Anthropic prompt cache keys on the system-prompt prefix hash._',
|
||||
'_A unique nonce invalidates the hash, forcing a fresh compute._',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── unknown scope ──
|
||||
return {
|
||||
type: 'text',
|
||||
value: [`Unknown scope: "${scope}"`, '', USAGE_TEXT].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
const breakCache: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'break-cache',
|
||||
description:
|
||||
'Manage prompt-cache breaking. Open actions or run: once, status, always, off',
|
||||
isHidden: false,
|
||||
isEnabled: () => !getIsNonInteractiveSession(),
|
||||
argumentHint: '[once|status|always|off|--clear]',
|
||||
bridgeSafe: true,
|
||||
getBridgeInvocationError: args =>
|
||||
args.trim()
|
||||
? undefined
|
||||
: 'Use /break-cache once/status/always/off over Remote Control.',
|
||||
load: () => import('./panel.js'),
|
||||
}
|
||||
|
||||
export const breakCacheNonInteractive: Command = {
|
||||
type: 'local',
|
||||
name: 'break-cache',
|
||||
description:
|
||||
'Force the next (or all) API call(s) to miss prompt cache. Scopes: once, status, always, off',
|
||||
isHidden: false,
|
||||
isEnabled: () => getIsNonInteractiveSession(),
|
||||
supportsNonInteractive: true,
|
||||
bridgeSafe: true,
|
||||
load: async () => ({
|
||||
call: callBreakCache,
|
||||
}),
|
||||
}
|
||||
|
||||
export default breakCache
|
||||
105
src/commands/break-cache/panel.tsx
Normal file
105
src/commands/break-cache/panel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Box, Dialog, Text, useInput } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { callBreakCache } from './index.js';
|
||||
|
||||
type BreakCacheAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
const ACTION_LABEL_COLUMN_WIDTH = 28;
|
||||
|
||||
async function runBreakCacheAction(scope: string, onDone: LocalJSXCommandOnDone): Promise<void> {
|
||||
const result = await callBreakCache(scope);
|
||||
if (result.type === 'text') {
|
||||
onDone(result.value, { display: 'system' });
|
||||
}
|
||||
}
|
||||
|
||||
function BreakCachePanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const actions = useMemo<BreakCacheAction[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Status',
|
||||
description: 'Show pending marker, always mode, and break count',
|
||||
run: () => void runBreakCacheAction('status', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Once',
|
||||
description: 'Break prompt cache on the next API call only',
|
||||
run: () => void runBreakCacheAction('once', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Always',
|
||||
description: 'Break prompt cache on every API call',
|
||||
run: () => void runBreakCacheAction('always', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Off',
|
||||
description: 'Disable always mode and clear pending once marker',
|
||||
run: () => void runBreakCacheAction('off', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Clear Once',
|
||||
description: 'Cancel the pending one-time cache break',
|
||||
run: () => void runBreakCacheAction('--clear', onDone),
|
||||
},
|
||||
],
|
||||
[onDone],
|
||||
);
|
||||
|
||||
const selectCurrent = () => {
|
||||
const action = actions[selectedIndex];
|
||||
if (!action) return;
|
||||
action.run();
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(index => Math.max(0, index - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
selectCurrent();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Break Cache"
|
||||
subtitle={`${actions.length} actions`}
|
||||
onCancel={() => onDone('Break-cache panel dismissed', { display: 'system' })}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{actions.map((action, index) => (
|
||||
<Box key={action.label} flexDirection="row">
|
||||
<Text>{`${index === selectedIndex ? '›' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
|
||||
<Text dimColor>{action.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑/↓ select · Enter run · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
const trimmed = args?.trim() ?? '';
|
||||
if (trimmed) {
|
||||
await runBreakCacheAction(trimmed, onDone);
|
||||
return null;
|
||||
}
|
||||
return <BreakCachePanel onDone={onDone} />;
|
||||
}
|
||||
Reference in New Issue
Block a user