diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce8a17e --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Claude Code (Reverse-Engineered) + +Anthropic 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 核心功能跑通,必要时删减次级能力。 + +## 核心能力 + +- API 通信(Anthropic SDK / Bedrock / Vertex) +- Bash / FileRead / FileWrite / FileEdit 等核心工具 +- REPL 交互界面(ink 终端渲染) +- 对话历史与会话管理 +- 权限系统 +- Agent / 子代理系统 + +## 已删减模块 + +| 模块 | 处理方式 | +|------|----------| +| Computer Use (`@ant/computer-use-*`) | stub | +| Claude for Chrome MCP | stub | +| Magic Docs / Voice Mode / LSP Server | 移除 | +| Analytics / GrowthBook / Sentry | 空实现 | +| Plugins / Marketplace / Desktop Upsell | 移除 | +| Ultraplan / Tungsten / Auto Dream | 移除 | +| MCP OAuth/IDP | 简化 | +| DAEMON / BRIDGE / BG_SESSIONS / TEMPLATES 等 | feature flag 关闭 | + +## 快速开始 + +### 环境要求 + +- [Bun](https://bun.sh/) >= 1.0 +- Node.js >= 18(部分依赖需要) +- 有效的 Anthropic API Key(或 Bedrock / Vertex 凭据) + +### 安装 + +```bash +bun install +``` + +### 运行 + +```bash +# 开发模式(watch) +bun run dev + +# 直接运行 +bun run src/entrypoints/cli.tsx + +# 管道模式(-p) +echo "say hello" | bun run src/entrypoints/cli.tsx -p + +# 构建 +bun run build +``` + +构建产物输出到 `dist/cli.js`(~25 MB,5300+ 模块)。 + +## 项目结构 + +``` +claude-code/ +├── src/ +│ ├── entrypoints/ +│ │ ├── cli.tsx # 入口文件(含 MACRO/feature polyfill) +│ │ └── sdk/ # SDK 子模块 stub +│ ├── main.tsx # 主 CLI 逻辑(Commander 定义) +│ └── types/ +│ ├── global.d.ts # 全局变量/宏声明 +│ └── internal-modules.d.ts # 内部 npm 包类型声明 +├── packages/ # Monorepo workspace 包 +│ ├── color-diff-napi/ # 完整实现(终端 color diff) +│ ├── modifiers-napi/ # stub(macOS 修饰键检测) +│ ├── audio-capture-napi/ # stub +│ ├── image-processor-napi/# stub +│ ├── url-handler-napi/ # stub +│ └── @ant/ # Anthropic 内部包 stub +│ ├── claude-for-chrome-mcp/ +│ ├── computer-use-mcp/ +│ ├── computer-use-input/ +│ └── computer-use-swift/ +├── scripts/ # 自动化 stub 生成脚本 +├── dist/ # 构建输出 +└── package.json # Bun workspaces monorepo 配置 +``` + +## 技术说明 + +### 运行时 Polyfill + +入口文件 `src/entrypoints/cli.tsx` 顶部注入了必要的 polyfill: + +- `feature()` — 所有 feature flag 返回 `false`,跳过未实现分支 +- `globalThis.MACRO` — 模拟构建时宏注入(VERSION 等) + +### 类型状态 + +仍有 ~1341 个 tsc 错误,均为反编译产生的源码级类型问题(`unknown` / `never` / `{}`),**不影响 Bun 运行时**。 + +### Monorepo + +项目采用 Bun workspaces 管理内部包。原先手工放在 `node_modules/` 下的 stub 已统一迁入 `packages/`,通过 `workspace:*` 解析。 + +## 许可证 + +本项目仅供学习研究用途。Claude Code 的所有权利归 [Anthropic](https://www.anthropic.com/) 所有。 diff --git a/RECORD.md b/RECORD.md index 90f4d18..fe3756d 100644 --- a/RECORD.md +++ b/RECORD.md @@ -103,3 +103,57 @@ const feature = (_name: string) => false; // 所有 feature flag 分支被跳 | `scripts/create-type-stubs.mjs` | 自动 stub 生成脚本 | | `scripts/fix-default-stubs.mjs` | 修复默认导出 stub | | `scripts/fix-missing-exports.mjs` | 补全缺失导出 | + +--- + +## 五、Monorepo 改造(2026-03-31) + +### 5.1 背景 + +`color-diff-napi` 原先是手工放在 `node_modules/` 下的 stub 文件,导出的是普通对象而非 class,导致 `new ColorDiff(...)` 报错: +``` +ERROR Object is not a constructor (evaluating 'new ColorDiff(patch, firstLine, filePath, fileContent)') +``` +同时 `@ant/*`、其他 `*-napi` 包也只有 `declare module` 类型声明,无运行时实现。 + +### 5.2 方案 + +将项目改造为 **Bun workspaces monorepo**,所有内部包统一放在 `packages/` 下,通过 `workspace:*` 依赖解析。 + +### 5.3 创建的 workspace 包 + +| 包名 | 路径 | 类型 | +|------|------|------| +| `color-diff-napi` | `packages/color-diff-napi/` | 完整实现(~1000行 TS,从 `src/native-ts/color-diff/` 移入) | +| `modifiers-napi` | `packages/modifiers-napi/` | stub(macOS 修饰键检测) | +| `audio-capture-napi` | `packages/audio-capture-napi/` | stub | +| `image-processor-napi` | `packages/image-processor-napi/` | stub | +| `url-handler-napi` | `packages/url-handler-napi/` | stub | +| `@ant/claude-for-chrome-mcp` | `packages/@ant/claude-for-chrome-mcp/` | stub | +| `@ant/computer-use-mcp` | `packages/@ant/computer-use-mcp/` | stub(含 subpath exports: sentinelApps, types) | +| `@ant/computer-use-input` | `packages/@ant/computer-use-input/` | stub | +| `@ant/computer-use-swift` | `packages/@ant/computer-use-swift/` | stub | + +### 5.4 新增的 npm 依赖 + +| 包名 | 原因 | +|------|------| +| `@opentelemetry/semantic-conventions` | 构建报错缺失 | +| `fflate` | `src/utils/dxt/zip.ts` 动态 import | +| `vscode-jsonrpc` | `src/services/lsp/LSPClient.ts` import | +| `@aws-sdk/credential-provider-node` | `src/utils/proxy.ts` 动态 import | + +### 5.5 关键变更 + +- `package.json`:添加 `workspaces`,添加所有 workspace 包和缺失 npm 依赖 +- `src/types/internal-modules.d.ts`:删除已移入 monorepo 的 `declare module` 块,仅保留 `bun:bundle`、`bun:ffi`、`@anthropic-ai/mcpb` +- `src/native-ts/color-diff/` → `packages/color-diff-napi/src/`:移动并内联了对 `stringWidth` 和 `logError` 的依赖 +- 删除 `node_modules/color-diff-napi/` 手工 stub + +### 5.6 构建验证 + +``` +$ bun run build +Bundled 5326 modules in 491ms + cli.js 25.74 MB (entry point) +``` diff --git a/package.json b/package.json index e1023ee..45ea10a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "scripts": { "build": "bun build src/entrypoints/cli.tsx --outdir dist --target bun", - "dev": "bun run --watch src/entrypoints/cli.tsx" + "dev": "bun run src/entrypoints/cli.tsx" }, "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", diff --git a/packages/@ant/computer-use-input/src/index.ts b/packages/@ant/computer-use-input/src/index.ts index e0c61b1..9a0d5c4 100644 --- a/packages/@ant/computer-use-input/src/index.ts +++ b/packages/@ant/computer-use-input/src/index.ts @@ -1,2 +1,44 @@ -export class ComputerUseInput {} -export class ComputerUseInputAPI {} +interface FrontmostAppInfo { + bundleId: string + appName: string +} + +export class ComputerUseInputAPI { + declare moveMouse: ( + x: number, + y: number, + animated: boolean, + ) => Promise + + declare key: ( + key: string, + action: 'press' | 'release', + ) => Promise + + declare keys: (parts: string[]) => Promise + + declare mouseLocation: () => Promise<{ x: number; y: number }> + + declare mouseButton: ( + button: 'left' | 'right' | 'middle', + action: 'click' | 'press' | 'release', + count?: number, + ) => Promise + + declare mouseScroll: ( + amount: number, + direction: 'vertical' | 'horizontal', + ) => Promise + + declare typeText: (text: string) => Promise + + declare getFrontmostAppInfo: () => FrontmostAppInfo | null + + declare isSupported: true +} + +interface ComputerUseInputUnsupported { + isSupported: false +} + +export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported diff --git a/packages/@ant/computer-use-swift/src/index.ts b/packages/@ant/computer-use-swift/src/index.ts index 4bdfff9..a9fa116 100644 --- a/packages/@ant/computer-use-swift/src/index.ts +++ b/packages/@ant/computer-use-swift/src/index.ts @@ -1 +1,112 @@ -export class ComputerUseAPI {} +interface DisplayGeometry { + width: number + height: number + scaleFactor: number + displayId: number +} + +interface PrepareDisplayResult { + activated: string + hidden: string[] +} + +interface AppInfo { + bundleId: string + displayName: string +} + +interface InstalledApp { + bundleId: string + displayName: string + path: string + iconDataUrl?: string +} + +interface RunningApp { + bundleId: string + displayName: string +} + +interface ScreenshotResult { + base64: string + width: number + height: number +} + +interface ResolvePrepareCaptureResult { + base64: string + width: number + height: number +} + +interface WindowDisplayInfo { + bundleId: string + displayIds: number[] +} + +interface AppsAPI { + prepareDisplay( + allowlistBundleIds: string[], + surrogateHost: string, + displayId?: number, + ): Promise + previewHideSet( + bundleIds: string[], + displayId?: number, + ): Promise> + findWindowDisplays( + bundleIds: string[], + ): Promise> + appUnderPoint( + x: number, + y: number, + ): Promise + listInstalled(): Promise + iconDataUrl(path: string): string | null + listRunning(): RunningApp[] + open(bundleId: string): Promise + unhide(bundleIds: string[]): Promise +} + +interface DisplayAPI { + getSize(displayId?: number): DisplayGeometry + listAll(): DisplayGeometry[] +} + +interface ScreenshotAPI { + captureExcluding( + allowedBundleIds: string[], + quality: number, + targetW: number, + targetH: number, + displayId?: number, + ): Promise + captureRegion( + allowedBundleIds: string[], + x: number, + y: number, + w: number, + h: number, + outW: number, + outH: number, + quality: number, + displayId?: number, + ): Promise +} + +export class ComputerUseAPI { + declare apps: AppsAPI + declare display: DisplayAPI + declare screenshot: ScreenshotAPI + + declare resolvePrepareCapture: ( + allowedBundleIds: string[], + surrogateHost: string, + quality: number, + targetW: number, + targetH: number, + displayId?: number, + autoResolve?: boolean, + doHide?: boolean, + ) => Promise +} diff --git a/src/components/FeedbackSurvey/useFrustrationDetection.ts b/src/components/FeedbackSurvey/useFrustrationDetection.ts index 655d5da..b2f028a 100644 --- a/src/components/FeedbackSurvey/useFrustrationDetection.ts +++ b/src/components/FeedbackSurvey/useFrustrationDetection.ts @@ -1,2 +1,9 @@ // Auto-generated stub — replace with real implementation -export {}; +export function useFrustrationDetection( + _messages: unknown[], + _isLoading: boolean, + _hasActivePrompt: boolean, + _otherSurveyOpen: boolean, +): { state: 'closed' | 'open'; handleTranscriptSelect: () => void } { + return { state: 'closed', handleTranscriptSelect: () => {} }; +} diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 128e73c..b08bfd0 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -294,8 +294,8 @@ function PromptInput({ // otherwise bridge becomes an invisible selection stop. const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting); // Tmux pill (ant-only) — visible when there's an active tungsten session - const hasTungstenSession = useAppState(s => "external" === 'ant' && s.tungstenActiveSession !== undefined); - const tmuxFooterVisible = "external" === 'ant' && hasTungstenSession; + const hasTungstenSession = useAppState(s => ("external" as string) === 'ant' && s.tungstenActiveSession !== undefined); + const tmuxFooterVisible = ("external" as string) === 'ant' && hasTungstenSession; // WebBrowser pill — visible when a browser is open const bagelFooterVisible = useAppState(s => false); const teamContext = useAppState(s => s.teamContext); @@ -391,7 +391,7 @@ function PromptInput({ // exist. When only local_agent tasks are running (coordinator/fork mode), the // pill is absent, so the -1 sentinel would leave nothing visually selected. // In that case, skip -1 and treat 0 as the minimum selectable index. - const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !("external" === 'ant' && isPanelAgentTask(t))), [tasks]); + const hasBgTaskPill = useMemo(() => Object.values(tasks).some(t => isBackgroundTask(t) && !(("external" as string) === 'ant' && isPanelAgentTask(t))), [tasks]); const minCoordinatorIndex = hasBgTaskPill ? -1 : 0; // Clamp index when tasks complete and the list shrinks beneath the cursor useEffect(() => { @@ -455,7 +455,7 @@ function PromptInput({ // Panel shows retained-completed agents too (getVisibleAgentTasks), so the // pill must stay navigable whenever the panel has rows — not just when // something is running. - const tasksFooterVisible = (runningTaskCount > 0 || "external" === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree); + const tasksFooterVisible = (runningTaskCount > 0 || ("external" as string) === 'ant' && coordinatorTaskCount > 0) && !shouldHideTasksFooter(tasks, showSpinnerTree); const teamsFooterVisible = cachedTeams.length > 0; const footerItems = useMemo(() => [tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', teamsFooterVisible && 'teams', bridgeFooterVisible && 'bridge', companionFooterVisible && 'companion'].filter(Boolean) as FooterItem[], [tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, teamsFooterVisible, bridgeFooterVisible, companionFooterVisible]); @@ -1054,7 +1054,7 @@ function PromptInput({ clearBuffer(); resetHistory(); return; - } else if (result.error === 'no_team_context') { + } else if ('error' in result && result.error === 'no_team_context') { // No team context - fall through to normal prompt submission } else { // Unknown recipient - fall through to normal prompt submission @@ -1742,7 +1742,7 @@ function PromptInput({ useKeybindings({ 'footer:up': () => { // ↑ scrolls within the coordinator task list before leaving the pill - if (tasksSelected && "external" === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) { + if (tasksSelected && ("external" as string) === 'ant' && coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex) { setCoordinatorTaskIndex(prev => prev - 1); return; } @@ -1750,7 +1750,7 @@ function PromptInput({ }, 'footer:down': () => { // ↓ scrolls within the coordinator task list, never leaves the pill - if (tasksSelected && "external" === 'ant' && coordinatorTaskCount > 0) { + if (tasksSelected && ("external" as string) === 'ant' && coordinatorTaskCount > 0) { if (coordinatorTaskIndex < coordinatorTaskCount - 1) { setCoordinatorTaskIndex(prev => prev + 1); } @@ -1813,7 +1813,7 @@ function PromptInput({ } break; case 'tmux': - if ("external" === 'ant') { + if (("external" as string) === 'ant') { setAppState(prev => prev.tungstenPanelAutoHidden ? { ...prev, tungstenPanelAutoHidden: false @@ -2306,7 +2306,7 @@ function getInitialPasteId(messages: Message[]): number { if (message.type === 'user') { // Check image paste IDs if (message.imagePasteIds) { - for (const id of message.imagePasteIds) { + for (const id of message.imagePasteIds as number[]) { if (id > maxId) maxId = id; } } diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index e50bcdc..eef2fb0 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -143,11 +143,11 @@ function PromptInputFooter({ {isFullscreen ? null : } - {"external" === 'ant' && isUndercover() && undercover} + {("external" as string) === 'ant' && isUndercover() && undercover} - {"external" === 'ant' && } + {("external" as string) === 'ant' && } ; } export default memo(PromptInputFooter); diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 2f1bbd1..4a7e34a 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -260,7 +260,7 @@ function ModeIndicator({ const expandedView = useAppState(s_3 => s_3.expandedView); const showSpinnerTree = expandedView === 'teammates'; const prStatus = usePrStatus(isLoading, isPrStatusEnabled()); - const hasTmuxSession = useAppState(s_4 => "external" === 'ant' && s_4.tungstenActiveSession !== undefined); + const hasTmuxSession = useAppState(s_4 => ("external" as string) === 'ant' && s_4.tungstenActiveSession !== undefined); const nextTickAt = useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL); // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; @@ -274,7 +274,7 @@ function ModeIndicator({ const selGetState = useSelection().getState; const hasNextTick = nextTickAt !== null; const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false; - const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !("external" === 'ant' && isPanelAgentTask(t))), [tasks]); + const runningTaskCount = useMemo(() => count(Object.values(tasks), t => isBackgroundTask(t) && !(("external" as string) === 'ant' && isPanelAgentTask(t))), [tasks]); const tasksV2 = useTasksV2(); const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0; const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase(); @@ -365,7 +365,7 @@ function ModeIndicator({ // its click-target Box isn't nested inside the // wrapper (reconciler throws on Box-in-Text). // Tmux pill (ant-only) — appears right after tasks in nav order - ...("external" === 'ant' && hasTmuxSession ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [] : []), ...(shouldShowPrStatus ? [] : [])]; + ...(("external" as string) === 'ant' && hasTmuxSession ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams ? [] : []), ...(shouldShowPrStatus ? [] : [])]; // Check if any in-process teammates exist (for hint text cycling) const hasAnyInProcessTeammates = Object.values(tasks).some(t_2 => t_2.type === 'in_process_teammate' && t_2.status === 'running'); @@ -399,7 +399,7 @@ function ModeIndicator({ } // Add "↓ to manage tasks" hint when panel has visible rows - const hasCoordinatorTasks = "external" === 'ant' && getVisibleAgentTasks(tasks).length > 0; + const hasCoordinatorTasks = ("external" as string) === 'ant' && getVisibleAgentTasks(tasks).length > 0; // Tasks pill renders as a Box sibling (not a parts entry) so its // click-target Box isn't nested inside — the diff --git a/src/components/PromptInput/PromptInputFooterSuggestions.tsx b/src/components/PromptInput/PromptInputFooterSuggestions.tsx index 98dcfee..3a4904a 100644 --- a/src/components/PromptInput/PromptInputFooterSuggestions.tsx +++ b/src/components/PromptInput/PromptInputFooterSuggestions.tsx @@ -34,7 +34,7 @@ function getIcon(itemId: string): string { function isUnifiedSuggestion(itemId: string): boolean { return itemId.startsWith('file-') || itemId.startsWith('mcp-resource-') || itemId.startsWith('agent-'); } -const SuggestionItemRow = memo(function SuggestionItemRow(t0) { +const SuggestionItemRow = memo(function SuggestionItemRow(t0: { item: SuggestionItem; maxColumnWidth: number; isSelected: boolean }) { const $ = _c(36); const { item, diff --git a/src/components/PromptInput/ShimmeredInput.tsx b/src/components/PromptInput/ShimmeredInput.tsx index b6890e5..b1e5b3d 100644 --- a/src/components/PromptInput/ShimmeredInput.tsx +++ b/src/components/PromptInput/ShimmeredInput.tsx @@ -74,8 +74,8 @@ export function HighlightedInput(t0) { $[8] = lo; $[9] = hi; } else { - lo = $[8]; - hi = $[9]; + lo = $[8] as number; + hi = $[9] as number; } sweepStart = lo - 10; cycleLength = hi - lo + 20; diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 16c5e45..b976021 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -11,6 +11,10 @@ if (typeof globalThis.MACRO === "undefined") { VERSION_CHANGELOG: "", }; } +// Build-time constants — normally replaced by Bun bundler at compile time +(globalThis as any).BUILD_TARGET = "external"; +(globalThis as any).BUILD_ENV = "production"; +(globalThis as any).INTERFACE_TYPE = "stdio"; // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // eslint-disable-next-line custom-rules/no-top-level-side-effects diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index da90d54..136c04c 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -98,7 +98,8 @@ export type SDKMessage = { type: string; [key: string]: unknown } export type SDKUserMessage = { type: "user"; content: unknown; uuid: string; [key: string]: unknown } export type SDKUserMessageReplay = SDKUserMessage export type SDKAssistantMessage = { type: "assistant"; content: unknown; [key: string]: unknown } -export type SDKAssistantMessageError = { type: "assistant_error"; error: unknown; [key: string]: unknown } +export type SDKAssistantErrorMessage = { type: "assistant_error"; error: unknown; [key: string]: unknown } +export type SDKAssistantMessageError = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens' export type SDKPartialAssistantMessage = { type: "partial_assistant"; [key: string]: unknown } export type SDKResultMessage = { type: "result"; [key: string]: unknown } export type SDKResultSuccess = { type: "result_success"; [key: string]: unknown } diff --git a/src/hooks/notifs/useAntOrgWarningNotification.ts b/src/hooks/notifs/useAntOrgWarningNotification.ts index 655d5da..b178afe 100644 --- a/src/hooks/notifs/useAntOrgWarningNotification.ts +++ b/src/hooks/notifs/useAntOrgWarningNotification.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export {}; +export function useAntOrgWarningNotification(): void {} diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 11cc4d8..8918dd8 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -104,13 +104,13 @@ const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').V // Frustration detection is ant-only (dogfooding). Conditional require so external // builds eliminate the module entirely (including its two O(n) useMemos that run // on every messages change, plus the GrowthBook fetch). -const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ +const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = ("external" as string) === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ state: 'closed', handleTranscriptSelect: () => {} }); // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). -const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = "external" === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {}; +const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = ("external" as string) === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {}; // Dead code elimination: conditional import for coordinator mode const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{ name: string; @@ -218,9 +218,9 @@ import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCall import type { EffortValue } from '../utils/effort.js'; import { RemoteCallout } from '../components/RemoteCallout.js'; /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -const AntModelSwitchCallout = "external" === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null; -const shouldShowAntModelSwitch = "external" === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false; -const UndercoverAutoCallout = "external" === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null; +const AntModelSwitchCallout = ("external" as string) === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null; +const shouldShowAntModelSwitch = ("external" as string) === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false; +const UndercoverAutoCallout = ("external" as string) === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null; /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ import { activityManager } from '../utils/activityManager.js'; import { createAbortController } from '../utils/abortController.js'; @@ -601,7 +601,7 @@ export function REPL({ // Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+ // includes, and these were on the render path (hot during PageUp spam). const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []); - const moreRightEnabled = useMemo(() => "external" === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []); + const moreRightEnabled = useMemo(() => ("external" as string) === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []); const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); const disableMessageActions = feature('MESSAGE_ACTIONS') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant @@ -733,7 +733,7 @@ export function REPL({ const [showIdeOnboarding, setShowIdeOnboarding] = useState(false); // Dead code elimination: model switch callout state (ant-only) const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => { - if ("external" === 'ant') { + if (("external" as string) === 'ant') { return shouldShowAntModelSwitch(); } return false; @@ -1012,7 +1012,7 @@ export function REPL({ }, []); const [showUndercoverCallout, setShowUndercoverCallout] = useState(false); useEffect(() => { - if ("external" === 'ant') { + if (("external" as string) === 'ant') { void (async () => { // Wait for repo classification to settle (memoized, no-op if primed). const { @@ -2041,10 +2041,10 @@ export function REPL({ if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'; // Model switch callout (ant-only, eliminated from external builds) - if ("external" === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch'; + if (("external" as string) === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch'; // Undercover auto-enable explainer (ant-only, eliminated from external builds) - if ("external" === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout'; + if (("external" as string) === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout'; // Effort callout (shown once for Opus 4.6 users when effort is enabled) if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout'; @@ -2482,7 +2482,7 @@ export function REPL({ dynamicSkillDirTriggers: new Set(), discoveredSkillNames: discoveredSkillNamesRef.current, setResponseLength, - pushApiMetricsEntry: "external" === 'ant' ? (ttftMs: number) => { + pushApiMetricsEntry: ("external" as string) === 'ant' ? (ttftMs: number) => { const now = Date.now(); const baseline = responseLengthRef.current; apiMetricsRef.current.push({ @@ -2605,7 +2605,7 @@ export function REPL({ if (feature('PROACTIVE') || feature('KAIROS')) { proactiveModule?.setContextBlocked(false); } - } else if (newMessage.type === 'progress' && isEphemeralToolProgress(newMessage.data.type)) { + } else if ((newMessage as MessageType).type === 'progress' && isEphemeralToolProgress((newMessage as ProgressMessage).data.type)) { // Replace the previous ephemeral progress tick for the same tool // call instead of appending. Sleep/Bash emit a tick per second and // only the last one is rendered; appending blows up the messages @@ -2618,7 +2618,7 @@ export function REPL({ // "Initializing…" because it renders the full progress trail. setMessages(oldMessages => { const last = oldMessages.at(-1); - if (last?.type === 'progress' && last.parentToolUseID === newMessage.parentToolUseID && last.data.type === newMessage.data.type) { + if (last?.type === 'progress' && last.parentToolUseID === (newMessage as MessageType).parentToolUseID && last.data.type === (newMessage as ProgressMessage).data.type) { const copy = oldMessages.slice(); copy[copy.length - 1] = newMessage; return copy; @@ -2804,14 +2804,14 @@ export function REPL({ if (feature('BUDDY')) { void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : { ...prev, - companionReaction: reaction + companionReaction: reaction as string | undefined })); } queryCheckpoint('query_end'); // Capture ant-only API metrics before resetLoadingState clears the ref. // For multi-request turns (tool use loops), compute P50 across all requests. - if ("external" === 'ant' && apiMetricsRef.current.length > 0) { + if (("external" as string) === 'ant' && apiMetricsRef.current.length > 0) { const entries = apiMetricsRef.current; const ttfts = entries.map(e => e.ttftMs); // Compute per-request OTPS using only active streaming time and @@ -2939,7 +2939,7 @@ export function REPL({ // minutes — wiping the session made the pill disappear entirely, forcing // the user to re-invoke Tmux just to peek. Skip on abort so the panel // stays open for inspection (matches the turn-duration guard below). - if ("external" === 'ant' && !abortController.signal.aborted) { + if (("external" as string) === 'ant' && !abortController.signal.aborted) { setAppState(prev => { if (prev.tungstenActiveSession === undefined) return prev; if (prev.tungstenPanelAutoHidden === true) return prev; @@ -3062,7 +3062,7 @@ export function REPL({ } // Atomically: clear initial message, set permission mode and rules, and store plan for verification - const shouldStorePlanForVerification = initialMsg.message.planContent && "external" === 'ant' && isEnvTruthy(undefined); + const shouldStorePlanForVerification = initialMsg.message.planContent && ("external" as string) === 'ant' && isEnvTruthy(undefined); setAppState(prev => { // Build and apply permission updates (mode + allowedPrompts rules) let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext; @@ -3595,7 +3595,7 @@ export function REPL({ // Handler for when user presses 1 on survey thanks screen to share details const handleSurveyRequestFeedback = useCallback(() => { - const command = "external" === 'ant' ? '/issue' : '/feedback'; + const command = ("external" as string) === 'ant' ? '/issue' : '/feedback'; onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, @@ -4063,7 +4063,7 @@ export function REPL({ // - Workers receive permission responses via mailbox messages // - Leaders receive permission requests via mailbox messages - if ("external" === 'ant') { + if (("external" as string) === 'ant') { // Tasks mode: watch for tasks and auto-process them // eslint-disable-next-line react-hooks/rules-of-hooks // biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds @@ -4172,7 +4172,7 @@ export function REPL({ // Fall back to default behavior const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop'; - if ("external" === 'ant') { + if (("external" as string) === 'ant') { const cmd = currentHooks[completedCount]?.data.command; const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : ''; return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`; @@ -4581,7 +4581,7 @@ export function REPL({ {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && {toolJSX.jsx} } - {"external" === 'ant' && } + {("external" as string) === 'ant' && } {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} {showSpinner && 0} leaderIsIdle={!isLoading} />} @@ -4804,7 +4804,7 @@ export function REPL({ }); }} />} {focusedInputDialog === 'ide-onboarding' && setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />} - {"external" === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && { + {("external" as string) === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && { setShowModelSwitchCallout(false); if (selection === 'switch' && modelAlias) { setAppState(prev => ({ @@ -4814,7 +4814,7 @@ export function REPL({ })); } }} />} - {"external" === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && setShowUndercoverCallout(false)} />} + {("external" as string) === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && setShowUndercoverCallout(false)} />} {focusedInputDialog === 'effort-callout' && { setShowEffortCallout(false); if (selection !== 'dismiss') { @@ -4897,7 +4897,7 @@ export function REPL({ {/* Frustration-triggered transcript sharing prompt */} {frustrationDetection.state !== 'closed' && {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} {/* Skill improvement survey - appears when improvements detected (ant-only) */} - {"external" === 'ant' && skillImprovementSurvey.suggestion && } + {("external" as string) === 'ant' && skillImprovementSurvey.suggestion && } {showIssueFlagBanner && } {} } - {"external" === 'ant' && } + {("external" as string) === 'ant' && } {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} } /> diff --git a/src/state/AppState.tsx b/src/state/AppState.tsx index bdbb169..b6c47aa 100644 --- a/src/state/AppState.tsx +++ b/src/state/AppState.tsx @@ -139,7 +139,7 @@ function useAppStore(): AppStateStore { * const { text, promptId } = useAppState(s => s.promptSuggestion) // good * ``` */ -export function useAppState(selector) { +export function useAppState(selector: (state: AppState) => R): R { const $ = _c(3); const store = useAppStore(); let t0; @@ -183,7 +183,7 @@ const NOOP_SUBSCRIBE = () => () => {}; * Safe version of useAppState that returns undefined if called outside of AppStateProvider. * Useful for components that may be rendered in contexts where AppStateProvider isn't available. */ -export function useAppStateMaybeOutsideOfProvider(selector) { +export function useAppStateMaybeOutsideOfProvider(selector: (state: AppState) => R): R | undefined { const $ = _c(3); const store = useContext(AppStoreContext); let t0; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 39695ef..9a22a9f 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -58,4 +58,30 @@ declare function launchUltraplan(...args: unknown[]): void declare type T = any // Tungsten (internal) -declare function TungstenPill(): JSX.Element | null +declare function TungstenPill(props?: { key?: string; selected?: boolean }): JSX.Element | null + +// ============================================================================ +// Build-time constants — replaced by Bun bundler, polyfilled at runtime +// Using `string` (not literal types) so comparisons don't produce TS2367 +declare const BUILD_TARGET: string +declare const BUILD_ENV: string +declare const INTERFACE_TYPE: string + +// ============================================================================ +// Bun text/file loaders — allow importing non-TS assets as strings +declare module '*.md' { + const content: string + export default content +} +declare module '*.txt' { + const content: string + export default content +} +declare module '*.html' { + const content: string + export default content +} +declare module '*.css' { + const content: string + export default content +} diff --git a/src/types/message.ts b/src/types/message.ts index bfbc1e6..73b40e2 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -1,40 +1,62 @@ // Auto-generated stub — replace with real implementation -export type Message = any; -export type AssistantMessage = any; -export type AttachmentMessage = any; -export type ProgressMessage = any; -export type SystemLocalCommandMessage = any; -export type SystemMessage = any; -export type UserMessage = any; -export type NormalizedUserMessage = any; -export type RequestStartEvent = any; -export type StreamEvent = any; -export type SystemCompactBoundaryMessage = any; -export type TombstoneMessage = any; -export type ToolUseSummaryMessage = any; -export type MessageOrigin = any; -export type CompactMetadata = any; -export type SystemAPIErrorMessage = any; -export type SystemFileSnapshotMessage = any; -export type NormalizedAssistantMessage = any; -export type NormalizedMessage = any; -export type PartialCompactDirection = any; -export type StopHookInfo = any; -export type SystemAgentsKilledMessage = any; -export type SystemApiMetricsMessage = any; -export type SystemAwaySummaryMessage = any; -export type SystemBridgeStatusMessage = any; -export type SystemInformationalMessage = any; -export type SystemMemorySavedMessage = any; -export type SystemMessageLevel = any; -export type SystemMicrocompactBoundaryMessage = any; -export type SystemPermissionRetryMessage = any; -export type SystemScheduledTaskFireMessage = any; -export type SystemStopHookSummaryMessage = any; -export type SystemTurnDurationMessage = any; -export type GroupedToolUseMessage = any; -export type RenderableMessage = any; -export type CollapsedReadSearchGroup = any; -export type CollapsibleMessage = any; -export type HookResultMessage = any; -export type SystemThinkingMessage = any; +import type { UUID } from 'crypto' + +/** + * Base message type with discriminant `type` field and common properties. + * Individual message subtypes (UserMessage, AssistantMessage, etc.) extend + * this with narrower `type` literals and additional fields. + */ +export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' +export type Message = { + type: MessageType + uuid: UUID + isMeta?: boolean + isCompactSummary?: boolean + toolUseResult?: unknown + isVisibleInTranscriptOnly?: boolean + message?: { + role?: string + content?: string | Array<{ type: string; text?: string; [key: string]: unknown }> + usage?: Record + [key: string]: unknown + } + [key: string]: unknown +} +export type AssistantMessage = Message & { type: 'assistant' }; +export type AttachmentMessage = Message & { type: 'attachment' }; +export type ProgressMessage = Message & { type: 'progress' }; +export type SystemLocalCommandMessage = Message & { type: 'system' }; +export type SystemMessage = Message & { type: 'system' }; +export type UserMessage = Message & { type: 'user' }; +export type NormalizedUserMessage = UserMessage; +export type RequestStartEvent = { type: string; [key: string]: unknown }; +export type StreamEvent = { type: string; [key: string]: unknown }; +export type SystemCompactBoundaryMessage = Message & { type: 'system' }; +export type TombstoneMessage = Message; +export type ToolUseSummaryMessage = Message; +export type MessageOrigin = string; +export type CompactMetadata = Record; +export type SystemAPIErrorMessage = Message & { type: 'system' }; +export type SystemFileSnapshotMessage = Message & { type: 'system' }; +export type NormalizedAssistantMessage = AssistantMessage; +export type NormalizedMessage = Message; +export type PartialCompactDirection = string; +export type StopHookInfo = Record; +export type SystemAgentsKilledMessage = Message & { type: 'system' }; +export type SystemApiMetricsMessage = Message & { type: 'system' }; +export type SystemAwaySummaryMessage = Message & { type: 'system' }; +export type SystemBridgeStatusMessage = Message & { type: 'system' }; +export type SystemInformationalMessage = Message & { type: 'system' }; +export type SystemMemorySavedMessage = Message & { type: 'system' }; +export type SystemMessageLevel = string; +export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }; +export type SystemPermissionRetryMessage = Message & { type: 'system' }; +export type SystemScheduledTaskFireMessage = Message & { type: 'system' }; +export type SystemStopHookSummaryMessage = Message & { type: 'system' }; +export type SystemTurnDurationMessage = Message & { type: 'system' }; +export type GroupedToolUseMessage = Message; +export type RenderableMessage = Message; +export type CollapsedReadSearchGroup = Message; +export type CollapsibleMessage = Message; +export type HookResultMessage = Message; +export type SystemThinkingMessage = Message & { type: 'system' }; diff --git a/src/types/messageQueueTypes.ts b/src/types/messageQueueTypes.ts index 247671e..6543963 100644 --- a/src/types/messageQueueTypes.ts +++ b/src/types/messageQueueTypes.ts @@ -1,3 +1,10 @@ // Auto-generated stub — replace with real implementation -export type QueueOperationMessage = any; -export type QueueOperation = any; +export type QueueOperationMessage = { + type: 'queue-operation' + operation: QueueOperation + timestamp: string + sessionId: string + content?: string + [key: string]: unknown +} +export type QueueOperation = 'enqueue' | 'dequeue' | 'remove' | string; diff --git a/src/types/sdk-stubs.d.ts b/src/types/sdk-stubs.d.ts index ac657cd..6633a5e 100644 --- a/src/types/sdk-stubs.d.ts +++ b/src/types/sdk-stubs.d.ts @@ -94,7 +94,8 @@ declare module "*/sdk/coreTypes.generated.js" { export type SDKUserMessage = { type: "user"; content: unknown; uuid: string; [key: string]: unknown } export type SDKUserMessageReplay = SDKUserMessage export type SDKAssistantMessage = { type: "assistant"; content: unknown; [key: string]: unknown } - export type SDKAssistantMessageError = { type: "assistant_error"; error: unknown; [key: string]: unknown } + export type SDKAssistantErrorMessage = { type: "assistant_error"; error: unknown; [key: string]: unknown } + export type SDKAssistantMessageError = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens' export type SDKPartialAssistantMessage = { type: "partial_assistant"; [key: string]: unknown } export type SDKResultMessage = { type: "result"; [key: string]: unknown } export type SDKResultSuccess = { type: "result_success"; [key: string]: unknown } diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 4198574..48744d8 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -486,8 +486,87 @@ function parseHttpHookOutput(body: string): { } } +/** Typed representation of sync hook JSON output, matching the syncHookResponseSchema Zod schema. */ +interface TypedSyncHookOutput { + continue?: boolean + suppressOutput?: boolean + stopReason?: string + decision?: 'approve' | 'block' + reason?: string + systemMessage?: string + hookSpecificOutput?: + | { + hookEventName: 'PreToolUse' + permissionDecision?: 'ask' | 'deny' | 'allow' | 'passthrough' + permissionDecisionReason?: string + updatedInput?: Record + additionalContext?: string + } + | { + hookEventName: 'UserPromptSubmit' + additionalContext?: string + } + | { + hookEventName: 'SessionStart' + additionalContext?: string + initialUserMessage?: string + watchPaths?: string[] + } + | { + hookEventName: 'Setup' + additionalContext?: string + } + | { + hookEventName: 'SubagentStart' + additionalContext?: string + } + | { + hookEventName: 'PostToolUse' + additionalContext?: string + updatedMCPToolOutput?: unknown + } + | { + hookEventName: 'PostToolUseFailure' + additionalContext?: string + } + | { + hookEventName: 'PermissionDenied' + retry?: boolean + } + | { + hookEventName: 'Notification' + additionalContext?: string + } + | { + hookEventName: 'PermissionRequest' + decision?: PermissionRequestResult + } + | { + hookEventName: 'Elicitation' + action?: 'accept' | 'decline' | 'cancel' + content?: Record + } + | { + hookEventName: 'ElicitationResult' + action?: 'accept' | 'decline' | 'cancel' + content?: Record + } + | { + hookEventName: 'CwdChanged' + watchPaths?: string[] + } + | { + hookEventName: 'FileChanged' + watchPaths?: string[] + } + | { + hookEventName: 'WorktreeCreate' + worktreePath: string + } +} + function processHookJSONOutput({ - json, + json: rawJson, command, hookName, toolUseID, @@ -511,6 +590,9 @@ function processHookJSONOutput({ }): Partial { const result: Partial = {} + // Cast to typed interface for type-safe property access + const json = rawJson as TypedSyncHookOutput + // At this point we know it's a sync response const syncJson = json diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 6d775d6..b2a4a30 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -1033,7 +1033,7 @@ class Project { 'sourceToolAssistantUUID' in message && message.sourceToolAssistantUUID ) { - effectiveParentUuid = message.sourceToolAssistantUUID + effectiveParentUuid = message.sourceToolAssistantUUID as UUID } const transcriptMessage: TranscriptMessage = { @@ -2120,7 +2120,7 @@ function recoverOrphanedParallelToolResults( chain: TranscriptMessage[], seen: Set, ): TranscriptMessage[] { - type ChainAssistant = Extract + type ChainAssistant = TranscriptMessage & { type: 'assistant' } const chainAssistants = chain.filter( (m): m is ChainAssistant => m.type === 'assistant', )