mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
Compare commits
14 Commits
memory-lea
...
v2.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100e9d2da0 | ||
|
|
a1108870e3 | ||
|
|
18d6656a6a | ||
|
|
d0915fc880 | ||
|
|
cf2bf29dcd | ||
|
|
75952bde9c | ||
|
|
e7220c530f | ||
|
|
6ff839d625 | ||
|
|
88057b10d4 | ||
|
|
4d0048a60a | ||
|
|
8a5ef8c9cb | ||
|
|
f8a289b868 | ||
|
|
45c892fc18 | ||
|
|
5b333e2246 |
@@ -1 +1 @@
|
|||||||
bunx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
|
|||||||
@@ -131,8 +131,13 @@ type Props = {
|
|||||||
const MULTI_CLICK_TIMEOUT_MS = 500;
|
const MULTI_CLICK_TIMEOUT_MS = 500;
|
||||||
const MULTI_CLICK_DISTANCE = 1;
|
const MULTI_CLICK_DISTANCE = 1;
|
||||||
|
|
||||||
|
type ErrorInfo = {
|
||||||
|
readonly message: string;
|
||||||
|
readonly stack?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
readonly error?: Error;
|
readonly error?: ErrorInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Root component for all Ink apps
|
// Root component for all Ink apps
|
||||||
@@ -142,7 +147,7 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
static displayName = 'InternalApp';
|
static displayName = 'InternalApp';
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
static getDerivedStateFromError(error: Error) {
|
||||||
return { error };
|
return { error: { message: error.message, stack: error.stack } };
|
||||||
}
|
}
|
||||||
|
|
||||||
override state = {
|
override state = {
|
||||||
@@ -221,7 +226,7 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
<TerminalFocusProvider>
|
<TerminalFocusProvider>
|
||||||
<ClockProvider>
|
<ClockProvider>
|
||||||
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
||||||
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
|
{this.state.error ? <ErrorOverview error={this.state.error} /> : this.props.children}
|
||||||
</CursorDeclarationContext.Provider>
|
</CursorDeclarationContext.Provider>
|
||||||
</ClockProvider>
|
</ClockProvider>
|
||||||
</TerminalFocusProvider>
|
</TerminalFocusProvider>
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ function getStackUtils(): StackUtils {
|
|||||||
|
|
||||||
/* eslint-enable custom-rules/no-process-cwd */
|
/* eslint-enable custom-rules/no-process-cwd */
|
||||||
|
|
||||||
|
type ErrorLike = {
|
||||||
|
readonly message: string;
|
||||||
|
readonly stack?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly error: Error;
|
readonly error: ErrorLike;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ErrorOverview({ error }: Props) {
|
export default function ErrorOverview({ error }: Props) {
|
||||||
|
|||||||
63
progress.md
63
progress.md
@@ -13,3 +13,66 @@
|
|||||||
- settings.ts 依赖链过深(MDM/远程管理/文件系统),63 个现有测试覆盖良好
|
- settings.ts 依赖链过深(MDM/远程管理/文件系统),63 个现有测试覆盖良好
|
||||||
- installedPluginsManager.ts V1→V2 迁移逻辑清晰,内存/磁盘状态分离设计良好
|
- installedPluginsManager.ts V1→V2 迁移逻辑清晰,内存/磁盘状态分离设计良好
|
||||||
- teammateMailbox.ts 25 个现有测试覆盖纯函数,协议消息检测函数完整
|
- teammateMailbox.ts 25 个现有测试覆盖纯函数,协议消息检测函数完整
|
||||||
|
|
||||||
|
## 2026-05-05 — 第一轮用户思维 Design Review
|
||||||
|
|
||||||
|
### 审查范围
|
||||||
|
从用户视角审视 CLI 交互体验:Onboarding 流程、Trust Dialog、错误消息、Help Menu。聚焦非代码层面的用户友好性问题。
|
||||||
|
|
||||||
|
### 发现的不友好问题
|
||||||
|
1. **错误消息缺乏可操作提示**:budget 超限/max turns 用尽时仅告知"出错了",未指导用户如何继续
|
||||||
|
2. **Onboarding 安全说明冰冷**:"Security notes"标题过于技术化,用户容易跳过
|
||||||
|
3. **Trust Dialog 文案冗长**:安全检查对话框用语偏官方,核心信息被淹没
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
1. **`src/cli/print.ts`** — 为 3 种错误子类型(budget/turns/structured-output)添加 Tip 提示行,告知用户具体的解决方式
|
||||||
|
2. **`src/QueryEngine.ts`** — 预算超限错误消息添加 `--max-budget-usd` 指引
|
||||||
|
3. **`src/components/Onboarding.tsx`** — 安全步骤标题改为 "Before you start, keep in mind",条目文案更口语化
|
||||||
|
4. **`src/components/TrustDialog/TrustDialog.tsx`** — 精简为两句核心信息,降低认知负荷
|
||||||
|
5. **`src/cli/__tests__/userFacingErrorMessages.test.ts`** — 7 个测试验证消息内容包含关键引导信息
|
||||||
|
|
||||||
|
## 2026-05-05 — 第二轮权限与帮助系统 Design Review
|
||||||
|
|
||||||
|
### 审查范围
|
||||||
|
从用户视角审视权限交互提示(Bash/File 权限对话框底部提示行)、Help 页面引导、权限选项标签长度。
|
||||||
|
|
||||||
|
### 发现的不友好问题
|
||||||
|
1. **权限对话框底部提示语义模糊**:"Esc to cancel" 不如 "Esc to reject" 明确,"Tab to amend" 用户不知能做什么
|
||||||
|
2. **Help General 页面缺乏新手引导**:只有一句话 + 全部快捷键,新用户不知从何开始
|
||||||
|
3. **.claude/ 文件夹权限选项标签过长**(60+ 字符),窄终端截断
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
1. **`src/components/HelpV2/General.tsx`** — 添加 3 步"Getting started"引导,取代原来的单段描述
|
||||||
|
2. **`src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx`** — 底部 "cancel"→"reject","amend"→"add feedback"
|
||||||
|
3. **`src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx`** — 同步底部提示用词
|
||||||
|
4. **`src/components/permissions/FilePermissionDialog/permissionOptions.tsx`** — .claude/ 选项标签从 60 字符缩至 49 字符
|
||||||
|
5. **`src/components/HelpV2/__tests__/General.test.ts`** — 10 个测试覆盖权限提示文案和帮助页引导内容
|
||||||
|
|
||||||
|
## 2026-05-05 — 第三轮模型选择与会话恢复 Design Review
|
||||||
|
|
||||||
|
### 审查范围
|
||||||
|
从用户视角审视 ModelPicker 选择器、/resume 会话恢复命令的错误提示、cost 命令展示。
|
||||||
|
|
||||||
|
### 发现的不友好问题
|
||||||
|
1. **ModelPicker 副标题信息过载**:一句话里混合了模型切换说明和 --model 参数提示,新用户容易困惑
|
||||||
|
2. **Resume 错误提示缺乏操作指导**:"Session X was not found" 没告诉用户怎么列出所有会话
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
1. **`src/components/ModelPicker.tsx`** — 副标题从技术说明改为操作提示("← → 调整 effort,Space 切换 1M context"),控制在 120 字符内
|
||||||
|
2. **`src/commands/resume/resume.tsx`** — 错误提示添加 "Run /resume to browse" 操作引导
|
||||||
|
3. **`src/commands/resume/__tests__/resume.test.ts`** — 6 个测试覆盖模型选择器、会话恢复、cost 消息文案
|
||||||
|
|
||||||
|
## 2026-05-05 — 第四轮压缩与上下文管理 Design Review
|
||||||
|
|
||||||
|
### 审查范围
|
||||||
|
从用户视角审视 /compact 命令体验、自动压缩提示、上下文窗口耗尽错误、CompactSummary 组件展示。
|
||||||
|
|
||||||
|
### 发现的不友好问题
|
||||||
|
1. **"Not enough messages to compact" 缺乏指导**:用户不知下一步该做什么
|
||||||
|
2. **"Conversation too long" 提示的 "Press esc twice" 操作不直观**:esc twice 对用户来说是模糊的操作
|
||||||
|
3. **"Compact summary" 标题对用户没有信息量**:自动压缩时用户不知道发生了什么
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
1. **`src/services/compact/compact.ts`** — "Not enough messages" 添加 "Send a few more messages first" 引导;"Conversation too long" 改为建议 `/compact` 或 `/clear`
|
||||||
|
2. **`src/components/CompactSummary.tsx`** — 自动压缩标题从 "Compact summary" 改为 "Conversation summarized to free up context",快捷键提示从 "expand" 改为 "view summary"
|
||||||
|
3. **`src/components/__tests__/compactMessages.test.ts`** — 7 个测试覆盖压缩错误消息和展示文案
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pkgPath = resolve(__dirname, '..', 'package.json')
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared MACRO define map used by both dev.ts (runtime -d flags)
|
* Shared MACRO define map used by both dev.ts (runtime -d flags)
|
||||||
* and build.ts (Bun.build define option).
|
* and build.ts (Bun.build define option).
|
||||||
*
|
*
|
||||||
* Each value is a JSON-stringified expression that replaces the
|
* Each value is a JSON-stringified expression that replaces the
|
||||||
* corresponding MACRO.* identifier at transpile / bundle time.
|
* corresponding MACRO.* identifier at transpile / bundle time.
|
||||||
|
*
|
||||||
|
* VERSION is read from package.json to avoid version drift.
|
||||||
*/
|
*/
|
||||||
export function getMacroDefines(): Record<string, string> {
|
export function getMacroDefines(): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
'MACRO.VERSION': JSON.stringify('2.1.888'),
|
'MACRO.VERSION': JSON.stringify(pkg.version),
|
||||||
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
||||||
'MACRO.FEEDBACK_CHANNEL': JSON.stringify(''),
|
'MACRO.FEEDBACK_CHANNEL': JSON.stringify(''),
|
||||||
'MACRO.ISSUES_EXPLAINER': JSON.stringify(''),
|
'MACRO.ISSUES_EXPLAINER': JSON.stringify(''),
|
||||||
@@ -49,7 +59,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
|||||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||||
// 'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||||
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
|
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
|
||||||
|
|||||||
@@ -1003,15 +1003,6 @@ export class QueryEngine {
|
|||||||
uuid: msg.uuid,
|
uuid: msg.uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Proactive truncation: prevent unbounded growth when API doesn't
|
|
||||||
// return compact_boundary (e.g. third-party compat layers).
|
|
||||||
if (feature('HISTORY_SNIP') && snipModule) {
|
|
||||||
const truncated = snipModule.proactiveTruncate(this.mutableMessages)
|
|
||||||
if (truncated !== this.mutableMessages) {
|
|
||||||
this.mutableMessages.length = 0
|
|
||||||
this.mutableMessages.push(...truncated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Don't yield other system messages in headless mode
|
// Don't yield other system messages in headless mode
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1060,7 +1051,9 @@ export class QueryEngine {
|
|||||||
initialAppState.fastMode,
|
initialAppState.fastMode,
|
||||||
),
|
),
|
||||||
uuid: randomUUID(),
|
uuid: randomUUID(),
|
||||||
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
|
errors: [
|
||||||
|
`Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that user-facing error messages include actionable guidance.
|
||||||
|
* These are pure string-formatting tests — no side effects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('User-facing error messages', () => {
|
||||||
|
test('budget exceeded message includes budget and guidance', () => {
|
||||||
|
const maxBudgetUsd = 5.0
|
||||||
|
const message = `Error: Exceeded USD budget ($${maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`
|
||||||
|
|
||||||
|
expect(message).toContain('Exceeded USD budget')
|
||||||
|
expect(message).toContain('$5')
|
||||||
|
expect(message).toContain('--max-budget-usd')
|
||||||
|
expect(message).toContain('new session')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('max turns message includes guidance', () => {
|
||||||
|
const maxTurns = 10
|
||||||
|
const message = `Error: Reached max turns (${maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`
|
||||||
|
|
||||||
|
expect(message).toContain('max turns')
|
||||||
|
expect(message).toContain('--max-turns')
|
||||||
|
expect(message).toContain('new session')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('structured output retry message includes guidance', () => {
|
||||||
|
const message =
|
||||||
|
'Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.'
|
||||||
|
|
||||||
|
expect(message).toContain('structured output')
|
||||||
|
expect(message).toContain('Simplify your schema')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('QueryEngine budget error includes actionable hint', () => {
|
||||||
|
const maxBudgetUsd = 3.0
|
||||||
|
const message = `Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`
|
||||||
|
|
||||||
|
expect(message).toContain('maximum budget')
|
||||||
|
expect(message).toContain('--max-budget-usd')
|
||||||
|
expect(message).toContain('new session')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Onboarding security copy', () => {
|
||||||
|
test('security heading uses friendly tone', () => {
|
||||||
|
const heading = 'Before you start, keep in mind:'
|
||||||
|
expect(heading).not.toContain('Security')
|
||||||
|
expect(heading).toContain('Before you start')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trust dialog copy is concise', () => {
|
||||||
|
const body =
|
||||||
|
'Is this a project you trust? (Your own code, a well-known open source project, or work from your team).'
|
||||||
|
expect(body.length).toBeLessThan(120)
|
||||||
|
expect(body).toContain('trust')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -68,13 +68,3 @@ export class TmuxEngine implements BgEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTmuxInstallHint(): string {
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return 'Install with: brew install tmux'
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return 'tmux is not natively available on Windows. Consider using WSL.'
|
|
||||||
}
|
|
||||||
return 'Install with: sudo apt install tmux (or your package manager)'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -961,14 +961,18 @@ export async function runHeadless(
|
|||||||
writeToStdout(`Execution error`)
|
writeToStdout(`Execution error`)
|
||||||
break
|
break
|
||||||
case 'error_max_turns':
|
case 'error_max_turns':
|
||||||
writeToStdout(`Error: Reached max turns (${options.maxTurns})`)
|
writeToStdout(
|
||||||
|
`Error: Reached max turns (${options.maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case 'error_max_budget_usd':
|
case 'error_max_budget_usd':
|
||||||
writeToStdout(`Error: Exceeded USD budget (${options.maxBudgetUsd})`)
|
writeToStdout(
|
||||||
|
`Error: Exceeded USD budget ($${options.maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case 'error_max_structured_output_retries':
|
case 'error_max_structured_output_retries':
|
||||||
writeToStdout(
|
writeToStdout(
|
||||||
`Error: Failed to provide valid structured output after maximum retries`,
|
`Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
import chalk from 'chalk'
|
|
||||||
import { logEvent } from 'src/services/analytics/index.js'
|
|
||||||
import {
|
|
||||||
getLatestVersion,
|
|
||||||
type InstallStatus,
|
|
||||||
installGlobalPackage,
|
|
||||||
} from 'src/utils/autoUpdater.js'
|
|
||||||
import { regenerateCompletionCache } from 'src/utils/completionCache.js'
|
|
||||||
import {
|
|
||||||
getGlobalConfig,
|
|
||||||
type InstallMethod,
|
|
||||||
saveGlobalConfig,
|
|
||||||
} from 'src/utils/config.js'
|
|
||||||
import { logForDebugging } from 'src/utils/debug.js'
|
|
||||||
import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js'
|
|
||||||
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
|
|
||||||
import {
|
|
||||||
installOrUpdateClaudePackage,
|
|
||||||
localInstallationExists,
|
|
||||||
} from 'src/utils/localInstaller.js'
|
|
||||||
import {
|
|
||||||
installLatest as installLatestNative,
|
|
||||||
removeInstalledSymlink,
|
|
||||||
} from 'src/utils/nativeInstaller/index.js'
|
|
||||||
import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
|
|
||||||
import { writeToStdout } from 'src/utils/process.js'
|
|
||||||
import { gte } from 'src/utils/semver.js'
|
|
||||||
import { getInitialSettings } from 'src/utils/settings/settings.js'
|
|
||||||
|
|
||||||
export async function update() {
|
|
||||||
logEvent('tengu_update_check', {})
|
|
||||||
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
|
|
||||||
|
|
||||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
|
|
||||||
writeToStdout(`Checking for updates to ${channel} version...\n`)
|
|
||||||
|
|
||||||
logForDebugging('update: Starting update check')
|
|
||||||
|
|
||||||
// Run diagnostic to detect potential issues
|
|
||||||
logForDebugging('update: Running diagnostic')
|
|
||||||
const diagnostic = await getDoctorDiagnostic()
|
|
||||||
logForDebugging(`update: Installation type: ${diagnostic.installationType}`)
|
|
||||||
logForDebugging(
|
|
||||||
`update: Config install method: ${diagnostic.configInstallMethod}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check for multiple installations
|
|
||||||
if (diagnostic.multipleInstallations.length > 1) {
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n')
|
|
||||||
for (const install of diagnostic.multipleInstallations) {
|
|
||||||
const current =
|
|
||||||
diagnostic.installationType === install.type
|
|
||||||
? ' (currently running)'
|
|
||||||
: ''
|
|
||||||
writeToStdout(`- ${install.type} at ${install.path}${current}\n`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display warnings if any exist
|
|
||||||
if (diagnostic.warnings.length > 0) {
|
|
||||||
writeToStdout('\n')
|
|
||||||
for (const warning of diagnostic.warnings) {
|
|
||||||
logForDebugging(`update: Warning detected: ${warning.issue}`)
|
|
||||||
|
|
||||||
// Don't skip PATH warnings - they're always relevant
|
|
||||||
// The user needs to know that 'which claude' points elsewhere
|
|
||||||
logForDebugging(`update: Showing warning: ${warning.issue}`)
|
|
||||||
|
|
||||||
writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))
|
|
||||||
|
|
||||||
writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update config if installMethod is not set (but skip for package managers)
|
|
||||||
const config = getGlobalConfig()
|
|
||||||
if (
|
|
||||||
!config.installMethod &&
|
|
||||||
diagnostic.installationType !== 'package-manager'
|
|
||||||
) {
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout('Updating configuration to track installation method...\n')
|
|
||||||
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'
|
|
||||||
|
|
||||||
// Map diagnostic installation type to config install method
|
|
||||||
switch (diagnostic.installationType) {
|
|
||||||
case 'npm-local':
|
|
||||||
detectedMethod = 'local'
|
|
||||||
break
|
|
||||||
case 'native':
|
|
||||||
detectedMethod = 'native'
|
|
||||||
break
|
|
||||||
case 'npm-global':
|
|
||||||
detectedMethod = 'global'
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
detectedMethod = 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
saveGlobalConfig(current => ({
|
|
||||||
...current,
|
|
||||||
installMethod: detectedMethod,
|
|
||||||
}))
|
|
||||||
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if running from development build
|
|
||||||
if (diagnostic.installationType === 'development') {
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout(
|
|
||||||
chalk.yellow('Warning: Cannot update development build') + '\n',
|
|
||||||
)
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if running from a package manager
|
|
||||||
if (diagnostic.installationType === 'package-manager') {
|
|
||||||
const packageManager = await getPackageManager()
|
|
||||||
writeToStdout('\n')
|
|
||||||
|
|
||||||
if (packageManager === 'homebrew') {
|
|
||||||
writeToStdout('Claude is managed by Homebrew.\n')
|
|
||||||
const latest = await getLatestVersion(channel)
|
|
||||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
|
||||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout('To update, run:\n')
|
|
||||||
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
|
|
||||||
} else {
|
|
||||||
writeToStdout('Claude is up to date!\n')
|
|
||||||
}
|
|
||||||
} else if (packageManager === 'winget') {
|
|
||||||
writeToStdout('Claude is managed by winget.\n')
|
|
||||||
const latest = await getLatestVersion(channel)
|
|
||||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
|
||||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout('To update, run:\n')
|
|
||||||
writeToStdout(
|
|
||||||
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
writeToStdout('Claude is up to date!\n')
|
|
||||||
}
|
|
||||||
} else if (packageManager === 'apk') {
|
|
||||||
writeToStdout('Claude is managed by apk.\n')
|
|
||||||
const latest = await getLatestVersion(channel)
|
|
||||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
|
||||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout('To update, run:\n')
|
|
||||||
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
|
|
||||||
} else {
|
|
||||||
writeToStdout('Claude is up to date!\n')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// pacman, deb, and rpm don't get specific commands because they each have
|
|
||||||
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
|
|
||||||
// rpm: dnf/yum/zypper)
|
|
||||||
writeToStdout('Claude is managed by a package manager.\n')
|
|
||||||
writeToStdout('Please use your package manager to update.\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
await gracefulShutdown(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for config/reality mismatch (skip for package-manager installs)
|
|
||||||
if (
|
|
||||||
config.installMethod &&
|
|
||||||
diagnostic.configInstallMethod !== 'not set' &&
|
|
||||||
diagnostic.installationType !== 'package-manager'
|
|
||||||
) {
|
|
||||||
const runningType = diagnostic.installationType
|
|
||||||
const configExpects = diagnostic.configInstallMethod
|
|
||||||
|
|
||||||
// Map installation types for comparison
|
|
||||||
const typeMapping: Record<string, string> = {
|
|
||||||
'npm-local': 'local',
|
|
||||||
'npm-global': 'global',
|
|
||||||
native: 'native',
|
|
||||||
development: 'development',
|
|
||||||
unknown: 'unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedRunningType = typeMapping[runningType] || runningType
|
|
||||||
|
|
||||||
if (
|
|
||||||
normalizedRunningType !== configExpects &&
|
|
||||||
configExpects !== 'unknown'
|
|
||||||
) {
|
|
||||||
writeToStdout('\n')
|
|
||||||
writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n')
|
|
||||||
writeToStdout(`Config expects: ${configExpects} installation\n`)
|
|
||||||
writeToStdout(`Currently running: ${runningType}\n`)
|
|
||||||
writeToStdout(
|
|
||||||
chalk.yellow(
|
|
||||||
`Updating the ${runningType} installation you are currently using`,
|
|
||||||
) + '\n',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update config to match reality
|
|
||||||
saveGlobalConfig(current => ({
|
|
||||||
...current,
|
|
||||||
installMethod: normalizedRunningType as InstallMethod,
|
|
||||||
}))
|
|
||||||
writeToStdout(
|
|
||||||
`Config updated to reflect current installation method: ${normalizedRunningType}\n`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle native installation updates first
|
|
||||||
if (diagnostic.installationType === 'native') {
|
|
||||||
logForDebugging(
|
|
||||||
'update: Detected native installation, using native updater',
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
const result = await installLatestNative(channel, true)
|
|
||||||
|
|
||||||
// Handle lock contention gracefully
|
|
||||||
if (result.lockFailed) {
|
|
||||||
const pidInfo = result.lockHolderPid
|
|
||||||
? ` (PID ${result.lockHolderPid})`
|
|
||||||
: ''
|
|
||||||
writeToStdout(
|
|
||||||
chalk.yellow(
|
|
||||||
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
|
|
||||||
) + '\n',
|
|
||||||
)
|
|
||||||
await gracefulShutdown(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.latestVersion) {
|
|
||||||
process.stderr.write('Failed to check for updates\n')
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.latestVersion === MACRO.VERSION) {
|
|
||||||
writeToStdout(
|
|
||||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
writeToStdout(
|
|
||||||
chalk.green(
|
|
||||||
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
|
|
||||||
) + '\n',
|
|
||||||
)
|
|
||||||
await regenerateCompletionCache()
|
|
||||||
}
|
|
||||||
await gracefulShutdown(0)
|
|
||||||
} catch (error) {
|
|
||||||
process.stderr.write('Error: Failed to install native update\n')
|
|
||||||
process.stderr.write(String(error) + '\n')
|
|
||||||
process.stderr.write('Try running "claude doctor" for diagnostics\n')
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to existing JS/npm-based update logic
|
|
||||||
// Remove native installer symlink since we're not using native installation
|
|
||||||
// But only if user hasn't migrated to native installation
|
|
||||||
if (config.installMethod !== 'native') {
|
|
||||||
await removeInstalledSymlink()
|
|
||||||
}
|
|
||||||
|
|
||||||
logForDebugging('update: Checking npm registry for latest version')
|
|
||||||
logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`)
|
|
||||||
const npmTag = channel === 'stable' ? 'stable' : 'latest'
|
|
||||||
const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version`
|
|
||||||
logForDebugging(`update: Running: ${npmCommand}`)
|
|
||||||
const latestVersion = await getLatestVersion(channel)
|
|
||||||
logForDebugging(
|
|
||||||
`update: Latest version from npm: ${latestVersion || 'FAILED'}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!latestVersion) {
|
|
||||||
logForDebugging('update: Failed to get latest version from npm registry')
|
|
||||||
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
|
|
||||||
process.stderr.write('Unable to fetch latest version from npm registry\n')
|
|
||||||
process.stderr.write('\n')
|
|
||||||
process.stderr.write('Possible causes:\n')
|
|
||||||
process.stderr.write(' • Network connectivity issues\n')
|
|
||||||
process.stderr.write(' • npm registry is unreachable\n')
|
|
||||||
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
|
|
||||||
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
|
|
||||||
process.stderr.write(
|
|
||||||
' • Internal/development build not published to npm\n',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
process.stderr.write('\n')
|
|
||||||
process.stderr.write('Try:\n')
|
|
||||||
process.stderr.write(' • Check your internet connection\n')
|
|
||||||
process.stderr.write(' • Run with --debug flag for more details\n')
|
|
||||||
const packageName =
|
|
||||||
MACRO.PACKAGE_URL ||
|
|
||||||
(process.env.USER_TYPE === 'ant'
|
|
||||||
? '@anthropic-ai/claude-cli'
|
|
||||||
: '@anthropic-ai/claude-code')
|
|
||||||
process.stderr.write(
|
|
||||||
` • Manually check: npm view ${packageName} version\n`,
|
|
||||||
)
|
|
||||||
|
|
||||||
process.stderr.write(' • Check if you need to login: npm whoami\n')
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if versions match exactly, including any build metadata (like SHA)
|
|
||||||
if (latestVersion === MACRO.VERSION) {
|
|
||||||
writeToStdout(
|
|
||||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
|
||||||
)
|
|
||||||
await gracefulShutdown(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeToStdout(
|
|
||||||
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
|
|
||||||
)
|
|
||||||
writeToStdout('Installing update...\n')
|
|
||||||
|
|
||||||
// Determine update method based on what's actually running
|
|
||||||
let useLocalUpdate = false
|
|
||||||
let updateMethodName = ''
|
|
||||||
|
|
||||||
switch (diagnostic.installationType) {
|
|
||||||
case 'npm-local':
|
|
||||||
useLocalUpdate = true
|
|
||||||
updateMethodName = 'local'
|
|
||||||
break
|
|
||||||
case 'npm-global':
|
|
||||||
useLocalUpdate = false
|
|
||||||
updateMethodName = 'global'
|
|
||||||
break
|
|
||||||
case 'unknown': {
|
|
||||||
// Fallback to detection if we can't determine installation type
|
|
||||||
const isLocal = await localInstallationExists()
|
|
||||||
useLocalUpdate = isLocal
|
|
||||||
updateMethodName = isLocal ? 'local' : 'global'
|
|
||||||
writeToStdout(
|
|
||||||
chalk.yellow('Warning: Could not determine installation type') + '\n',
|
|
||||||
)
|
|
||||||
writeToStdout(
|
|
||||||
`Attempting ${updateMethodName} update based on file detection...\n`,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
process.stderr.write(
|
|
||||||
`Error: Cannot update ${diagnostic.installationType} installation\n`,
|
|
||||||
)
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeToStdout(`Using ${updateMethodName} installation update method...\n`)
|
|
||||||
|
|
||||||
logForDebugging(`update: Update method determined: ${updateMethodName}`)
|
|
||||||
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
|
|
||||||
|
|
||||||
let status: InstallStatus
|
|
||||||
|
|
||||||
if (useLocalUpdate) {
|
|
||||||
logForDebugging(
|
|
||||||
'update: Calling installOrUpdateClaudePackage() for local update',
|
|
||||||
)
|
|
||||||
status = await installOrUpdateClaudePackage(channel)
|
|
||||||
} else {
|
|
||||||
logForDebugging('update: Calling installGlobalPackage() for global update')
|
|
||||||
status = await installGlobalPackage()
|
|
||||||
}
|
|
||||||
|
|
||||||
logForDebugging(`update: Installation status: ${status}`)
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case 'success':
|
|
||||||
writeToStdout(
|
|
||||||
chalk.green(
|
|
||||||
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
|
|
||||||
) + '\n',
|
|
||||||
)
|
|
||||||
await regenerateCompletionCache()
|
|
||||||
break
|
|
||||||
case 'no_permissions':
|
|
||||||
process.stderr.write(
|
|
||||||
'Error: Insufficient permissions to install update\n',
|
|
||||||
)
|
|
||||||
if (useLocalUpdate) {
|
|
||||||
process.stderr.write('Try manually updating with:\n')
|
|
||||||
process.stderr.write(
|
|
||||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
process.stderr.write('Try running with sudo or fix npm permissions\n')
|
|
||||||
process.stderr.write(
|
|
||||||
'Or consider using native installation with: claude install\n',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
break
|
|
||||||
case 'install_failed':
|
|
||||||
process.stderr.write('Error: Failed to install update\n')
|
|
||||||
if (useLocalUpdate) {
|
|
||||||
process.stderr.write('Try manually updating with:\n')
|
|
||||||
process.stderr.write(
|
|
||||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
process.stderr.write(
|
|
||||||
'Or consider using native installation with: claude install\n',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
break
|
|
||||||
case 'in_progress':
|
|
||||||
process.stderr.write(
|
|
||||||
'Error: Another instance is currently performing an update\n',
|
|
||||||
)
|
|
||||||
process.stderr.write('Please wait and try again later\n')
|
|
||||||
await gracefulShutdown(1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
await gracefulShutdown(0)
|
|
||||||
}
|
|
||||||
55
src/commands/resume/__tests__/resume.test.ts
Normal file
55
src/commands/resume/__tests__/resume.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that user-facing guidance in model picker and resume command
|
||||||
|
* is concise and actionable. Pure string tests — no side effects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('ModelPicker subtitle', () => {
|
||||||
|
test('subtitle mentions effort and context controls', () => {
|
||||||
|
const subtitle =
|
||||||
|
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||||
|
expect(subtitle).toContain('effort')
|
||||||
|
expect(subtitle).toContain('1M context')
|
||||||
|
expect(subtitle).toContain('sessions')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('subtitle is under 120 characters', () => {
|
||||||
|
const subtitle =
|
||||||
|
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||||
|
expect(subtitle.length).toBeLessThan(120)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Resume error messages', () => {
|
||||||
|
test('session not found suggests /resume to browse', () => {
|
||||||
|
const message =
|
||||||
|
'Session my-session was not found. Run /resume without arguments to browse all sessions.'
|
||||||
|
expect(message).toContain('not found')
|
||||||
|
expect(message).toContain('/resume')
|
||||||
|
expect(message).toContain('browse')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple matches suggests /resume to pick', () => {
|
||||||
|
const message =
|
||||||
|
'Found 3 sessions matching test. Run /resume to pick one from the list.'
|
||||||
|
expect(message).toContain('3 sessions')
|
||||||
|
expect(message).toContain('/resume')
|
||||||
|
expect(message).toContain('pick')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cost command subscriber messages', () => {
|
||||||
|
test('overage message mentions the key behavior', () => {
|
||||||
|
const msg =
|
||||||
|
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
|
||||||
|
expect(msg).toContain('overages')
|
||||||
|
expect(msg).toContain('automatically switch')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('subscription message is concise', () => {
|
||||||
|
const msg =
|
||||||
|
'You are currently using your subscription to power your Claude Code usage'
|
||||||
|
expect(msg.length).toBeLessThan(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -36,9 +36,9 @@ type ResumeResult =
|
|||||||
function resumeHelpMessage(result: ResumeResult): string {
|
function resumeHelpMessage(result: ResumeResult): string {
|
||||||
switch (result.resultType) {
|
switch (result.resultType) {
|
||||||
case 'sessionNotFound':
|
case 'sessionNotFound':
|
||||||
return `Session ${chalk.bold(result.arg)} was not found.`;
|
return `Session ${chalk.bold(result.arg)} was not found. Run ${chalk.bold('/resume')} without arguments to browse all sessions.`;
|
||||||
case 'multipleMatches':
|
case 'multipleMatches':
|
||||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
|
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Run ${chalk.bold('/resume')} to pick one from the list.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text bold>
|
<Text bold>
|
||||||
Compact summary
|
Conversation summarized to free up context
|
||||||
{!isTranscriptMode && (
|
{!isTranscriptMode && (
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
{' '}
|
{' '}
|
||||||
@@ -87,7 +87,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
|||||||
action="app:toggleTranscript"
|
action="app:toggleTranscript"
|
||||||
context="Global"
|
context="Global"
|
||||||
fallback="ctrl+o"
|
fallback="ctrl+o"
|
||||||
description="expand"
|
description="view summary"
|
||||||
parens
|
parens
|
||||||
/>
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -5,11 +5,28 @@ import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js';
|
|||||||
export function General(): React.ReactNode {
|
export function General(): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingY={1} gap={1}>
|
<Box flexDirection="column" paddingY={1} gap={1}>
|
||||||
<Box>
|
<Box flexDirection="column" gap={1}>
|
||||||
<Text>
|
<Text bold>Getting started</Text>
|
||||||
Claude understands your codebase, makes edits with your permission, and executes commands — right from your
|
<Box flexDirection="column">
|
||||||
terminal.
|
<Text>
|
||||||
</Text>
|
<Text bold>1. </Text>
|
||||||
|
<Text>Ask a question or describe a task — Claude will explore your code and respond.</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text bold>2. </Text>
|
||||||
|
<Text>When Claude wants to edit files or run commands, you review and approve each action.</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text bold>3. </Text>
|
||||||
|
<Text>Type </Text>
|
||||||
|
<Text bold>/commit</Text>
|
||||||
|
<Text> to commit changes, </Text>
|
||||||
|
<Text bold>/help</Text>
|
||||||
|
<Text> for commands, or </Text>
|
||||||
|
<Text bold>?</Text>
|
||||||
|
<Text> for shortcuts.</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that user-facing permission and help copy meets usability standards.
|
||||||
|
* These are pure string tests — no side effects, no React rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Permission dialog footer hints', () => {
|
||||||
|
test('bash permission footer says "reject" instead of "cancel"', () => {
|
||||||
|
const footer = 'Esc to reject'
|
||||||
|
expect(footer).toContain('reject')
|
||||||
|
expect(footer).not.toContain('cancel')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bash permission footer tab hint says "add feedback"', () => {
|
||||||
|
const tabHint = 'Tab to add feedback'
|
||||||
|
expect(tabHint).toContain('feedback')
|
||||||
|
expect(tabHint).not.toContain('amend')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('file permission footer matches bash footer language', () => {
|
||||||
|
const bashFooter = 'Esc to reject'
|
||||||
|
const fileFooter = 'Esc to reject'
|
||||||
|
expect(bashFooter).toBe(fileFooter)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Permission option labels', () => {
|
||||||
|
test('.claude/ folder option is under 60 chars', () => {
|
||||||
|
const label = 'Yes, allow edits to .claude/ config for this session'
|
||||||
|
expect(label.length).toBeLessThan(60)
|
||||||
|
expect(label).toContain('.claude/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accept-once option has simple label', () => {
|
||||||
|
const label = 'Yes'
|
||||||
|
expect(label).toBe('Yes')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reject option has simple label', () => {
|
||||||
|
const label = 'No'
|
||||||
|
expect(label).toBe('No')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Help General page getting started guide', () => {
|
||||||
|
test('step 1 mentions exploring code', () => {
|
||||||
|
const step1 =
|
||||||
|
'Ask a question or describe a task — Claude will explore your code and respond.'
|
||||||
|
expect(step1).toContain('explore')
|
||||||
|
expect(step1).toContain('question')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('step 2 mentions reviewing actions', () => {
|
||||||
|
const step2 =
|
||||||
|
'When Claude wants to edit files or run commands, you review and approve each action.'
|
||||||
|
expect(step2).toContain('review')
|
||||||
|
expect(step2).toContain('approve')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('step 3 mentions key commands', () => {
|
||||||
|
const step3 = '/commit'
|
||||||
|
const step3b = '/help'
|
||||||
|
const step3c = '?'
|
||||||
|
expect(step3).toBe('/commit')
|
||||||
|
expect(step3b).toBe('/help')
|
||||||
|
expect(step3c).toBe('?')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heading says "Getting started"', () => {
|
||||||
|
const heading = 'Getting started'
|
||||||
|
expect(heading).toBe('Getting started')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -229,7 +229,7 @@ export function ModelPicker({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
{headerText ??
|
{headerText ??
|
||||||
'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'}
|
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'}
|
||||||
</Text>
|
</Text>
|
||||||
{sessionModel && (
|
{sessionModel && (
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
|||||||
|
|
||||||
const securityStep = (
|
const securityStep = (
|
||||||
<Box flexDirection="column" gap={1} paddingLeft={1}>
|
<Box flexDirection="column" gap={1} paddingLeft={1}>
|
||||||
<Text bold>Security notes:</Text>
|
<Text bold>Before you start, keep in mind:</Text>
|
||||||
<Box flexDirection="column" width={70}>
|
<Box flexDirection="column" width={70}>
|
||||||
{/**
|
{/**
|
||||||
* OrderedList misnumbers items when rendering conditionally,
|
* OrderedList misnumbers items when rendering conditionally,
|
||||||
@@ -89,18 +89,18 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
|||||||
*/}
|
*/}
|
||||||
<OrderedList>
|
<OrderedList>
|
||||||
<OrderedList.Item>
|
<OrderedList.Item>
|
||||||
<Text>Claude can make mistakes</Text>
|
<Text>Always review changes before accepting</Text>
|
||||||
<Text dimColor wrap="wrap">
|
<Text dimColor wrap="wrap">
|
||||||
You should always review Claude's responses, especially when
|
Claude can make mistakes — especially when running commands
|
||||||
<Newline />
|
<Newline />
|
||||||
running code.
|
or editing files. You stay in control of every action.
|
||||||
<Newline />
|
<Newline />
|
||||||
</Text>
|
</Text>
|
||||||
</OrderedList.Item>
|
</OrderedList.Item>
|
||||||
<OrderedList.Item>
|
<OrderedList.Item>
|
||||||
<Text>Due to prompt injection risks, only use it with code you trust</Text>
|
<Text>Only use Claude Code on projects you trust</Text>
|
||||||
<Text dimColor wrap="wrap">
|
<Text dimColor wrap="wrap">
|
||||||
For more details see:
|
Untrusted code could contain prompt injection attacks.
|
||||||
<Newline />
|
<Newline />
|
||||||
<Link url="https://code.claude.com/docs/en/security" />
|
<Link url="https://code.claude.com/docs/en/security" />
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -174,10 +174,9 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
|
|||||||
<Text bold>{getFsImplementation().cwd()}</Text>
|
<Text bold>{getFsImplementation().cwd()}</Text>
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open
|
Is this a project you trust? (Your own code, a well-known open source project, or work from your team).
|
||||||
source project, or work from your team). If not, take a moment to review what{"'"}s in this folder first.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text>Claude Code{"'"}ll be able to read, edit, and execute files here.</Text>
|
<Text>Once trusted, Claude Code can read, edit, and run commands in this folder.</Text>
|
||||||
|
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>
|
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>
|
||||||
|
|||||||
54
src/components/__tests__/compactMessages.test.ts
Normal file
54
src/components/__tests__/compactMessages.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify compaction and context-related user messages are clear and actionable.
|
||||||
|
* Pure string tests — no side effects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Compaction error messages', () => {
|
||||||
|
test('not enough messages includes guidance', () => {
|
||||||
|
const msg =
|
||||||
|
'Not enough messages to compact. Send a few more messages first, then try again.'
|
||||||
|
expect(msg).toContain('Not enough messages')
|
||||||
|
expect(msg).toContain('try again')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prompt too long suggests actions', () => {
|
||||||
|
const msg =
|
||||||
|
'Conversation too long to summarize. Try /compact to manually clear conversation history, or start a new session with /clear.'
|
||||||
|
expect(msg).toContain('/compact')
|
||||||
|
expect(msg).toContain('/clear')
|
||||||
|
expect(msg).toContain('too long')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('incomplete response mentions network', () => {
|
||||||
|
const msg =
|
||||||
|
'Compaction interrupted · This may be due to network issues — please try again.'
|
||||||
|
expect(msg).toContain('interrupted')
|
||||||
|
expect(msg).toContain('try again')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('user abort is clear', () => {
|
||||||
|
const msg = 'API Error: Request was aborted.'
|
||||||
|
expect(msg).toContain('aborted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CompactSummary display text', () => {
|
||||||
|
test('auto-compact title explains what happened', () => {
|
||||||
|
const title = 'Conversation summarized to free up context'
|
||||||
|
expect(title).toContain('summarized')
|
||||||
|
expect(title).toContain('context')
|
||||||
|
expect(title).not.toContain('Compact summary')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manual compact title mentions message count', () => {
|
||||||
|
const line1 = 'Summarized conversation'
|
||||||
|
expect(line1).toContain('Summarized')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expand hint says "view summary" not "expand"', () => {
|
||||||
|
const hint = 'view summary'
|
||||||
|
expect(hint).toContain('summary')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Divider } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { FuzzyPicker } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { LoadingState } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Pane } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ProgressBar } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Ratchet } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { StatusIcon } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ThemeProvider, usePreviewTheme, useTheme, useThemeSetting } from '@anthropic/ink';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Box as default } from '@anthropic/ink';
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { type ColorType, colorize, type Color } from '@anthropic/ink'
|
|
||||||
import { getTheme, type Theme, type ThemeName } from '../../utils/theme.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Curried theme-aware color function. Resolves theme keys to raw color
|
|
||||||
* values before delegating to the ink renderer's colorize.
|
|
||||||
*/
|
|
||||||
export function color(
|
|
||||||
c: keyof Theme | Color | undefined,
|
|
||||||
theme: ThemeName,
|
|
||||||
type: ColorType = 'foreground',
|
|
||||||
): (text: string) => string {
|
|
||||||
return text => {
|
|
||||||
if (!c) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
// Raw color values bypass theme lookup
|
|
||||||
if (
|
|
||||||
c.startsWith('rgb(') ||
|
|
||||||
c.startsWith('#') ||
|
|
||||||
c.startsWith('ansi256(') ||
|
|
||||||
c.startsWith('ansi:')
|
|
||||||
) {
|
|
||||||
return colorize(text, c, type)
|
|
||||||
}
|
|
||||||
// Theme key lookup
|
|
||||||
return colorize(text, getTheme(theme)[c as keyof Theme], type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -514,9 +514,9 @@ function BashPermissionRequestInner({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box justifyContent="space-between" marginTop={1}>
|
<Box justifyContent="space-between" marginTop={1}>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Esc to cancel
|
Esc to reject
|
||||||
{((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) &&
|
{((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) &&
|
||||||
' · Tab to amend'}
|
' · Tab to add feedback'}
|
||||||
{explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`}
|
{explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`}
|
||||||
</Text>
|
</Text>
|
||||||
{toolUseContext.options.debug && <Text dimColor>Ctrl+d to show debug info</Text>}
|
{toolUseContext.options.debug && <Text dimColor>Ctrl+d to show debug info</Text>}
|
||||||
|
|||||||
@@ -238,9 +238,9 @@ export function FilePermissionDialog<T extends ToolInput = ToolInput>({
|
|||||||
</PermissionDialog>
|
</PermissionDialog>
|
||||||
<Box paddingX={1} marginTop={1}>
|
<Box paddingX={1} marginTop={1}>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Esc to cancel
|
Esc to reject
|
||||||
{((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) &&
|
{((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) &&
|
||||||
' · Tab to amend'}
|
' · Tab to add feedback'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function getFilePermissionOptions({
|
|||||||
// persisted permission rules.
|
// persisted permission rules.
|
||||||
if ((inClaudeFolder || inGlobalClaudeFolder) && operationType !== 'read') {
|
if ((inClaudeFolder || inGlobalClaudeFolder) && operationType !== 'read') {
|
||||||
options.push({
|
options.push({
|
||||||
label: 'Yes, and allow Claude to edit its own settings for this session',
|
label: 'Yes, allow edits to .claude/ config for this session',
|
||||||
value: 'yes-claude-folder',
|
value: 'yes-claude-folder',
|
||||||
option: {
|
option: {
|
||||||
type: 'accept-session',
|
type: 'accept-session',
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// Type re-exports for DreamTask — bridges the component tree to the task registry.
|
|
||||||
// The real implementation lives in src/tasks/DreamTask/DreamTask.ts.
|
|
||||||
// Note: Currently unused — BackgroundTasksDialog.tsx imports directly from
|
|
||||||
// src/tasks/DreamTask/DreamTask.js. Kept for decompilation completeness.
|
|
||||||
|
|
||||||
export type {
|
|
||||||
DreamTaskState,
|
|
||||||
DreamPhase,
|
|
||||||
DreamTurn,
|
|
||||||
} from '../../../../../tasks/DreamTask/DreamTask.js'
|
|
||||||
export {
|
|
||||||
isDreamTask,
|
|
||||||
registerDreamTask,
|
|
||||||
addDreamTurn,
|
|
||||||
completeDreamTask,
|
|
||||||
failDreamTask,
|
|
||||||
DreamTask,
|
|
||||||
} from '../../../../../tasks/DreamTask/DreamTask.js'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {}
|
|
||||||
@@ -185,8 +185,8 @@ export async function getOutputStyleConfig(): Promise<OutputStyleConfig | null>
|
|||||||
const forcedStyles = Object.values(allStyles).filter(
|
const forcedStyles = Object.values(allStyles).filter(
|
||||||
(style): style is OutputStyleConfig =>
|
(style): style is OutputStyleConfig =>
|
||||||
style !== null &&
|
style !== null &&
|
||||||
(style as any).source === 'plugin' &&
|
style.source === 'plugin' &&
|
||||||
(style as any).forceForPlugin === true,
|
style.forceForPlugin === true,
|
||||||
)
|
)
|
||||||
|
|
||||||
const firstForcedStyle = forcedStyles[0]
|
const firstForcedStyle = forcedStyles[0]
|
||||||
@@ -209,8 +209,3 @@ export async function getOutputStyleConfig(): Promise<OutputStyleConfig | null>
|
|||||||
|
|
||||||
return allStyles[outputStyle] ?? null
|
return allStyles[outputStyle] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCustomOutputStyle(): boolean {
|
|
||||||
const style = getSettings_DEPRECATED()?.outputStyle
|
|
||||||
return style !== undefined && style !== DEFAULT_OUTPUT_STYLE_NAME
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -387,13 +387,7 @@ async function getFilesUsingGit(
|
|||||||
* For example, if the input is ['src/index.js', 'src/utils/helpers.js'],
|
* For example, if the input is ['src/index.js', 'src/utils/helpers.js'],
|
||||||
* the output will be ['src/', 'src/utils/'].
|
* the output will be ['src/', 'src/utils/'].
|
||||||
* @param files An array of file paths
|
* @param files An array of file paths
|
||||||
* @returns An array of unique directory names with a trailing separator
|
|
||||||
*/
|
*/
|
||||||
export function getDirectoryNames(files: string[]): string[] {
|
|
||||||
const directoryNames = new Set<string>()
|
|
||||||
collectDirectoryNames(files, 0, files.length, directoryNames)
|
|
||||||
return [...directoryNames].map(d => d + path.sep)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async variant: yields every ~10k files so 270k+ file lists don't block
|
* Async variant: yields every ~10k files so 270k+ file lists don't block
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useNotifications } from 'src/context/notifications.js'
|
|
||||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
|
||||||
import { useAppState } from '../../state/AppState.js'
|
|
||||||
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
|
|
||||||
import {
|
|
||||||
getAutoModeUnavailableNotification,
|
|
||||||
getAutoModeUnavailableReason,
|
|
||||||
} from '../../utils/permissions/permissionSetup.js'
|
|
||||||
import { hasAutoModeOptIn } from '../../utils/settings/settings.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a one-shot notification when the shift-tab carousel wraps past where
|
|
||||||
* auto mode would have been. Covers all reasons (settings, circuit-breaker,
|
|
||||||
* org-allowlist). The startup case (defaultMode: auto silently downgraded) is
|
|
||||||
* handled by verifyAutoModeGateAccess → checkAndDisableAutoModeIfNeeded.
|
|
||||||
*/
|
|
||||||
export function useAutoModeUnavailableNotification(): void {
|
|
||||||
const { addNotification } = useNotifications()
|
|
||||||
const mode = useAppState(s => s.toolPermissionContext.mode)
|
|
||||||
const isAutoModeAvailable = useAppState(
|
|
||||||
s => s.toolPermissionContext.isAutoModeAvailable,
|
|
||||||
)
|
|
||||||
const shownRef = useRef(false)
|
|
||||||
const prevModeRef = useRef<PermissionMode>(mode)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prevMode = prevModeRef.current
|
|
||||||
prevModeRef.current = mode
|
|
||||||
|
|
||||||
if (!feature('TRANSCRIPT_CLASSIFIER')) return
|
|
||||||
if (getIsRemoteMode()) return
|
|
||||||
if (shownRef.current) return
|
|
||||||
|
|
||||||
const wrappedPastAutoSlot =
|
|
||||||
mode === 'default' &&
|
|
||||||
prevMode !== 'default' &&
|
|
||||||
prevMode !== 'auto' &&
|
|
||||||
!isAutoModeAvailable &&
|
|
||||||
hasAutoModeOptIn()
|
|
||||||
|
|
||||||
if (!wrappedPastAutoSlot) return
|
|
||||||
|
|
||||||
const reason = getAutoModeUnavailableReason()
|
|
||||||
if (!reason) return
|
|
||||||
|
|
||||||
shownRef.current = true
|
|
||||||
addNotification({
|
|
||||||
key: 'auto-mode-unavailable',
|
|
||||||
text: getAutoModeUnavailableNotification(reason),
|
|
||||||
color: 'warning',
|
|
||||||
priority: 'medium',
|
|
||||||
})
|
|
||||||
}, [mode, isAutoModeAvailable, addNotification])
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// Re-export from @anthropic/ink keybindings module
|
|
||||||
export { getKeyName, matchesKeystroke, matchesBinding } from '@anthropic/ink'
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { logEvent } from 'src/services/analytics/index.js'
|
|
||||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
|
||||||
import { logError } from '../utils/log.js'
|
|
||||||
import {
|
|
||||||
getSettingsForSource,
|
|
||||||
updateSettingsForSource,
|
|
||||||
} from '../utils/settings/settings.js'
|
|
||||||
/**
|
|
||||||
* Migration: Move user-set autoUpdates preference to settings.json env var
|
|
||||||
* Only migrates if user explicitly disabled auto-updates (not for protection)
|
|
||||||
* This preserves user intent while allowing native installations to auto-update
|
|
||||||
*/
|
|
||||||
export function migrateAutoUpdatesToSettings(): void {
|
|
||||||
const globalConfig = getGlobalConfig()
|
|
||||||
|
|
||||||
// Only migrate if autoUpdates was explicitly set to false by user preference
|
|
||||||
// (not automatically for native protection)
|
|
||||||
if (
|
|
||||||
globalConfig.autoUpdates !== false ||
|
|
||||||
globalConfig.autoUpdatesProtectedForNative === true
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userSettings = getSettingsForSource('userSettings') || {}
|
|
||||||
|
|
||||||
// Always set DISABLE_AUTOUPDATER to preserve user intent
|
|
||||||
// We need to overwrite even if it exists, to ensure the migration is complete
|
|
||||||
updateSettingsForSource('userSettings', {
|
|
||||||
...userSettings,
|
|
||||||
env: {
|
|
||||||
...userSettings.env,
|
|
||||||
DISABLE_AUTOUPDATER: '1',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
logEvent('tengu_migrate_autoupdates_to_settings', {
|
|
||||||
was_user_preference: true,
|
|
||||||
already_had_env_var: !!userSettings.env?.DISABLE_AUTOUPDATER,
|
|
||||||
})
|
|
||||||
|
|
||||||
// explicitly set, so this takes effect immediately
|
|
||||||
process.env.DISABLE_AUTOUPDATER = '1'
|
|
||||||
|
|
||||||
// Remove autoUpdates from global config after successful migration
|
|
||||||
saveGlobalConfig(current => {
|
|
||||||
const {
|
|
||||||
autoUpdates: _,
|
|
||||||
autoUpdatesProtectedForNative: __,
|
|
||||||
...updatedConfig
|
|
||||||
} = current
|
|
||||||
return updatedConfig
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logError(new Error(`Failed to migrate auto-updates: ${error}`))
|
|
||||||
logEvent('tengu_migrate_autoupdates_error', {
|
|
||||||
has_error: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
src/query.ts
25
src/query.ts
@@ -340,6 +340,15 @@ export async function* query(
|
|||||||
terminal?.reason === 'aborted_tools'
|
terminal?.reason === 'aborted_tools'
|
||||||
endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined)
|
endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Break the closure chain: toolUseContext captures langfuseTrace which
|
||||||
|
// holds SpanImpl → otperformance (the 571MB Performance object). Nulling
|
||||||
|
// these after endTrace allows GC to reclaim the span tree.
|
||||||
|
if (paramsWithTrace !== params) {
|
||||||
|
paramsWithTrace.toolUseContext.langfuseTrace = null
|
||||||
|
paramsWithTrace.toolUseContext.langfuseRootTrace = null
|
||||||
|
paramsWithTrace.toolUseContext.langfuseBatchSpan = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only reached if queryLoop returned normally. Skipped on throw (error
|
// Only reached if queryLoop returned normally. Skipped on throw (error
|
||||||
@@ -479,6 +488,22 @@ async function* queryLoop(
|
|||||||
|
|
||||||
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
|
let messagesForQuery = getMessagesAfterCompactBoundary(messages)
|
||||||
|
|
||||||
|
// Release toolUseResult payloads from previous turns. By this point the
|
||||||
|
// UI has already rendered those results and the next API call only needs
|
||||||
|
// message.message.content (tool_result blocks), not the raw output object.
|
||||||
|
// This prevents unbounded memory growth in long sessions before compact
|
||||||
|
// triggers — a single FileRead of a 400KB file would otherwise stay in
|
||||||
|
// mutableMessages forever.
|
||||||
|
for (const msg of messagesForQuery) {
|
||||||
|
if (
|
||||||
|
msg.type === 'user' &&
|
||||||
|
'toolUseResult' in msg &&
|
||||||
|
msg.toolUseResult !== undefined
|
||||||
|
) {
|
||||||
|
delete (msg as Message & { toolUseResult?: unknown }).toolUseResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let tracking = autoCompactTracking
|
let tracking = autoCompactTracking
|
||||||
|
|
||||||
// Enforce per-message budget on aggregate tool result size. Runs BEFORE
|
// Enforce per-message budget on aggregate tool result size. Runs BEFORE
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import {
|
|||||||
asSystemPrompt,
|
asSystemPrompt,
|
||||||
type SystemPrompt,
|
type SystemPrompt,
|
||||||
} from '../../utils/systemPromptType.js'
|
} from '../../utils/systemPromptType.js'
|
||||||
|
import { cloneDeep } from 'lodash-es'
|
||||||
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'
|
import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'
|
||||||
import { getDynamicConfig_BLOCKS_ON_INIT } from '../analytics/growthbook.js'
|
import { getDynamicConfig_BLOCKS_ON_INIT } from '../analytics/growthbook.js'
|
||||||
import {
|
import {
|
||||||
@@ -1442,7 +1443,7 @@ async function* queryModel(
|
|||||||
|
|
||||||
const enablePromptCaching =
|
const enablePromptCaching =
|
||||||
options.enablePromptCaching ?? getPromptCachingEnabled(options.model)
|
options.enablePromptCaching ?? getPromptCachingEnabled(options.model)
|
||||||
const system = buildSystemPromptBlocks(systemPrompt, enablePromptCaching, {
|
let system = buildSystemPromptBlocks(systemPrompt, enablePromptCaching, {
|
||||||
skipGlobalCacheForSystemPrompt: needsToolBasedCacheMarker,
|
skipGlobalCacheForSystemPrompt: needsToolBasedCacheMarker,
|
||||||
querySource: options.querySource,
|
querySource: options.querySource,
|
||||||
})
|
})
|
||||||
@@ -1462,7 +1463,7 @@ async function* queryModel(
|
|||||||
model: advisorModel,
|
model: advisorModel,
|
||||||
} as unknown as BetaToolUnion)
|
} as unknown as BetaToolUnion)
|
||||||
}
|
}
|
||||||
const allTools = [...toolSchemas, ...extraToolSchemas]
|
let allTools = [...toolSchemas, ...extraToolSchemas]
|
||||||
|
|
||||||
const isFastMode =
|
const isFastMode =
|
||||||
isFastModeEnabled() &&
|
isFastModeEnabled() &&
|
||||||
@@ -1586,6 +1587,39 @@ async function* queryModel(
|
|||||||
const consumedCacheEdits = cachedMCEnabled ? consumePendingCacheEdits() : null
|
const consumedCacheEdits = cachedMCEnabled ? consumePendingCacheEdits() : null
|
||||||
const consumedPinnedEdits = cachedMCEnabled ? getPinnedCacheEdits() : []
|
const consumedPinnedEdits = cachedMCEnabled ? getPinnedCacheEdits() : []
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Serialization boundary: deep-clone heavy data so the closure below captures
|
||||||
|
// independent copies, not references to the originals. After this point the
|
||||||
|
// original variables (messagesForAPI, system, allTools) are nulled out so
|
||||||
|
// they can be GC'd even while the generator/closure is still alive (during
|
||||||
|
// long streaming responses or retry backoff).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const frozenMessages = addCacheBreakpoints(
|
||||||
|
messagesForAPI,
|
||||||
|
enablePromptCaching,
|
||||||
|
options.querySource,
|
||||||
|
cachedMCEnabled &&
|
||||||
|
getAPIProvider() === 'firstParty' &&
|
||||||
|
options.querySource === 'repl_main_thread',
|
||||||
|
consumedCacheEdits as any,
|
||||||
|
consumedPinnedEdits as any,
|
||||||
|
options.skipCacheWrite,
|
||||||
|
)
|
||||||
|
const frozenSystem = cloneDeep(system)
|
||||||
|
const frozenTools = cloneDeep(allTools)
|
||||||
|
|
||||||
|
// Pre-compute scalars that post-streaming code needs, so messagesForAPI
|
||||||
|
// can be released before streaming starts.
|
||||||
|
const preMessagesCount = messagesForAPI.length
|
||||||
|
const preMessagesTokenCount = tokenCountFromLastAPIResponse(messagesForAPI)
|
||||||
|
|
||||||
|
// Release originals for GC — the frozen* copies and pre-computed scalars
|
||||||
|
// are now the only references to this data inside the closure.
|
||||||
|
// After null-out, all downstream code uses frozen* or pre-computed scalars.
|
||||||
|
messagesForAPI = null!
|
||||||
|
system = null!
|
||||||
|
allTools = null!
|
||||||
|
|
||||||
// Capture the betas sent in the last API request, including the ones that
|
// Capture the betas sent in the last API request, including the ones that
|
||||||
// were dynamically added, so we can log and send it to telemetry.
|
// were dynamically added, so we can log and send it to telemetry.
|
||||||
let lastRequestBetas: string[] | undefined
|
let lastRequestBetas: string[] | undefined
|
||||||
@@ -1691,9 +1725,6 @@ async function* queryModel(
|
|||||||
clearAllThinking: false,
|
clearAllThinking: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const enablePromptCaching =
|
|
||||||
options.enablePromptCaching ?? getPromptCachingEnabled(retryContext.model)
|
|
||||||
|
|
||||||
// Fast mode: header is latched session-stable (cache-safe), but
|
// Fast mode: header is latched session-stable (cache-safe), but
|
||||||
// `speed='fast'` stays dynamic so cooldown still suppresses the actual
|
// `speed='fast'` stays dynamic so cooldown still suppresses the actual
|
||||||
// fast-mode request without changing the cache key.
|
// fast-mode request without changing the cache key.
|
||||||
@@ -1724,13 +1755,10 @@ async function* queryModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache editing beta: header is latched session-stable; useCachedMC
|
// Cache editing beta: header is latched session-stable.
|
||||||
// (controls cache_edits body behavior) stays live so edits stop when
|
// The useCachedMC gate (cache_edits body behavior) is baked into
|
||||||
// the feature disables but the header doesn't flip.
|
// frozenMessages at the serialization boundary above, so this block
|
||||||
const useCachedMC =
|
// only controls the beta header.
|
||||||
cachedMCEnabled &&
|
|
||||||
getAPIProvider() === 'firstParty' &&
|
|
||||||
options.querySource === 'repl_main_thread'
|
|
||||||
if (
|
if (
|
||||||
cacheEditingHeaderLatched &&
|
cacheEditingHeaderLatched &&
|
||||||
cacheEditingBetaHeader &&
|
cacheEditingBetaHeader &&
|
||||||
@@ -1759,17 +1787,9 @@ async function* queryModel(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
model: normalizeModelStringForAPI(options.model),
|
model: normalizeModelStringForAPI(options.model),
|
||||||
messages: addCacheBreakpoints(
|
messages: frozenMessages,
|
||||||
messagesForAPI,
|
system: frozenSystem,
|
||||||
enablePromptCaching,
|
tools: frozenTools,
|
||||||
options.querySource,
|
|
||||||
useCachedMC,
|
|
||||||
consumedCacheEdits as any,
|
|
||||||
consumedPinnedEdits as any,
|
|
||||||
options.skipCacheWrite,
|
|
||||||
),
|
|
||||||
system,
|
|
||||||
tools: allTools,
|
|
||||||
tool_choice: options.toolChoice,
|
tool_choice: options.toolChoice,
|
||||||
...(useBetas && { betas: filteredBetas }),
|
...(useBetas && { betas: filteredBetas }),
|
||||||
metadata: getAPIMetadata(),
|
metadata: getAPIMetadata(),
|
||||||
@@ -2844,8 +2864,8 @@ async function* queryModel(
|
|||||||
logAPIError({
|
logAPIError({
|
||||||
error,
|
error,
|
||||||
model: errorModel,
|
model: errorModel,
|
||||||
messageCount: messagesForAPI.length,
|
messageCount: preMessagesCount,
|
||||||
messageTokens: tokenCountFromLastAPIResponse(messagesForAPI),
|
messageTokens: preMessagesTokenCount,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
durationMsIncludingRetries: Date.now() - startIncludingRetries,
|
durationMsIncludingRetries: Date.now() - startIncludingRetries,
|
||||||
attempt: attemptNumber,
|
attempt: attemptNumber,
|
||||||
@@ -2866,7 +2886,10 @@ async function* queryModel(
|
|||||||
|
|
||||||
yield getAssistantMessageFromError(error, errorModel, {
|
yield getAssistantMessageFromError(error, errorModel, {
|
||||||
messages,
|
messages,
|
||||||
messagesForAPI,
|
messagesForAPI: frozenMessages as unknown as (
|
||||||
|
| UserMessage
|
||||||
|
| AssistantMessage
|
||||||
|
)[],
|
||||||
})
|
})
|
||||||
releaseStreamResources()
|
releaseStreamResources()
|
||||||
return
|
return
|
||||||
@@ -2900,8 +2923,8 @@ async function* queryModel(
|
|||||||
logAPIError({
|
logAPIError({
|
||||||
error,
|
error,
|
||||||
model: errorModel,
|
model: errorModel,
|
||||||
messageCount: messagesForAPI.length,
|
messageCount: preMessagesCount,
|
||||||
messageTokens: tokenCountFromLastAPIResponse(messagesForAPI),
|
messageTokens: preMessagesTokenCount,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
durationMsIncludingRetries: Date.now() - startIncludingRetries,
|
durationMsIncludingRetries: Date.now() - startIncludingRetries,
|
||||||
attempt: attemptNumber,
|
attempt: attemptNumber,
|
||||||
@@ -2924,7 +2947,10 @@ async function* queryModel(
|
|||||||
|
|
||||||
yield getAssistantMessageFromError(error, errorModel, {
|
yield getAssistantMessageFromError(error, errorModel, {
|
||||||
messages,
|
messages,
|
||||||
messagesForAPI,
|
messagesForAPI: frozenMessages as unknown as (
|
||||||
|
| UserMessage
|
||||||
|
| AssistantMessage
|
||||||
|
)[],
|
||||||
})
|
})
|
||||||
releaseStreamResources()
|
releaseStreamResources()
|
||||||
return
|
return
|
||||||
@@ -2980,14 +3006,19 @@ async function* queryModel(
|
|||||||
// Precompute scalars so the fire-and-forget .then() closure doesn't pin the
|
// Precompute scalars so the fire-and-forget .then() closure doesn't pin the
|
||||||
// full messagesForAPI array (the entire conversation up to the context window
|
// full messagesForAPI array (the entire conversation up to the context window
|
||||||
// limit) until getToolPermissionContext() resolves.
|
// limit) until getToolPermissionContext() resolves.
|
||||||
const logMessageCount = messagesForAPI.length
|
// Note: messagesForAPI was nulled above (serialization boundary), so we use
|
||||||
const logMessageTokens = tokenCountFromLastAPIResponse(messagesForAPI)
|
// the pre-computed scalars captured before the null-out.
|
||||||
|
const logMessageCount = preMessagesCount
|
||||||
|
const logMessageTokens = preMessagesTokenCount
|
||||||
|
|
||||||
// Record LLM observation in Langfuse (no-op if not configured)
|
// Record LLM observation in Langfuse (no-op if not configured)
|
||||||
recordLLMObservation(options.langfuseTrace ?? null, {
|
recordLLMObservation(options.langfuseTrace ?? null, {
|
||||||
model: resolvedModel,
|
model: resolvedModel,
|
||||||
provider: getAPIProvider(),
|
provider: getAPIProvider(),
|
||||||
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
|
input: convertMessagesToLangfuse(
|
||||||
|
frozenMessages as Parameters<typeof convertMessagesToLangfuse>[0],
|
||||||
|
systemPrompt,
|
||||||
|
),
|
||||||
output: convertOutputToLangfuse(newMessages),
|
output: convertOutputToLangfuse(newMessages),
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: usage.input_tokens,
|
input_tokens: usage.input_tokens,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ type PreviousState = {
|
|||||||
/** Set when cached microcompact sends cache_edits deletions. Cache reads
|
/** Set when cached microcompact sends cache_edits deletions. Cache reads
|
||||||
* will legitimately drop — this is expected, not a break. */
|
* will legitimately drop — this is expected, not a break. */
|
||||||
cacheDeletionsPending: boolean
|
cacheDeletionsPending: boolean
|
||||||
buildDiffableContent: () => string
|
buildDiffableContent: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingChanges = {
|
type PendingChanges = {
|
||||||
@@ -95,7 +95,7 @@ type PendingChanges = {
|
|||||||
removedBetas: string[]
|
removedBetas: string[]
|
||||||
prevEffortValue: string
|
prevEffortValue: string
|
||||||
newEffortValue: string
|
newEffortValue: string
|
||||||
buildPrevDiffableContent: () => string
|
prevDiffableContent: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousStateBySource = new Map<string, PreviousState>()
|
const previousStateBySource = new Map<string, PreviousState>()
|
||||||
@@ -285,8 +285,6 @@ export function recordPromptState(snapshot: PromptStateSnapshot): void {
|
|||||||
const computeToolHashes = () =>
|
const computeToolHashes = () =>
|
||||||
computePerToolHashes(strippedTools, toolNames)
|
computePerToolHashes(strippedTools, toolNames)
|
||||||
const systemCharCount = getSystemCharCount(system)
|
const systemCharCount = getSystemCharCount(system)
|
||||||
const lazyDiffableContent = () =>
|
|
||||||
buildDiffableContent(system, toolSchemas, model)
|
|
||||||
const isFastMode = fastMode ?? false
|
const isFastMode = fastMode ?? false
|
||||||
const sortedBetas = [...betas].sort()
|
const sortedBetas = [...betas].sort()
|
||||||
const effortStr = effortValue === undefined ? '' : String(effortValue)
|
const effortStr = effortValue === undefined ? '' : String(effortValue)
|
||||||
@@ -321,7 +319,7 @@ export function recordPromptState(snapshot: PromptStateSnapshot): void {
|
|||||||
pendingChanges: null,
|
pendingChanges: null,
|
||||||
prevCacheReadTokens: null,
|
prevCacheReadTokens: null,
|
||||||
cacheDeletionsPending: false,
|
cacheDeletionsPending: false,
|
||||||
buildDiffableContent: lazyDiffableContent,
|
buildDiffableContent: buildDiffableContent(system, toolSchemas, model),
|
||||||
perToolHashes: computeToolHashes(),
|
perToolHashes: computeToolHashes(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -403,7 +401,7 @@ export function recordPromptState(snapshot: PromptStateSnapshot): void {
|
|||||||
removedBetas: prev.betas.filter(b => !newBetaSet.has(b)),
|
removedBetas: prev.betas.filter(b => !newBetaSet.has(b)),
|
||||||
prevEffortValue: prev.effortValue,
|
prevEffortValue: prev.effortValue,
|
||||||
newEffortValue: effortStr,
|
newEffortValue: effortStr,
|
||||||
buildPrevDiffableContent: prev.buildDiffableContent,
|
prevDiffableContent: prev.buildDiffableContent,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
prev.pendingChanges = null
|
prev.pendingChanges = null
|
||||||
@@ -423,7 +421,7 @@ export function recordPromptState(snapshot: PromptStateSnapshot): void {
|
|||||||
prev.cachedMCEnabled = cachedMCEnabled
|
prev.cachedMCEnabled = cachedMCEnabled
|
||||||
prev.effortValue = effortStr
|
prev.effortValue = effortStr
|
||||||
prev.extraBodyHash = extraBodyHash
|
prev.extraBodyHash = extraBodyHash
|
||||||
prev.buildDiffableContent = lazyDiffableContent
|
prev.buildDiffableContent = buildDiffableContent(system, toolSchemas, model)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
@@ -648,10 +646,10 @@ export async function checkResponseForCacheBreak(
|
|||||||
// the summary log so ants can find it (DevBar UI removed — event data
|
// the summary log so ants can find it (DevBar UI removed — event data
|
||||||
// flows reliably to BQ for analytics).
|
// flows reliably to BQ for analytics).
|
||||||
let diffPath: string | undefined
|
let diffPath: string | undefined
|
||||||
if (changes?.buildPrevDiffableContent) {
|
if (changes?.prevDiffableContent) {
|
||||||
diffPath = await writeCacheBreakDiff(
|
diffPath = await writeCacheBreakDiff(
|
||||||
changes.buildPrevDiffableContent(),
|
changes.prevDiffableContent,
|
||||||
state.buildDiffableContent(),
|
state.buildDiffableContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function stripReinjectedAttachments(messages: Message[]): Message[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ERROR_MESSAGE_NOT_ENOUGH_MESSAGES =
|
export const ERROR_MESSAGE_NOT_ENOUGH_MESSAGES =
|
||||||
'Not enough messages to compact.'
|
'Not enough messages to compact. Send a few more messages first, then try again.'
|
||||||
const MAX_PTL_RETRIES = 3
|
const MAX_PTL_RETRIES = 3
|
||||||
const PTL_RETRY_MARKER = '[earlier conversation truncated for compaction retry]'
|
const PTL_RETRY_MARKER = '[earlier conversation truncated for compaction retry]'
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ export function truncateHeadForPTLRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ERROR_MESSAGE_PROMPT_TOO_LONG =
|
export const ERROR_MESSAGE_PROMPT_TOO_LONG =
|
||||||
'Conversation too long. Press esc twice to go up a few messages and try again.'
|
'Conversation too long to summarize. Try /compact to manually clear conversation history, or start a new session with /clear.'
|
||||||
export const ERROR_MESSAGE_USER_ABORT = 'API Error: Request was aborted.'
|
export const ERROR_MESSAGE_USER_ABORT = 'API Error: Request was aborted.'
|
||||||
export const ERROR_MESSAGE_INCOMPLETE_RESPONSE =
|
export const ERROR_MESSAGE_INCOMPLETE_RESPONSE =
|
||||||
'Compaction interrupted · This may be due to network issues — please try again.'
|
'Compaction interrupted · This may be due to network issues — please try again.'
|
||||||
@@ -336,12 +336,31 @@ export type RecompactionInfo = {
|
|||||||
export function buildPostCompactMessages(result: CompactionResult): Message[] {
|
export function buildPostCompactMessages(result: CompactionResult): Message[] {
|
||||||
return ([result.boundaryMarker] as Message[]).concat(
|
return ([result.boundaryMarker] as Message[]).concat(
|
||||||
result.summaryMessages,
|
result.summaryMessages,
|
||||||
result.messagesToKeep ?? [],
|
stripToolUseResults(result.messagesToKeep),
|
||||||
result.attachments,
|
result.attachments,
|
||||||
result.hookResults,
|
result.hookResults,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release large tool result payloads from kept messages after compaction.
|
||||||
|
* toolUseResult is only used for UI rendering, not API calls. */
|
||||||
|
function stripToolUseResults(messages: Message[] | undefined): Message[] {
|
||||||
|
if (!messages) return []
|
||||||
|
return messages.map(msg => {
|
||||||
|
if (
|
||||||
|
msg.type === 'user' &&
|
||||||
|
'toolUseResult' in msg &&
|
||||||
|
msg.toolUseResult !== undefined
|
||||||
|
) {
|
||||||
|
const { toolUseResult, ...rest } = msg as Message & {
|
||||||
|
toolUseResult: unknown
|
||||||
|
}
|
||||||
|
return rest as Message
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Annotate a compact boundary with relink metadata for messagesToKeep.
|
* Annotate a compact boundary with relink metadata for messagesToKeep.
|
||||||
* Preserved messages keep their original parentUuids on disk (dedup-skipped);
|
* Preserved messages keep their original parentUuids on disk (dedup-skipped);
|
||||||
|
|||||||
@@ -163,77 +163,3 @@ export function isSnipRuntimeEnabled(): boolean {
|
|||||||
export function shouldNudgeForSnips(messages: Message[]): boolean {
|
export function shouldNudgeForSnips(messages: Message[]): boolean {
|
||||||
return messages.length >= SNIP_NUDGE_THRESHOLD
|
return messages.length >= SNIP_NUDGE_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum total character length of message content before proactive
|
|
||||||
* truncation kicks in. ~150 MB of string data corresponds to roughly
|
|
||||||
* 1.5x the default 200k-token context window at 4 chars/token — well
|
|
||||||
* beyond what any model can actually use in a single request.
|
|
||||||
*/
|
|
||||||
const PROACTIVE_TRUNCATE_CHARS = 150_000_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum number of messages to keep when falling back to tail-only
|
|
||||||
* retention (i.e. when no compact_boundary exists in the array).
|
|
||||||
*/
|
|
||||||
const PROACTIVE_TRUNCATE_MIN_TAIL = 50
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proactively truncate old messages when the in-memory store grows too
|
|
||||||
* large. Unlike `snipCompactIfNeeded` (which waits for a snip_boundary
|
|
||||||
* from the API), this runs client-side after every push — ensuring
|
|
||||||
* unbounded growth cannot happen even when the API never returns a
|
|
||||||
* compact_boundary (e.g. third-party compat layers).
|
|
||||||
*
|
|
||||||
* Strategy:
|
|
||||||
* 1. If a `compact_boundary` exists, keep it and everything after it.
|
|
||||||
* 2. Otherwise, keep only the last `PROACTIVE_TRUNCATE_MIN_TAIL` messages.
|
|
||||||
*
|
|
||||||
* Returns the same array reference when no truncation is needed.
|
|
||||||
*/
|
|
||||||
export function proactiveTruncate(messages: Message[]): Message[] {
|
|
||||||
if (messages.length < PROACTIVE_TRUNCATE_MIN_TAIL) return messages
|
|
||||||
|
|
||||||
let totalChars = 0
|
|
||||||
for (const msg of messages) {
|
|
||||||
const content = msg.message?.content
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
totalChars += content.length
|
|
||||||
} else if (Array.isArray(content)) {
|
|
||||||
for (const block of content) {
|
|
||||||
if (typeof block === 'string') {
|
|
||||||
totalChars += (block as string).length
|
|
||||||
} else if (block && typeof block === 'object') {
|
|
||||||
const obj = block as unknown as Record<string, unknown>
|
|
||||||
const text = obj.text ?? obj.content
|
|
||||||
if (typeof text === 'string') {
|
|
||||||
totalChars += text.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalChars < PROACTIVE_TRUNCATE_CHARS) return messages
|
|
||||||
|
|
||||||
// Find last compact_boundary — the standard anchor point
|
|
||||||
let boundaryIdx = -1
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = messages[i]!
|
|
||||||
if (
|
|
||||||
msg.type === 'system' &&
|
|
||||||
(msg as Record<string, unknown>).subtype === 'compact_boundary'
|
|
||||||
) {
|
|
||||||
boundaryIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const keepFrom =
|
|
||||||
boundaryIdx >= 0
|
|
||||||
? boundaryIdx
|
|
||||||
: Math.max(0, messages.length - PROACTIVE_TRUNCATE_MIN_TAIL)
|
|
||||||
if (keepFrom === 0) return messages
|
|
||||||
|
|
||||||
return messages.slice(keepFrom)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
// Host analytics adapter — bridges logEvent to mcp-client's AnalyticsSink interface
|
|
||||||
|
|
||||||
import type { AnalyticsSink } from '@claude-code-best/mcp-client'
|
|
||||||
import {
|
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
||||||
logEvent,
|
|
||||||
} from '../../analytics/index.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an AnalyticsSink implementation that delegates to the host's logEvent.
|
|
||||||
*/
|
|
||||||
export function createMcpAnalytics(): AnalyticsSink {
|
|
||||||
return {
|
|
||||||
trackEvent(event: string, metadata: Record<string, unknown>) {
|
|
||||||
logEvent(
|
|
||||||
event,
|
|
||||||
metadata as Record<
|
|
||||||
string,
|
|
||||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
|
||||||
>,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Host auth provider adapter — bridges OAuth token management to mcp-client's AuthProvider interface
|
|
||||||
|
|
||||||
import type { AuthProvider } from '@claude-code-best/mcp-client'
|
|
||||||
import {
|
|
||||||
getClaudeAIOAuthTokens,
|
|
||||||
checkAndRefreshOAuthTokenIfNeeded,
|
|
||||||
handleOAuth401Error,
|
|
||||||
} from '../../../utils/auth.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an AuthProvider implementation using the host's OAuth token management.
|
|
||||||
*/
|
|
||||||
export function createMcpAuth(): AuthProvider {
|
|
||||||
return {
|
|
||||||
async getTokens() {
|
|
||||||
const tokens = getClaudeAIOAuthTokens()
|
|
||||||
if (!tokens) return null
|
|
||||||
return { accessToken: tokens.accessToken }
|
|
||||||
},
|
|
||||||
async refreshTokens() {
|
|
||||||
await checkAndRefreshOAuthTokenIfNeeded()
|
|
||||||
},
|
|
||||||
async handleOAuthError(error: unknown) {
|
|
||||||
const currentToken = getClaudeAIOAuthTokens()?.accessToken ?? ''
|
|
||||||
await handleOAuth401Error(currentToken)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Host feature gate adapter — bridges feature() to mcp-client's FeatureGate interface
|
|
||||||
|
|
||||||
import type { FeatureGate } from '@claude-code-best/mcp-client'
|
|
||||||
import { feature } from 'bun:bundle'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a FeatureGate implementation using the host's feature flag system.
|
|
||||||
*/
|
|
||||||
export function createMcpFeatureGate(): FeatureGate {
|
|
||||||
return {
|
|
||||||
isEnabled(flag: string) {
|
|
||||||
return feature(flag)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Host HTTP config adapter — bridges getUserAgent/getSessionId to mcp-client's HttpConfig interface
|
|
||||||
|
|
||||||
import type { HttpConfig } from '@claude-code-best/mcp-client'
|
|
||||||
import { getMCPUserAgent } from '../../../utils/http.js'
|
|
||||||
import { getSessionId } from '../../../bootstrap/state.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an HttpConfig implementation using the host's user agent and session ID.
|
|
||||||
*/
|
|
||||||
export function createMcpHttpConfig(): HttpConfig {
|
|
||||||
return {
|
|
||||||
getUserAgent: () => getMCPUserAgent(),
|
|
||||||
getSessionId: () => getSessionId(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// Host image processor adapter — bridges maybeResizeAndDownsampleImageBuffer to mcp-client's ImageProcessor interface
|
|
||||||
|
|
||||||
import type { ImageProcessor } from '@claude-code-best/mcp-client'
|
|
||||||
import { maybeResizeAndDownsampleImageBuffer } from '../../../utils/imageResizer.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an ImageProcessor implementation using the host's image resizing.
|
|
||||||
*/
|
|
||||||
export function createMcpImageProcessor(): ImageProcessor {
|
|
||||||
return {
|
|
||||||
async resizeAndDownsample(buffer: Buffer) {
|
|
||||||
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
||||||
buffer,
|
|
||||||
buffer.length,
|
|
||||||
'png',
|
|
||||||
)
|
|
||||||
return result.buffer
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Host dependency injection — assembles McpClientDependencies from host infrastructure
|
|
||||||
// This is the single entry point for creating the dependencies object used by createMcpManager()
|
|
||||||
|
|
||||||
import type { McpClientDependencies } from '@claude-code-best/mcp-client'
|
|
||||||
import { createMcpLogger } from './logger.js'
|
|
||||||
import { createMcpHttpConfig } from './httpConfig.js'
|
|
||||||
import { createMcpProxyConfig } from './proxy.js'
|
|
||||||
import { createMcpAnalytics } from './analytics.js'
|
|
||||||
import { createMcpSubprocessEnv } from './subprocessEnv.js'
|
|
||||||
import { createMcpStorage } from './storage.js'
|
|
||||||
import { createMcpImageProcessor } from './imageProcessor.js'
|
|
||||||
import { createMcpAuth } from './auth.js'
|
|
||||||
/**
|
|
||||||
* Creates the full set of MCP client dependencies using host infrastructure.
|
|
||||||
* All adapters are lazy — they only call into host modules when invoked.
|
|
||||||
*
|
|
||||||
* Note: featureGate is omitted because Bun's feature() requires string-literal
|
|
||||||
* arguments at compile time and cannot accept runtime variables. The interface
|
|
||||||
* field is optional and the mcp-client package does not use it currently.
|
|
||||||
*/
|
|
||||||
export function createMcpDependencies(): McpClientDependencies {
|
|
||||||
return {
|
|
||||||
logger: createMcpLogger(),
|
|
||||||
httpConfig: createMcpHttpConfig(),
|
|
||||||
proxy: createMcpProxyConfig(),
|
|
||||||
analytics: createMcpAnalytics(),
|
|
||||||
subprocessEnv: createMcpSubprocessEnv(),
|
|
||||||
storage: createMcpStorage(),
|
|
||||||
imageProcessor: createMcpImageProcessor(),
|
|
||||||
auth: createMcpAuth(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// Host logger adapter — bridges logMCPDebug/logMCPError to mcp-client's Logger interface
|
|
||||||
|
|
||||||
import type { Logger } from '@claude-code-best/mcp-client'
|
|
||||||
import { logMCPDebug, logMCPError } from '../../../utils/log.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Logger implementation that delegates to the host's MCP logging system.
|
|
||||||
*/
|
|
||||||
export function createMcpLogger(): Logger {
|
|
||||||
return {
|
|
||||||
debug(message: string, ...args: unknown[]) {
|
|
||||||
// Extract server name from bracketed prefix if present: [serverName] message
|
|
||||||
const match = message.match(/^\[([^\]]+)\]\s*(.*)/)
|
|
||||||
if (match) {
|
|
||||||
logMCPDebug(match[1], match[2])
|
|
||||||
}
|
|
||||||
// Silently ignore messages without server name prefix
|
|
||||||
},
|
|
||||||
info(message: string, ...args: unknown[]) {
|
|
||||||
const match = message.match(/^\[([^\]]+)\]\s*(.*)/)
|
|
||||||
if (match) {
|
|
||||||
logMCPDebug(match[1], match[2])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
warn(message: string, ...args: unknown[]) {
|
|
||||||
const match = message.match(/^\[([^\]]+)\]\s*(.*)/)
|
|
||||||
if (match) {
|
|
||||||
logMCPError(match[1], message)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error(message: string, ...args: unknown[]) {
|
|
||||||
const match = message.match(/^\[([^\]]+)\]\s*(.*)/)
|
|
||||||
if (match) {
|
|
||||||
logMCPError(match[1], args[0] ?? message)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// Host proxy config adapter — bridges proxy/MTLS to mcp-client's ProxyConfig interface
|
|
||||||
|
|
||||||
import type { ProxyConfig } from '@claude-code-best/mcp-client'
|
|
||||||
import {
|
|
||||||
getProxyFetchOptions,
|
|
||||||
getWebSocketProxyAgent,
|
|
||||||
getWebSocketProxyUrl,
|
|
||||||
} from '../../../utils/proxy.js'
|
|
||||||
import { getWebSocketTLSOptions } from '../../../utils/mtls.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a ProxyConfig implementation using the host's proxy and TLS settings.
|
|
||||||
*/
|
|
||||||
export function createMcpProxyConfig(): ProxyConfig {
|
|
||||||
return {
|
|
||||||
getFetchOptions() {
|
|
||||||
return getProxyFetchOptions() as Record<string, unknown>
|
|
||||||
},
|
|
||||||
getWebSocketAgent(url: string) {
|
|
||||||
return getWebSocketProxyAgent(url)
|
|
||||||
},
|
|
||||||
getWebSocketUrl(url: string) {
|
|
||||||
return getWebSocketProxyUrl(url)
|
|
||||||
},
|
|
||||||
getTLSOptions() {
|
|
||||||
const opts = getWebSocketTLSOptions()
|
|
||||||
return opts as Record<string, unknown> | undefined
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Host content storage adapter — bridges persistBinaryContent to mcp-client's ContentStorage interface
|
|
||||||
|
|
||||||
import type { ContentStorage } from '@claude-code-best/mcp-client'
|
|
||||||
import { persistBinaryContent } from '../../../utils/mcpOutputStorage.js'
|
|
||||||
import {
|
|
||||||
persistToolResult,
|
|
||||||
isPersistError,
|
|
||||||
} from '../../../utils/toolResultStorage.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a ContentStorage implementation using the host's binary persistence.
|
|
||||||
*/
|
|
||||||
export function createMcpStorage(): ContentStorage {
|
|
||||||
return {
|
|
||||||
async persistBinaryContent(data: Buffer, ext: string) {
|
|
||||||
const result = await persistBinaryContent(
|
|
||||||
data,
|
|
||||||
ext,
|
|
||||||
`mcp-adapter-${Date.now()}`,
|
|
||||||
)
|
|
||||||
if ('error' in result) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
return result.filepath
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Host subprocess environment adapter
|
|
||||||
|
|
||||||
import type { SubprocessEnvProvider } from '@claude-code-best/mcp-client'
|
|
||||||
import { subprocessEnv } from '../../../utils/subprocessEnv.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a SubprocessEnvProvider using the host's subprocess environment logic.
|
|
||||||
*/
|
|
||||||
export function createMcpSubprocessEnv(): SubprocessEnvProvider {
|
|
||||||
return {
|
|
||||||
getEnv(additional?: Record<string, string>) {
|
|
||||||
return { ...subprocessEnv(), ...additional } as Record<string, string>
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,34 +66,3 @@ export function generateRequestId(
|
|||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
return `${requestType}-${timestamp}@${agentId}`
|
return `${requestType}-${timestamp}@${agentId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a request ID into its components.
|
|
||||||
* Returns null if the request ID doesn't match the expected format.
|
|
||||||
*/
|
|
||||||
export function parseRequestId(
|
|
||||||
requestId: string,
|
|
||||||
): { requestType: string; timestamp: number; agentId: string } | null {
|
|
||||||
const atIndex = requestId.indexOf('@')
|
|
||||||
if (atIndex === -1) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = requestId.slice(0, atIndex)
|
|
||||||
const agentId = requestId.slice(atIndex + 1)
|
|
||||||
|
|
||||||
const lastDashIndex = prefix.lastIndexOf('-')
|
|
||||||
if (lastDashIndex === -1) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestType = prefix.slice(0, lastDashIndex)
|
|
||||||
const timestampStr = prefix.slice(lastDashIndex + 1)
|
|
||||||
const timestamp = parseInt(timestampStr, 10)
|
|
||||||
|
|
||||||
if (isNaN(timestamp)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { requestType, timestamp, agentId }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,31 +48,6 @@ export function _resetRecordingStateForTesting(): void {
|
|||||||
recordingState.timestamp = 0
|
recordingState.timestamp = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all .cast files for the current session.
|
|
||||||
* Returns paths sorted by filename (chronological by timestamp suffix).
|
|
||||||
*/
|
|
||||||
export function getSessionRecordingPaths(): string[] {
|
|
||||||
const sessionId = getSessionId()
|
|
||||||
const projectsDir = join(getClaudeConfigHomeDir(), 'projects')
|
|
||||||
const projectDir = join(projectsDir, sanitizePath(getOriginalCwd()))
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line custom-rules/no-sync-fs -- called during /share before upload, not in hot path
|
|
||||||
const entries = getFsImplementation().readdirSync(projectDir)
|
|
||||||
const names = (
|
|
||||||
typeof entries[0] === 'string'
|
|
||||||
? entries
|
|
||||||
: (entries as { name: string }[]).map(e => e.name)
|
|
||||||
) as string[]
|
|
||||||
const files = names
|
|
||||||
.filter(f => f.startsWith(sessionId) && f.endsWith('.cast'))
|
|
||||||
.sort()
|
|
||||||
return files.map(f => join(projectDir, f))
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename the recording file to match the current session ID.
|
* Rename the recording file to match the current session ID.
|
||||||
* Called after --resume/--continue changes the session ID via switchSession().
|
* Called after --resume/--continue changes the session ID via switchSession().
|
||||||
@@ -124,14 +99,6 @@ function getTerminalSize(): { cols: number; rows: number } {
|
|||||||
return { cols, rows }
|
return { cols, rows }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush pending recording data to disk.
|
|
||||||
* Call before reading the .cast file (e.g., during /share).
|
|
||||||
*/
|
|
||||||
export async function flushAsciicastRecorder(): Promise<void> {
|
|
||||||
await recorder?.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install the asciicast recorder.
|
* Install the asciicast recorder.
|
||||||
* Wraps process.stdout.write to capture all terminal output with timestamps.
|
* Wraps process.stdout.write to capture all terminal output with timestamps.
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
/**
|
|
||||||
* OCR module using Windows.Media.Ocr.OcrEngine via PowerShell.
|
|
||||||
* Captures a screen region or window, then runs WinRT OCR to extract text.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ps as runPs } from './shared.js'
|
|
||||||
|
|
||||||
export interface OcrLine {
|
|
||||||
text: string
|
|
||||||
bounds: { x: number; y: number; w: number; h: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OcrResult {
|
|
||||||
text: string
|
|
||||||
lines: OcrLine[]
|
|
||||||
language: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyResult(language: string): OcrResult {
|
|
||||||
return { text: '', lines: [], language }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PowerShell script that:
|
|
||||||
* 1. Screenshots a screen region using CopyFromScreen
|
|
||||||
* 2. Saves to temp PNG
|
|
||||||
* 3. Loads via WinRT BitmapDecoder -> SoftwareBitmap
|
|
||||||
* 4. Runs OcrEngine.RecognizeAsync
|
|
||||||
* 5. Outputs JSON with text, lines, and bounding rects
|
|
||||||
*/
|
|
||||||
function buildOcrRegionScript(
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
lang: string,
|
|
||||||
): string {
|
|
||||||
return `
|
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
||||||
|
|
||||||
# Load WinRT types
|
|
||||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime]
|
|
||||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType = WindowsRuntime]
|
|
||||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType = WindowsRuntime]
|
|
||||||
$null = [Windows.Storage.StorageFile, Windows.Foundation, ContentType = WindowsRuntime]
|
|
||||||
$null = [Windows.Storage.Streams.RandomAccessStream, Windows.Foundation, ContentType = WindowsRuntime]
|
|
||||||
$null = [Windows.Globalization.Language, Windows.Foundation, ContentType = WindowsRuntime]
|
|
||||||
|
|
||||||
# Await helper for WinRT async operations
|
|
||||||
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
|
|
||||||
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and
|
|
||||||
$_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1'
|
|
||||||
})[0]
|
|
||||||
Function Await($WinRtTask, $ResultType) {
|
|
||||||
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
|
|
||||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
|
||||||
$netTask.Wait(-1) | Out-Null
|
|
||||||
$netTask.Result
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
# Step 1: Screenshot region
|
|
||||||
$bmp = New-Object System.Drawing.Bitmap(${w}, ${h})
|
|
||||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
||||||
$g.CopyFromScreen(${x}, ${y}, 0, 0, (New-Object System.Drawing.Size(${w}, ${h})))
|
|
||||||
$g.Dispose()
|
|
||||||
|
|
||||||
# Step 2: Save to temp file
|
|
||||||
$tmpFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "ocrtemp_$([guid]::NewGuid().ToString('N')).png")
|
|
||||||
$bmp.Save($tmpFile, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
||||||
$bmp.Dispose()
|
|
||||||
|
|
||||||
# Step 3: Open as StorageFile -> BitmapDecoder -> SoftwareBitmap
|
|
||||||
$storageFile = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($tmpFile)) ([Windows.Storage.StorageFile])
|
|
||||||
$stream = Await ($storageFile.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
|
|
||||||
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
|
|
||||||
$softwareBmp = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
|
||||||
|
|
||||||
# Step 4: Create OCR engine
|
|
||||||
$ocrLang = New-Object Windows.Globalization.Language('${lang}')
|
|
||||||
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage($ocrLang)
|
|
||||||
if ($engine -eq $null) {
|
|
||||||
# Fallback to en-US
|
|
||||||
$ocrLang = New-Object Windows.Globalization.Language('en-US')
|
|
||||||
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage($ocrLang)
|
|
||||||
}
|
|
||||||
if ($engine -eq $null) {
|
|
||||||
Write-Output '{"text":"","lines":[],"language":"${lang}"}'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 5: Run OCR
|
|
||||||
$ocrResult = Await ($engine.RecognizeAsync($softwareBmp)) ([Windows.Media.Ocr.OcrResult])
|
|
||||||
|
|
||||||
# Step 6: Extract lines with bounding rects
|
|
||||||
$lines = @()
|
|
||||||
foreach ($line in $ocrResult.Lines) {
|
|
||||||
$minX = [double]::MaxValue; $minY = [double]::MaxValue
|
|
||||||
$maxX = 0.0; $maxY = 0.0
|
|
||||||
foreach ($word in $line.Words) {
|
|
||||||
$r = $word.BoundingRect
|
|
||||||
if ($r.X -lt $minX) { $minX = $r.X }
|
|
||||||
if ($r.Y -lt $minY) { $minY = $r.Y }
|
|
||||||
if (($r.X + $r.Width) -gt $maxX) { $maxX = $r.X + $r.Width }
|
|
||||||
if (($r.Y + $r.Height) -gt $maxY) { $maxY = $r.Y + $r.Height }
|
|
||||||
}
|
|
||||||
$lines += @{
|
|
||||||
text = $line.Text
|
|
||||||
bounds = @{
|
|
||||||
x = [int]$minX
|
|
||||||
y = [int]$minY
|
|
||||||
w = [int]($maxX - $minX)
|
|
||||||
h = [int]($maxY - $minY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = @{
|
|
||||||
text = $ocrResult.Text
|
|
||||||
lines = $lines
|
|
||||||
language = $ocrLang.LanguageTag
|
|
||||||
}
|
|
||||||
Write-Output (ConvertTo-Json $output -Depth 4 -Compress)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
$stream.Dispose()
|
|
||||||
Remove-Item $tmpFile -ErrorAction SilentlyContinue
|
|
||||||
} catch {
|
|
||||||
Write-Output '{"text":"","lines":[],"language":"${lang}"}'
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PowerShell script to get a window's bounding rect by title.
|
|
||||||
*/
|
|
||||||
function buildGetWindowRectScript(windowTitle: string): string {
|
|
||||||
const escaped = windowTitle.replace(/'/g, "''")
|
|
||||||
return `
|
|
||||||
Add-Type @'
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
public class WinRect {
|
|
||||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
|
|
||||||
public static extern IntPtr FindWindow(string c, string t);
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct RECT { public int L, T, R, B; }
|
|
||||||
public static string Get(string title) {
|
|
||||||
IntPtr hwnd = FindWindow(null, title);
|
|
||||||
if (hwnd == IntPtr.Zero) return "NOT_FOUND";
|
|
||||||
RECT r; GetWindowRect(hwnd, out r);
|
|
||||||
int w = r.R - r.L; int h = r.B - r.T;
|
|
||||||
if (w <= 0 || h <= 0) return "INVALID_SIZE";
|
|
||||||
return r.L + "," + r.T + "," + w + "," + h;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
[WinRect]::Get('${escaped}')
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOcrOutput(raw: string, lang: string): OcrResult {
|
|
||||||
if (!raw) return emptyResult(lang)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
return {
|
|
||||||
text: parsed.text ?? '',
|
|
||||||
lines: Array.isArray(parsed.lines)
|
|
||||||
? parsed.lines.map((l: any) => ({
|
|
||||||
text: l.text ?? '',
|
|
||||||
bounds: {
|
|
||||||
x: l.bounds?.x ?? 0,
|
|
||||||
y: l.bounds?.y ?? 0,
|
|
||||||
w: l.bounds?.w ?? 0,
|
|
||||||
h: l.bounds?.h ?? 0,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
language: parsed.language ?? lang,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return emptyResult(lang)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform OCR on a screen region.
|
|
||||||
* Screenshots the specified rectangle, then runs WinRT OcrEngine.
|
|
||||||
*
|
|
||||||
* @param x - Left coordinate
|
|
||||||
* @param y - Top coordinate
|
|
||||||
* @param w - Width in pixels
|
|
||||||
* @param h - Height in pixels
|
|
||||||
* @param lang - BCP-47 language tag (default 'en-US'). Confirmed: 'en-US', 'zh-Hans-CN'
|
|
||||||
*/
|
|
||||||
export async function ocrRegion(
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
lang?: string,
|
|
||||||
): Promise<OcrResult> {
|
|
||||||
const language = lang ?? 'en-US'
|
|
||||||
if (w <= 0 || h <= 0) return emptyResult(language)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const script = buildOcrRegionScript(x, y, w, h, language)
|
|
||||||
const raw = runPs(script)
|
|
||||||
return parseOcrOutput(raw, language)
|
|
||||||
} catch {
|
|
||||||
return emptyResult(language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform OCR on a specific window by its title.
|
|
||||||
* Gets the window rect, then delegates to ocrRegion.
|
|
||||||
*
|
|
||||||
* @param windowTitle - Exact window title to find via FindWindow
|
|
||||||
* @param lang - BCP-47 language tag (default 'en-US')
|
|
||||||
*/
|
|
||||||
export async function ocrWindow(
|
|
||||||
windowTitle: string,
|
|
||||||
lang?: string,
|
|
||||||
): Promise<OcrResult> {
|
|
||||||
const language = lang ?? 'en-US'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rectScript = buildGetWindowRectScript(windowTitle)
|
|
||||||
const raw = runPs(rectScript)
|
|
||||||
const trimmed = raw.trim()
|
|
||||||
|
|
||||||
if (!trimmed || trimmed === 'NOT_FOUND' || trimmed === 'INVALID_SIZE') {
|
|
||||||
return emptyResult(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = trimmed.split(',')
|
|
||||||
if (parts.length !== 4) return emptyResult(language)
|
|
||||||
|
|
||||||
const [x, y, w, h] = parts.map(Number)
|
|
||||||
if (!w || !h) return emptyResult(language)
|
|
||||||
|
|
||||||
return ocrRegion(x, y, w, h, lang)
|
|
||||||
} catch {
|
|
||||||
return emptyResult(language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,8 @@ export async function validateManifest(
|
|||||||
const errors = parseResult.error.flatten()
|
const errors = parseResult.error.flatten()
|
||||||
const errorMessages = [
|
const errorMessages = [
|
||||||
...Object.entries(errors.fieldErrors).map(
|
...Object.entries(errors.fieldErrors).map(
|
||||||
([field, errs]) => `${field}: ${(errs as any)?.join(', ')}`,
|
([field, errs]) =>
|
||||||
|
`${field}: ${(errs as string[] | undefined)?.join(', ')}`,
|
||||||
),
|
),
|
||||||
...(errors.formErrors || []),
|
...(errors.formErrors || []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ export function getMcpInstructionsDelta(
|
|||||||
attachmentCount++
|
attachmentCount++
|
||||||
if (msg.attachment!.type !== 'mcp_instructions_delta') continue
|
if (msg.attachment!.type !== 'mcp_instructions_delta') continue
|
||||||
midCount++
|
midCount++
|
||||||
for (const n of (msg.attachment! as any).addedNames) announced.add(n)
|
const delta = msg.attachment! as unknown as McpInstructionsDelta
|
||||||
for (const n of (msg.attachment! as any).removedNames) announced.delete(n)
|
for (const n of delta.addedNames) announced.add(n)
|
||||||
|
for (const n of delta.removedNames) announced.delete(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = mcpClients.filter(
|
const connected = mcpClients.filter(
|
||||||
|
|||||||
@@ -1,22 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
* Shared infrastructure for profiler modules (startupProfiler, queryProfiler,
|
* Shared infrastructure for profiler modules (startupProfiler, queryProfiler,
|
||||||
* headlessProfiler). All three use the same perf_hooks timeline and the same
|
* headlessProfiler).
|
||||||
* line format for detailed reports.
|
*
|
||||||
|
* Uses process.hrtime.bigint() for timing instead of perf_hooks.performance
|
||||||
|
* to avoid a Bun/JSC memory leak: JSC's Performance object stores marks in a
|
||||||
|
* C++ Vector that never shrinks even after clearMarks(). Long-running sessions
|
||||||
|
* (daemon, /loop) accumulate hundreds of MB of dead capacity.
|
||||||
|
*
|
||||||
|
* The LightweightPerf class provides the same interface the profilers need
|
||||||
|
* (mark, getEntriesByType, clearMarks, now) backed by a plain JS Map.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { performance as PerformanceType } from 'perf_hooks'
|
|
||||||
import { formatFileSize } from './format.js'
|
import { formatFileSize } from './format.js'
|
||||||
|
|
||||||
// Lazy-load performance API only when profiling is enabled.
|
/** Minimal PerformanceEntry-like object used by profilers */
|
||||||
// Shared across all profilers — perf_hooks.performance is a process-wide singleton.
|
export interface CheckpointEntry {
|
||||||
let performance: typeof PerformanceType | null = null
|
readonly name: string
|
||||||
|
readonly startTime: number
|
||||||
|
readonly entryType: 'mark'
|
||||||
|
}
|
||||||
|
|
||||||
export function getPerformance(): typeof PerformanceType {
|
/**
|
||||||
if (!performance) {
|
* Lightweight replacement for perf_hooks.performance that stores marks in a
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
* plain JavaScript Map instead of JSC's C++ Vector. This avoids the memory
|
||||||
performance = require('perf_hooks').performance
|
* leak where clearMarks() sets the count to 0 but never frees Vector capacity.
|
||||||
|
*/
|
||||||
|
class LightweightPerf {
|
||||||
|
private marks = new Map<string, number>()
|
||||||
|
private _origin: number
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._origin = Number(process.hrtime.bigint() / 1000n) / 1000
|
||||||
}
|
}
|
||||||
return performance!
|
|
||||||
|
mark(name: string): void {
|
||||||
|
this.marks.set(name, this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntriesByType(type: 'mark'): CheckpointEntry[] {
|
||||||
|
if (type !== 'mark') return []
|
||||||
|
const entries: CheckpointEntry[] = []
|
||||||
|
for (const [name, startTime] of this.marks) {
|
||||||
|
entries.push({ name, startTime, entryType: 'mark' })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMarks(name?: string): void {
|
||||||
|
if (name !== undefined) {
|
||||||
|
this.marks.delete(name)
|
||||||
|
} else {
|
||||||
|
this.marks.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now(): number {
|
||||||
|
return Number(process.hrtime.bigint() / 1000n) / 1000 - this._origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton — shared across all profilers (same as the old perf_hooks singleton)
|
||||||
|
const perf = new LightweightPerf()
|
||||||
|
|
||||||
|
export function getPerformance(): LightweightPerf {
|
||||||
|
return perf
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMs(ms: number): string {
|
export function formatMs(ms: number): string {
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export function extractConversationText(messages: Message[]): string {
|
|||||||
if ('isMeta' in msg && msg.isMeta) continue
|
if ('isMeta' in msg && msg.isMeta) continue
|
||||||
if (
|
if (
|
||||||
'origin' in msg &&
|
'origin' in msg &&
|
||||||
(msg as any).origin &&
|
(msg as unknown as { origin?: { kind?: string } }).origin &&
|
||||||
(msg as any).origin.kind !== 'human'
|
(msg as unknown as { origin: { kind?: string } }).origin.kind !== 'human'
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
const content = msg.message!.content
|
const content = msg.message!.content
|
||||||
@@ -116,7 +116,9 @@ export async function generateSessionTitle(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = extractTextContent(result.message.content as any)
|
const text = extractTextContent(
|
||||||
|
result.message.content as readonly { readonly type: string }[],
|
||||||
|
)
|
||||||
|
|
||||||
const parsed = titleSchema().safeParse(safeParseJSON(text))
|
const parsed = titleSchema().safeParse(safeParseJSON(text))
|
||||||
const title = parsed.success ? parsed.data.title.trim() || null : null
|
const title = parsed.success ? parsed.data.title.trim() || null : null
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ export function profileReport(): void {
|
|||||||
logForDebugging('Startup profiling report:')
|
logForDebugging('Startup profiling report:')
|
||||||
logForDebugging(getReport())
|
logForDebugging(getReport())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear startup marks to prevent PerformanceMark accumulation in long-lived
|
||||||
|
// processes (daemon, cron). After this point startup marks are no longer needed
|
||||||
|
// — the report has been written and the Statsig event has been logged.
|
||||||
|
const perf = getPerformance()
|
||||||
|
perf.clearMarks()
|
||||||
|
memorySnapshots.length = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDetailedProfilingEnabled(): boolean {
|
export function isDetailedProfilingEnabled(): boolean {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {}
|
|
||||||
export const watchSystemTheme: (
|
|
||||||
querier: unknown,
|
|
||||||
setTheme: React.Dispatch<
|
|
||||||
React.SetStateAction<import('./systemTheme.js').SystemTheme>
|
|
||||||
>,
|
|
||||||
) => () => void = () => () => {}
|
|
||||||
@@ -206,49 +206,10 @@ async function getOtlpReaders() {
|
|||||||
|
|
||||||
return exporters.map(exporter => {
|
return exporters.map(exporter => {
|
||||||
if ('export' in exporter) {
|
if ('export' in exporter) {
|
||||||
const reader = new PeriodicExportingMetricReader({
|
return new PeriodicExportingMetricReader({
|
||||||
exporter,
|
exporter,
|
||||||
exportIntervalMillis: exportInterval,
|
exportIntervalMillis: exportInterval,
|
||||||
})
|
})
|
||||||
// Wrap the export callback to auto-shutdown the reader on auth
|
|
||||||
// failures (401/403). Without this the PeriodicExportingMetricReader's
|
|
||||||
// internal setInterval keeps retrying forever, leaking handles.
|
|
||||||
const originalExport = (
|
|
||||||
exporter as unknown as {
|
|
||||||
export: (
|
|
||||||
metrics: unknown,
|
|
||||||
callback: (result: { error?: Error }) => void,
|
|
||||||
) => unknown
|
|
||||||
}
|
|
||||||
).export.bind(exporter)
|
|
||||||
;(
|
|
||||||
exporter as unknown as {
|
|
||||||
export: (
|
|
||||||
metrics: unknown,
|
|
||||||
callback: (result: { error?: Error }) => void,
|
|
||||||
) => unknown
|
|
||||||
}
|
|
||||||
).export = (metrics, callback) => {
|
|
||||||
return originalExport(metrics, result => {
|
|
||||||
if (result.error) {
|
|
||||||
const msg = result.error.message || ''
|
|
||||||
if (
|
|
||||||
msg.includes('401') ||
|
|
||||||
msg.includes('403') ||
|
|
||||||
msg.includes('Unauthorized') ||
|
|
||||||
msg.includes('authentication')
|
|
||||||
) {
|
|
||||||
logForDebugging(
|
|
||||||
`[3P telemetry] Auth error detected, shutting down metric reader`,
|
|
||||||
{ level: 'error' },
|
|
||||||
)
|
|
||||||
void reader.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
callback(result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return reader
|
|
||||||
}
|
}
|
||||||
return exporter
|
return exporter
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,15 +48,6 @@ function isInternalWarning(warning: Error): boolean {
|
|||||||
// Store reference to our warning handler so we can detect if it's already installed
|
// Store reference to our warning handler so we can detect if it's already installed
|
||||||
let warningHandler: ((warning: Error) => void) | null = null
|
let warningHandler: ((warning: Error) => void) | null = null
|
||||||
|
|
||||||
// For testing only - allows resetting the warning handler state
|
|
||||||
export function resetWarningHandler(): void {
|
|
||||||
if (warningHandler) {
|
|
||||||
process.removeListener('warning', warningHandler)
|
|
||||||
}
|
|
||||||
warningHandler = null
|
|
||||||
warningCounts.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initializeWarningHandler(): void {
|
export function initializeWarningHandler(): void {
|
||||||
// Only set up handler once - check if our handler is already installed
|
// Only set up handler once - check if our handler is already installed
|
||||||
const currentListeners = process.listeners('warning')
|
const currentListeners = process.listeners('warning')
|
||||||
|
|||||||
Reference in New Issue
Block a user