diff --git a/V6.md b/V6.md deleted file mode 100644 index 3d970340a..000000000 --- a/V6.md +++ /dev/null @@ -1,1330 +0,0 @@ -# Claude Code 架构重构规划 - -## 一、当前架构 (As-Is) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Entry Layer │ -│ cli.tsx → main.tsx (4680行) ─┬─→ REPL.tsx (5005行, 交互) │ -│ ├─→ print.ts (Headless/Pipe) │ -│ └─→ SDK (QueryEngine) │ -├─────────────────────────────────────────────────────────────────┤ -│ Core Monolith │ -│ │ -│ ┌──────────┐ ┌──────────────┐ ┌───────────────────────┐ │ -│ │ query.ts │←──│ QueryEngine │ │ AppState (199行类型) │ │ -│ │ (1732行) │ │ (1320行) │ │ UI+MCP+Bridge+ │ │ -│ │ │ │ │ │ Perm+Plugin+Agent │ │ -│ └────┬─────┘ └──────────────┘ └───────────────────────┘ │ -│ │ │ -│ ┌────▼─────────────────────────────────────────────────────┐ │ -│ │ services/api/claude.ts (3415行) │ │ -│ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ │ │ -│ │ │Anthropic│ │ Bedrock │ │ Vertex │ │ OpenAI │ │ │ -│ │ │ (主路径)│ │(if分支) │ │(if分支) │ │ (882行 │ │ │ -│ │ │ │ │ │ │ │ │ 已实现) │ │ │ -│ │ └─────────┘ └──────────┘ └─────────┘ └───────────┘ │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────▼──────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ tools.ts │ │ services/mcp/│ │ auth.ts │ │ -│ │ (硬编码列表) │ │ client.ts │ │ (2001行,7种认证) │ │ -│ │ 54个Tool │ │ (3351行) │ │ +oauth/(12文件) │ │ -│ └───────────────┘ └──────────────┘ └──────────────────┘ │ -│ │ -│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │ -│ │ sessionStorage│ │ context.ts │ │ ink/ (104文件) │ │ -│ │ (5106行,JSONL)│ │ (189行,轻量) │ │ (UI框架) │ │ -│ └───────────────┘ └───────────────┘ └─────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────────┐ │ -│ │ 隐蔽巨文件 │ │ -│ │ tasks/LocalMainSessionTask.ts (15373行) ← 全库最大单文件 │ │ -│ │ utils/hooks.ts (5177行) + hooks/ (5文件 1494行) │ │ -│ │ components/ (596文件) ← UI组件, 低估 3.5x │ │ -│ └───────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - -耦合特征: - ● main.tsx ~2867行内联 action handler (L1003-L3870), 51个subcommand - ● REPL.tsx God Component: 54 useState, 68 useEffect, ~30 自定义Hook - ● AppState 单一类型混合 7+ 个域 (UI/MCP/Permission/Bridge/Agent/Plugin/Team) - ● Provider 分发靠 if/else 字符串比较 (108处 provider 相关引用) - ● Tool 注册为静态列表 (getAllBaseTools), 54个工具, 无发现机制 - ● auth.ts (2001行) + oauth服务 (12文件 1077行) + 其他 (~779行), 总计约3857行 - ● hooks.ts 5177行, 27种 hook 事件类型, 混杂在 utils/ 中 - ● LocalMainSessionTask.ts 15373行为全库最大单体文件, 文档此前未提及 -``` - ---- - -## 二、目标架构 (To-Be) - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Entry Layer │ -│ │ -│ cli.tsx ──→ main.tsx (瘦身) ─┬─→ REPL (纯 UI, Hook 编排) │ -│ ├─→ Headless (JSON 输出) │ -│ ├─→ SDK (程序化接口) │ -│ └─→ Deep Links (claude:// 协议) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────── packages/agent ──────────────────────────┐ │ -│ │ (核心引擎, 零 UI 依赖) │ │ -│ │ │ │ -│ │ query() QueryEngine HookLifecycle │ │ -│ │ ├─ streaming ├─ turn管理 ├─ PreToolUse │ │ -│ │ ├─ recovery ├─ compaction ├─ PostToolUse │ │ -│ │ ├─ attachments ├─ SDK消息转换 ├─ Notification │ │ -│ │ └─ abort └─ budget追踪 ├─ Stop │ │ -│ │ ├─ SubagentStop │ │ -│ │ CompactionService CronScheduler ├─ UserPromptSubmit │ │ -│ │ (snip/micro/auto) (定时任务/抖动) ├─ SessionStart/End │ │ -│ │ ├─ PreCompact/ │ │ -│ │ LocalMainSessionTask │ PostCompact │ │ -│ │ (15373行 → 分解重构) ├─ Permission* │ │ -│ │ └─ ... (27种事件) │ │ -│ │ QueryDeps (依赖注入) │ │ -│ └──────────────────────────┬────────────────────────────────────┘ │ -│ │ │ -├─────────────────────────────┼───────────────────────────────────────────┤ -│ ▼ │ -│ ┌──────────────── packages/provider ────────────────────────┐ │ -│ │ (适配器层, 可扩展) │ │ -│ │ │ │ -│ │ ┌─────────────────┐ ┌────────────────┐ │ │ -│ │ │ ProviderAdapter │ │ AuthProvider │ │ │ -│ │ │ ├─queryStream() │ │ ├─getCredentials() │ │ -│ │ │ ├─query() │ │ ├─refresh() │ │ │ -│ │ │ ├─isAvailable() │ │ └─invalidate() │ │ │ -│ │ │ └─listModels() │ └────────────────┘ │ │ -│ │ └────────┬────────┘ │ │ -│ │ │ │ │ -│ │ ┌────────▼────────────────────────────────────────┐ │ │ -│ │ │ StreamAdapter (归一化) │ │ │ -│ │ │ 将任意 SSE/WS/流 → 统一的内部事件格式 │ │ │ -│ │ └─────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ ContextProvider (可插拔 prompt 管线) │ │ │ -│ │ │ GitStatus → ClaudeMd → Date → Attribution → ... │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────┐ │ │ -│ │ │ NetworkLayer (网络/代理) │ │ │ -│ │ │ Proxy / mTLS / CA证书 / Upstream Proxy │ │ │ -│ │ └──────────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ │ -├─────────────────────────────┼───────────────────────────────────────────┤ -│ ▼ │ -│ ┌──────────────── 具体实现 (Implementation) ──────────────────────┐ │ -│ │ │ │ -│ │ LLM Providers Auth Implementations │ │ -│ │ ├─ Anthropic ├─ AnthropicOAuth │ │ -│ │ ├─ OpenAI ├─ APIKey (Keychain/env/config) │ │ -│ │ ├─ Gemini ├─ AWS (Bedrock IAM) │ │ -│ │ ├─ Mistral ├─ GCP (Vertex ADC) │ │ -│ │ ├─ Bedrock └─ Azure (Managed Identity) │ │ -│ │ ├─ Vertex │ │ -│ │ ├─ 通义千问 Storage Backends │ │ -│ │ └─ 本地推理 ├─ LocalFile (JSONL) │ │ -│ │ (llama.cpp/vLLM) ├─ RemoteAPI │ │ -│ │ └─ Memory (测试) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -├────────────────────────────────── UI ───────────────────────────────────┤ -│ │ -│ ┌──────────── packages/ink ───────────────────────────────────────┐ │ -│ │ UI 框架 (104文件 + 扩展) │ │ -│ │ ├─ reconciler / hooks (useInput等) / components │ │ -│ │ ├─ Keybinding 系统 (可配置键绑定, 模式解析, 冲突解决) │ │ -│ │ ├─ Vim Emulation (motions / operators / text objects) │ │ -│ │ ├─ Typeahead (命令/文件建议, 模糊搜索, ghost text) │ │ -│ │ └─ InkConfig (12个注入点) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────── 基础设施层 ─────────────────────────────────────┤ -│ │ -│ ┌──────── packages/agent-tools ──────────────────────────────────┐ │ -│ │ Agent 工具库 (纯逻辑) │ │ -│ │ ├─ Tool interface + 54 个工具实现 │ │ -│ │ ├─ Sandbox 系统 (沙盒隔离执行) │ │ -│ │ ├─ configs, aliases, cost, deprecation, contextWindow │ │ -│ │ └─ ModelDeps (注入点) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/shell ────────────────────────────────────────┐ │ -│ │ Shell 执行层 (独立抽象, 零 UI 依赖) │ │ -│ │ ├─ ShellProvider 接口 (统一 bash/zsh/PowerShell) │ │ -│ │ ├─ Bash/Zsh 实现 (命令前缀注入/超时/环境构建) │ │ -│ │ ├─ PowerShell 实现 (Windows 路径转换/FindGitBash) │ │ -│ │ └─ 子进程环境构建 (subprocessEnv) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/config ───────────────────────────────────────┐ │ -│ │ 配置管理 (基础设施层, 被所有模块依赖) │ │ -│ │ ├─ SettingsManager (7层优先级合并: user→project→local→ │ │ -│ │ │ policy→flag→command→session) │ │ -│ │ ├─ FeatureFlagProvider (BunBundle/EnvVar/ConfigFile/Remote) │ │ -│ │ ├─ SettingsSync (跨设备同步) │ │ -│ │ ├─ RemoteManagedSettings (企业管控) │ │ -│ │ └─ GlobalConfig (apiKey/oauthToken等, config.ts 1821行) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/telemetry ────────────────────────────────────┐ │ -│ │ 遥测/诊断 (真实实现, 非空 stub, 需谨慎处理) │ │ -│ │ ├─ AnalyticsEventEmitter (OTel日志导出+JSONL批处理, 806行) │ │ -│ │ ├─ GrowthBook客户端 (AB测试/FeatureFlag, 1163行) │ │ -│ │ ├─ Datadog日志 (日志上传, 321行) │ │ -│ │ ├─ SessionTracer (OTel兼容会话追踪) │ │ -│ │ └─ Metadata (事件元数据enrichment, 973行) │ │ -│ │ 总计: services/analytics/ (10文件, 4062行) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────── 领域系统 ───────────────────────────────────────┤ -│ │ -│ ┌──────── packages/memory ───────────────────────────────────────┐ │ -│ │ 记忆系统 (独立实现, 零 UI 依赖) │ │ -│ │ ├─ MemoryStore (存储抽象) / MemoryRecall (相关性检索) │ │ -│ │ ├─ MemoryExtract (后台提取) / MemoryConsolidation (合并/清理) │ │ -│ │ └─ 类型: user / feedback / project / reference │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────── packages/permission ───────────────────────────────────┐ │ -│ │ 权限系统 (独立实现, 零 UI 依赖) │ │ -│ │ ├─ PermissionMode (8种模式) / PermissionPipeline (检查管线) │ │ -│ │ ├─ RuleStore (allow/deny/ask 规则) / AutoClassifier (AI分类) │ │ -│ │ └─ ToolPermissionContext │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────── 扩展系统 (Phase 5) ─────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │ -│ │ ToolRegistry │ │ OutputTarget │ │ packages/swarm │ │ -│ │ ├─ 内置(静态)│ │ ├─ Terminal(Ink) │ │ (多Agent协调) │ │ -│ │ ├─ MCP(动态) │ │ ├─ JSON (SDK) │ │ ├─ Backends │ │ -│ │ ├─ Plugin │ │ ├─ Web (未来) │ │ │ (进程/Tmux/iTerm2) │ │ -│ │ └─ 用户自定义│ │ └─ Silent (后台) │ │ ├─ PermissionSync │ │ -│ └──────────────┘ └──────────────────┘ │ ├─ TeammateMailbox │ │ -│ │ └─ Worktree管理 │ │ -│ ┌──────────────────┐ ┌───────────────┐ └──────────────────────────┘ │ -│ │ packages/ide │ │ packages/server│ │ -│ │ ├─ VS Code │ │ ├─ DirectConn │ ┌──────────────────────────┐ │ -│ │ ├─ JetBrains │ │ └─ LockFile │ │ packages/teleport │ │ -│ │ ├─ LSP Client │ │ (6/11文件stub │ │ ├─ 环境选择/配置 │ │ -│ │ ├─ Code Indexing │ │ 仅371行有效) │ │ ├─ Git 打包 │ │ -│ │ └─ Claude-in- │ │ └─ API 集成 │ │ -│ │ Chrome │ └──────────────────────────┘ │ -│ └──────────────────┘ │ -│ ┌──────────────────────────┐ │ -│ ┌──────────────────┐ │ packages/updater │ │ -│ │ packages/cli │ │ ├─ NativeInstaller │ │ -│ │ ├─ Transport │ │ │ (.deb/.rpm/.pkg) │ │ -│ │ │ (Hybrid/SSE/ │ │ ├─ BinaryDownload │ │ -│ │ │ WS/Worker) │ │ └─ AutoUpdateCheck │ │ -│ │ ├─ StructuredIO │ └──────────────────────────┘ │ -│ │ └─ Rollback │ │ -│ └──────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 三、单体文件分解图 - -``` -main.tsx (4680行) REPL.tsx (5005行) -┌──────────────────────┐ ┌──────────────────────────┐ -│ main() (安全/URL/argv)│ │ God Component │ -├──────────────────────┤ │ 54 useState, 68 useEffect│ -│ .action() handler │ │ ~30 自定义 Hook │ -│ ┌──────────────────┐ │ │ │ -│ │ parseActionOptions│ │ │ ┌─ useQueryLifecycle ──┐│ -│ │ (L1003-L3870, │ │ │ │ query生命周期 (830行) ││ -│ │ ~2867行内联) │ │ │ │ 权限回调, abort处理 ││ -│ ├──────────────────┤ │ │ └──────────────────────┘│ -│ │ mcpSetup │ │ │ ┌─ usePromptSubmit ────┐│ -│ ├──────────────────┤ │ │ │ 命令解析, 队列 (350行)││ -│ │ headlessSetup │ │ │ └──────────────────────┘│ -│ │ buildInitialState│ │ │ ┌─ useDialogManager ───┐│ -│ ├──────────────────┤ │ │ │ 通知优先级 (20路) ││ -│ │ sessionResume │ │ │ └──────────────────────┘│ -│ └──────────────────┘ │ │ ┌─ useScrollManager ───┐│ -├──────────────────────┤ │ │ 视口状态机 (135行) ││ -│ 51个 subcommands │ │ └──────────────────────┘│ -│ mcp/server/ssh/auth │ │ ┌─ useSessionInit ─────┐│ -│ plugin/doctor/update │ │ │ 初始化, initial msg ││ -│ agents/task/autoMode │ │ └──────────────────────┘│ -└──────────────────────┘ └──────────────────────────┘ - -query.ts (1732行) AppState (199行类型定义) -┌──────────────────────┐ ┌──────────────────────────┐ -│ query() 异步生成器 │ │ 单一嵌套类型, ~90字段 │ -│ ┌──────────────────┐ │ │ │ -│ │compactionPipeline│ │ │ ┌─ UISlice ────────────┐│ -│ │ (snip/micro/auto)│ │ │ │ verbose, expanded, ││ -│ ├──────────────────┤ │ │ │ footer, spinner ││ -│ │streamingOrchestrator │ └─────────────────────┘│ -│ │ (流+错误+abort) │ │ │ ┌─ MCPSlice ───────────┐│ -│ ├──────────────────┤ │ │ │ clients, tools, ││ -│ │ recovery │ │ │ │ commands, resources ││ -│ │ (max_tokens/ptl) │ │ │ └─────────────────────┘│ -│ ├──────────────────┤ │ │ ┌─ PermissionSlice ────┐│ -│ │ attachments │ │ │ │ toolPermissionContext││ -│ │ (files/mem/skill)│ │ │ └─────────────────────┘│ -│ └──────────────────┘ │ │ ┌─ BridgeSlice ────────┐│ -└──────────────────────┘ │ │ replBridge* (~20字段)││ - │ └─────────────────────┘│ -services/mcp/client.ts (3351行) │ ┌─ AgentSlice ─────────┐│ -┌──────────────────────┐ │ │ tasks, agents, team ││ -│ ┌──────────────────┐ │ │ └─────────────────────┘│ -│ │transportManager │ │ │ ┌─ PluginSlice ────────┐│ -│ │(stdio/SSE/WS) │ │ │ │ enabled, commands ││ -│ ├──────────────────┤ │ │ └─────────────────────┘│ -│ │ toolDiscovery │ │ └──────────────────────────┘ -│ │(MCPTool实例化) │ │ -│ ├──────────────────┤ │ → Domain Slicing: -│ │ authManager │ │ type AppState = UISlice -│ │(OAuth+重连) │ │ & MCPSlice & PermissionSlice -│ └──────────────────┘ │ & BridgeSlice & AgentSlice -└──────────────────────┘ & PluginSlice & TeamSlice - -LocalMainSessionTask.ts (15373行) ← 全库最大单体文件 -┌──────────────────────┐ (此前文档未提及) -│ 单文件承担本地主会话 │ -│ 全部生命周期管理 │ -│ 包含: turn管理/消息 │ -│ 处理/工具调用/权限 │ -│ 恢复/compaction等 │ -│ 分解优先级: P1 │ -└──────────────────────┘ -``` - ---- - -## 四、适配器层详解 - -### 4.1 LLM Provider 适配器 (P1) - -``` - ┌─────────────────────────┐ - │ agent │ - │ query() / QueryEngine │ - └────────────┬────────────┘ - │ - ┌────────────▼────────────┐ - │ ProviderAdapter │ - │ ┌─ queryStreaming() │ - │ ├─ query() │ - │ ├─ isAvailable() │ - │ └─ listModels() │ - └────────────┬────────────┘ - │ - ┌──────────────────┼──────────────────┐ - │ │ │ - ┌─────────▼──────┐ ┌────────▼───────┐ ┌────────▼───────┐ - │ Anthropic │ │ OpenAI │ │ Gemini │ - │ │ │ (已有参考) │ │ (新增) │ - │ ┌────────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │ - │ │消息格式转换 │ │ │ │消息格式转换││ │ │消息格式转换│ │ - │ └────────────┘ │ │ └────────────┘│ │ └────────────┘ │ - │ ┌────────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │ - │ │工具格式转换 │ │ │ │工具格式转换││ │ │工具格式转换│ │ - │ └────────────┘ │ │ └────────────┘│ │ └────────────┘ │ - │ ┌────────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │ - │ │StreamAdapter│ │ │StreamAdapter ││ │StreamAdapter │ │ - │ │(原生) │ │ │(SSE→内部) ││ │(SSE→内部) │ │ - │ └────────────┘ │ │ └────────────┘│ │ └────────────┘ │ - └────────────────┘ └───────────────┘ └────────────────┘ - ↑ ↑ ↑ - ┌────┴────────────────────┴──────────────────┴────┐ - │ 归一化事件格式 │ - │ content_block_start/delta/stop │ - │ message_start/delta/stop │ - │ error │ - └──────────────────────────────────────────────────┘ - -当前问题: claude.ts 3415行单体, Bedrock/Vertex 靠 if/else 分支 (108处provider引用) -参考实现: src/services/api/openai/ (6文件, 882行) - ├─ streamAdapter.ts (310行, SSE→内部事件流) - ├─ convertMessages.ts (184行, 消息格式转换) - ├─ convertTools.ts (68行, 工具格式转换) - ├─ modelMapping.ts (56行, 模型名称映射) - ├─ client.ts (48行, OpenAI客户端) - └─ index.ts (216行, 入口+queryModel) -已验证可行: 通过 CLAUDE_CODE_USE_OPENAI=1 启用, 流适配器模式已跑通 -``` - -### 4.2 Auth Provider 适配器 (P1, 与 LLM 配套) - -``` -┌──────────────────────────────────────────────────┐ -│ ProviderAdapter │ -│ 需要 Credentials 才能工作 │ -└─────────────────────┬────────────────────────────┘ - │ - ┌────────────▼────────────┐ - │ AuthProvider │ - │ ┌─ getCredentials() │ - │ ├─ refresh() │ - │ ├─ isAuthenticated() │ - │ └─ invalidate() │ - └────────────┬───────────┘ - │ - ┌────────┬────────┼────────┬──────────┐ - │ │ │ │ │ -┌───▼──┐ ┌──▼───┐ ┌──▼──┐ ┌──▼──┐ ┌────▼───┐ -│OAuth │ │APIKey│ │ AWS │ │ GCP │ │ Azure │ -│ │ │ │ │ │ │ │ │ │ -│PKCE │ │Keych │ │IAM │ │ADC │ │Managed │ -│flow │ │env │ │STS │ │ │ │Identity│ -│ │ │config│ │ │ │ │ │ │ -└──────┘ └──────┘ └─────┘ └─────┘ └────────┘ - -当前问题: auth.ts (2001行) + oauth/(12文件 1077行) + 其他(~779行) = 总计约3857行 - 7种认证: APIKey/OAuth/Bedrock IAM/Vertex ADC/Keychain/ApiKeyHelper/Foundry -已有实现: 全部可从现有代码提取 -``` - -### 4.3 Tool Registry (P1) - -``` -┌───────────────────────────────────────────┐ -│ ToolRegistry │ -│ │ -│ register(tool) unregister(name) │ -│ get(name) getAll() │ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ 发现机制 │ │ -│ │ 1. BuiltInTools ─── 静态注册 │ │ -│ │ 2. MCPTools ─── 动态加载 (已有)│ │ -│ │ 3. PluginTools ─── npm包+配置 │ │ -│ │ 4. UserTools ─── ~/.claude/ │ │ -│ └─────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 统一 AgentTool 接口 │ -│ call() / checkPermissions() │ -│ isEnabled() / isReadOnly() │ -└───────────────────────────────────────────┘ - -当前问题: tools.ts getAllBaseTools() 硬编码 54个 (20常驻 + 34条件, feature()控制) -优势: Tool 接口已很干净, MCPTool 已证明动态加载 -``` - -### 4.4 Storage Backend (P2) - -``` -┌───────────────────────────────────────────┐ -│ Project (会话管理) │ -│ 写队列 / 刷新 / 远程同步 │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ StorageBackend │ │ -│ │ read/write/append │ │ -│ │ delete/list │ │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ │ │ │ │ -│ ┌──▼──────┐ ┌─────▼──────┐ ┌─────▼───┐ │ -│ │LocalFile│ │ RemoteAPI │ │ Memory │ │ -│ │(JSONL) │ │(已有部分) │ │(测试用) │ │ -│ └─────────┘ └────────────┘ └─────────┘ │ -└───────────────────────────────────────────┘ - -当前问题: sessionStorage.ts (5106行, JSONL) + sessionRestore.ts (551行) - + sessionStoragePortable.ts (793行) + listSessionsImpl.ts (454行) - = 总计约6968行, 硬编码 JSONL 格式 -已有基础: hydrateRemoteSession / persistToRemote 部分实现 -``` - -### 4.5 Output Target (P3) - -``` -┌───────────────────────────────────────────┐ -│ agent │ -│ (产出 Message/Event) │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ OutputTarget │ │ -│ │ renderMessage() │ │ -│ │ renderToolProgress()│ │ -│ │ renderError() │ │ -│ │ renderPermission() │ │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌─────────┬───────┼────────┬──────────┐ │ -│ │ │ │ │ │ │ -│ ┌▼───────┐┌▼─────┐┌▼──────┐┌▼────────┐ │ │ -│ │Terminal││ JSON ││ Web ││ Silent │ │ │ -│ │(Ink) ││(SDK) ││(未来) ││(后台) │ │ │ -│ └────────┘└──────┘└───────┘└─────────┘ │ │ -└───────────────────────────────────────────┘ - -当前问题: 170+组件耦合 Ink API, 3种输出路径未抽象 -已有分支: headless/pipe 绕过 Ink, SDK 跳过 Ink -``` - -### 4.6 Context Pipeline (P2) - -``` -┌──────────────────────────────────────────────────┐ -│ System Prompt 装配 │ -│ │ -│ ┌────────────────────────────────────────────┐ │ -│ │ ContextProvider[] │ │ -│ │ (按 priority 排序, 可插拔) │ │ -│ │ │ │ -│ │ ┌─ GitStatusProvider ──── priority: 10 │ │ -│ │ ├─ ClaudeMdProvider ──── priority: 20 │ │ -│ │ ├─ DateProvider ──────── priority: 30 │ │ -│ │ ├─ AttributionProvider ─ priority: 40 │ │ -│ │ ├─ AdvisorProvider ──── priority: 50 │ │ -│ │ └─ CustomProvider ────── priority: 99 │ │ -│ │ (用户通过配置注册) │ │ -│ └────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 最终 System Prompt │ -└──────────────────────────────────────────────────┘ - -当前问题: context.ts (189行, 轻量) 提供上下文, claude.ts buildSystemPromptBlocks() - 做 prompt 缓存分块, 无自定义 hook 点 -改动范围: 集中在 prompt 装配逻辑 (claude.ts + context.ts) -``` - -### 4.7 Hook 生命周期 (P1, 核心扩展机制) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ HookLifecycle (packages/agent 内) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Hook 点 (用户在 settings.json 配置 shell 命令) │ │ -│ │ 共 27 种事件, 按类型分组: │ │ -│ │ │ │ -│ │ 工具相关: │ │ -│ │ ├─ PreToolUse 工具调用前 → 可阻止/修改输入 │ │ -│ │ ├─ PostToolUse 工具调用后 → 可修改输出 │ │ -│ │ └─ PostToolUseFailure 工具调用失败后 │ │ -│ │ 会话相关: │ │ -│ │ ├─ SessionStart/End 会话生命周期 │ │ -│ │ ├─ UserPromptSubmit 用户提交输入时 │ │ -│ │ ├─ Stop/StopFailure 对话停止时 │ │ -│ │ ├─ PreCompact/PostCompact 上下文压缩前后 │ │ -│ │ └─ CwdChanged 工作目录变更时 │ │ -│ │ 子Agent/团队: │ │ -│ │ ├─ SubagentStart/Stop 子Agent生命周期 │ │ -│ │ ├─ TeammateIdle 队友空闲时 │ │ -│ │ ├─ TaskCreated/Completed 任务生命周期 │ │ -│ │ └─ WorktreeCreate/Remove Worktree管理 │ │ -│ │ 权限/通知: │ │ -│ │ ├─ Notification 通知触发 → 自定义渠道 │ │ -│ │ ├─ PermissionRequest/Denied 权限审批 │ │ -│ │ ├─ Elicitation/Result 用户反馈请求 │ │ -│ │ └─ ConfigChange 配置变更时 │ │ -│ │ 其他: Setup, InstructionsLoaded, FileChanged │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼──────────────────────────────────────┐ │ -│ │ HookExecutor (hooks.ts, 5177行 → 提取为独立模块) │ │ -│ │ │ │ -│ │ ├─ spawnShellCommand() → 执行用户配置的 shell 命令 │ │ -│ │ ├─ parseJsonOutput() → 解析 hook 的 JSON 输出 │ │ -│ │ ├─ timeout / abort → 超时和中断处理 │ │ -│ │ ├─ hooksConfigSnapshot → 配置快照 (热加载) │ │ -│ │ └─ fileChangedWatcher → 文件变更监听触发 hook │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: hooks.ts (5177行) + hooks/ (5文件 1494行) = 6713行 │ -│ 混杂在 utils/ 中, 27种事件类型全部硬编码 │ -│ 改动范围: 提取到 packages/agent/hooks/, 作为核心生命周期 │ -│ 依赖关系: HookExecutor ← ToolRegistry (PreToolUse/PostToolUse) │ -│ HookExecutor ← QueryEngine (Stop/SubagentStop) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.8 Shell 执行层 (P2, BashTool 底层依赖) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/shell (~19000行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ ShellProvider 接口 │ │ -│ │ ├─ spawn(command, options) → ShellResult │ │ -│ │ ├─ getShellPath() → string │ │ -│ │ ├─ buildEnv(context) → Record │ │ -│ │ └─ wrapCommand(cmd, sandbox) → string (前缀注入) │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────┼──────────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼─────────┐ ┌──────▼─────────┐ ┌────────▼──────────┐ │ -│ │ BashProvider │ │ ZshProvider │ │ PowerShellProvider│ │ -│ │ │ │ │ │ │ │ -│ │ ├─ bash/(24文件)│ │ ├─ .zshrc 检测│ │ ├─ powershell/ │ │ -│ │ │ 12310行 │ │ ├─ 插件兼容 │ │ │ 3文件, 2305行 │ │ -│ │ ├─ 超时/中断 │ │ └─ compfix │ │ ├─ FindGitBash │ │ -│ │ ├─ 沙盒集成 │ └────────────────┘ │ ├─ Windows路径 │ │ -│ │ └─ 命令前缀 │ │ └─ ExecutionPolicy│ │ -│ └─────────────────┘ └───────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ subprocessEnv.ts → 子进程环境变量构建 │ │ -│ │ ShellCommand.ts (465行) → 命令封装 + 流式输出 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: bash/ 12310行 + powershell/ 2305行 混在 utils/ 中 │ -│ 改动范围: 提取为独立 package, BashTool 通过依赖注入使用 │ -│ 依赖方向: packages/shell ← packages/agent-tools (BashTool) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.9 配置管理系统 (P2, 基础设施层) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/config (~9700行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ SettingsManager │ │ -│ │ ├─ 7 层优先级合并 (低→高, 后者覆盖前者): │ │ -│ │ │ 1. userSettings ~/.claude/settings.json │ │ -│ │ │ 2. projectSettings .claude/settings.json │ │ -│ │ │ 3. localSettings .claude/local/settings.json │ │ -│ │ │ 4. policySettings 企业管理 (远程下发) │ │ -│ │ │ 5. flagSettings GrowthBook feature flags │ │ -│ │ │ 6. cliArg --allowed-tools 等 CLI 参数 │ │ -│ │ │ 7. session 临时 ("始终允许" 按钮产生) │ │ -│ │ └─ get(key) / set(key, value, source) / watch(key, cb) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ FeatureFlagProvider │ │ SettingsSync │ │ -│ │ ├─ BunBundle │ │ ├─ 跨设备同步 (已部分实现) │ │ -│ │ ├─ EnvVar │ │ ├─ 冲突检测/合并 │ │ -│ │ ├─ ConfigFile │ │ └─ RemoteManagedSettings │ │ -│ │ ├─ Remote (GrowthBook)│ │ (企业管控配置) │ │ -│ │ └─ feature(name)→bool│ └──────────────────────────────────┘ │ -│ └──────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ GlobalConfig (src/utils/config.ts, 1821行) │ │ -│ │ ├─ apiKey / oauthToken / customApiKeyResponses │ │ -│ │ ├─ preferredNotifChannel / projects (per-project) │ │ -│ │ ├─ saveGlobalConfig / getGlobalConfig (文件锁+新鲜度监控) │ │ -│ │ └─ trust dialog / config backup / default factory │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: settings/(3文件 1411行) + config.ts(1821行) 混在utils │ -│ 改动范围: 提取为独立 package, 作为最底层基础设施 │ -│ 依赖方向: 被 packages/agent, packages/permission 等所有模块依赖 │ -│ 注意: feature() 使用181处, 分布在30+文件, 提取需谨慎 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.10 遥测/诊断系统 (P3, 真实实现, 非空 stub) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ services/analytics/ (10文件, 4062行, 真实实现) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ 重要: 这不是空 stub, 而是完整的生产级实现 │ │ -│ │ 包含 OTel 日志导出、GrowthBook AB测试、Datadog 日志上传 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ FirstPartyEventLoggingExporter (806行) │ │ -│ │ ├─ OpenTelemetry LogExporter + JSONL 批处理 + HTTP 上传 │ │ -│ └─ FirstPartyEventLogger (449行) │ │ -│ └─ OTel LoggerProvider + 采样策略 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ GrowthBook 客户端 (1163行) │ │ -│ │ ├─ Feature flags / AB 测试 / 远程配置 │ │ -│ └─ sinkKillswitch (25行) — per-sink 开关 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Datadog 日志 (321行) + Metadata (973行) │ │ -│ │ ├─ Datadog 日志上传 (需环境变量配置端点/密钥) │ │ -│ └─ 事件元数据enrichment │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: 真实实现散布在 services/analytics/, 耦合 GrowthBook │ -│ 建议: 暂不提取为独立 package, 保持原地, 避免引入回归风险 │ -│ 依赖方向: 被多个模块调用, 但不影响核心业务逻辑 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.11 Compaction 服务 (P2, 从 query.ts 独立) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ CompactionService (packages/agent 内) │ -│ src/services/compact/ (29文件, 4267行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ CompactionStrategy (策略模式) │ │ -│ │ │ │ -│ │ ├─ SnipCompaction 精确裁剪 (保留首尾, 裁剪中间) │ │ -│ │ ├─ MicroCompaction 摘要压缩 (每 N 轮自动摘要) │ │ -│ │ └─ AutoCompaction 智能压缩 (按 token budget 触发) │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────────▼──────────────────────────────────┐ │ -│ │ ContextWindowManager │ │ -│ │ ├─ token 计数 / budget 分配 │ │ -│ │ ├─ 触发阈值检测 (80%/90%/95%) │ │ -│ │ └─ prompt 构造 (摘要请求 → 模型 → 替换历史) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: 逻辑分散在 query.ts + services/compact/ 两处 │ -│ 改动范围: 统一到 packages/agent/compaction/ │ -│ 依赖方向: ← QueryEngine 调用; → packages/provider (摘要请求) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.12 Swarm / 多Agent 协调 (P3, 扩展系统) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/swarm (~7548行 + tasks/) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ SwarmOrchestrator │ │ -│ │ ├─ spawnTeammate(config) → TeammateHandle │ │ -│ │ ├─ broadcast(message) → void │ │ -│ │ ├─ getTeamStatus() → TeamStatus[] │ │ -│ │ └─ shutdown() → void │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────┼──────────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼──────────┐ ┌─────▼───────────┐ ┌──────▼──────────┐ │ -│ │ InProcessBackend│ │ TmuxBackend │ │ ITerm2Backend │ │ -│ │ (同进程, 线程) │ │ (Tmux pane) │ │ (iTerm2 split) │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ │ -│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ TeammateMailbox │ │ PermissionSync │ │ -│ │ ├─ send(to, msg) │ │ ├─ 主Agent → 子Agent 权限传递 │ │ -│ │ ├─ receive() │ │ ├─ 规则同步 / 模式继承 │ │ -│ │ └─ broadcast(msg) │ │ └─ 子Agent 结果审批 │ │ -│ └──────────────────────┘ └──────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Task 类型系统 (src/tasks/, 13入口, 含巨文件) │ │ -│ │ ├─ LocalMainSessionTask 15373行 (全库最大!) │ │ -│ │ ├─ DreamTask 后台记忆合并 │ │ -│ │ ├─ InProcessTeammateTask 进程内队友 │ │ -│ │ ├─ LocalAgentTask 本地子Agent │ │ -│ │ ├─ RemoteAgentTask 远程子Agent │ │ -│ │ └─ MonitorMcpTask MCP 监控 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Worktree 管理 (src/utils/worktree.ts, 1519行) │ │ -│ │ ├─ createWorktree() → 隔离的 git worktree │ │ -│ │ ├─ cleanupWorktree() → 合并/丢弃 │ │ -│ │ └─ getWorktreePaths() → 路径映射 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: src/utils/swarm/(22文件, 7548行) + tasks/ 分散 │ -│ swarm 含 13文件(4486行) + backends/(9文件 3062行) │ -│ 改动范围: 统一到 packages/swarm/ │ -│ 依赖方向: ← packages/agent (AgentTool); → packages/shell │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.13 IDE / 编辑器集成 (P3, 扩展系统) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/ide (~6800行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ IDEConnector 接口 │ │ -│ │ ├─ connect(ideType) → IDEConnection │ │ -│ │ ├─ highlightFile(path, range) │ │ -│ │ ├─ openFile(path, line?) │ │ -│ │ └─ getSelection() → Range | null │ │ -│ └──────────────────────────┬──────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────┼──────────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼──────────┐ ┌─────▼───────────┐ ┌──────▼──────────┐ │ -│ │ VSCodeProvider │ │ JetBrainsProvider│ │ LSPClient │ │ -│ │ (ide.ts 1494行) │ │ (jetbrains.ts │ │ (services/lsp/ │ │ -│ │ LSP 协议连接 │ │ 191行) │ │ 8文件) │ │ -│ │ 选区/高亮同步 │ │ IDE 集成 │ │ 代码操作/诊断 │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ CodeIndexing (native-ts/file-index/) │ │ -│ │ ├─ 文件内容索引 → 快速代码搜索 │ │ -│ │ └─ 增量更新 / 持久化 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Claude-in-Chrome (utils/claudeInChrome/, 7文件, 2337行) │ │ -│ │ ├─ Chrome Native Messaging → 浏览器控制 │ │ -│ │ ├─ 设置管理 / Prompt 注入 │ │ -│ │ └─ 独立于 Computer Use, 通过 --chrome 启用 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: ide.ts + jetbrains.ts + lsp/ + claudeInChrome/ 分散 │ -│ 改动范围: 合并到 packages/ide/ │ -│ 依赖方向: ← packages/agent (LSPTool); 独立于核心循环 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.14 Server 模式 (P3, 大部分 stub) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/server (11文件, 大部分 stub) │ -│ src/server/ (392行, 其中仅371行有效代码) │ -│ │ -│ 实际实现 (4文件, 371行): │ -│ ├─ directConnectManager.ts (213行) — Direct Connect 管理 │ -│ ├─ createDirectConnectSession.ts (88行) — 会话创建 │ -│ ├─ types.ts (57行) — 类型定义 │ -│ └─ lockfile.ts (13行) — PID 锁 │ -│ │ -│ Stub 文件 (6个, 各3行): │ -│ ├─ server.ts / sessionManager.ts / connectHeadless.ts │ -│ ├─ serverBanner.ts / serverLog.ts / parseConnectUrl.ts │ -│ └─ backends/dangerousBackend.ts │ -│ │ -│ 当前问题: 6/11文件为空 stub, 仅 DirectConnect 有实际代码 │ -│ 建议: 低优先级, 待 stub 被恢复后再考虑提取 │ -│ 依赖方向: ← main.tsx (启动, DIRECT_CONNECT feature); 独立于核心 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.15 远程执行 / Teleport (P3, 扩展系统) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/teleport (~2200行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ TeleportProvider │ │ -│ │ ├─ selectEnvironment(config) → Environment │ │ -│ │ ├─ packGitContext() → Archive (Git 打包上传) │ │ -│ │ ├─ execute(command, env) → Result (远程执行) │ │ -│ │ └─ syncResults(remote, local) (结果同步) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: teleport.tsx(1234行) + teleport/(4文件, 956行) │ -│ = 总计约2190行, 散布在 utils/ 中 │ -│ 改动范围: 提取为独立 package │ -│ 依赖方向: ← packages/agent; → packages/shell (远程执行) │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.16 自动更新 / 安装器 (P3, 入口层辅助) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/updater (~3579行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ NativeInstaller │ │ -│ │ ├─ Linux: .deb / .rpm 包管理 │ │ -│ │ ├─ macOS: .pkg 安装器 │ │ -│ │ └─ Windows: (预留) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ AutoUpdater (autoUpdater.ts, 561行) │ │ -│ │ ├─ 版本检查 (远程 / npm registry) │ │ -│ │ ├─ 二进制下载 + 校验 │ │ -│ │ └─ PID 锁 (防止并发更新) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: nativeInstaller/(5文件 3018行) + autoUpdater.ts(561行)│ -│ = 总计约3579行, 散布在 utils/ 中 │ -│ 改动范围: 提取为独立 package, 仅被 entry layer 调用 │ -│ 依赖方向: ← cli.tsx / main.tsx (启动时检查); 无业务依赖 │ -└───────────────────────────────────────────────────────────────────┘ -``` - -### 4.17 CLI 传输层 (P3, 入口层基础设施) - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ packages/cli (~12700行) │ -│ src/cli/ (127文件) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Transport 层 (可插拔 I/O 传输) │ │ -│ │ │ │ -│ │ ├─ HybridTransport 混合模式 (本地 + 远程) │ │ -│ │ ├─ SSETransport Server-Sent Events │ │ -│ │ ├─ WebSocketTransport WebSocket 双向通信 │ │ -│ │ ├─ WorkerStateTransport Worker 线程通信 │ │ -│ │ └─ SerialBatchTransport 串行批处理 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Handler 模块 (8个, 按 subcommand 分发) │ │ -│ │ ├─ agents / auth / mcp / autoMode / ... │ │ -│ │ ├─ StructuredIO (结构化输入输出) │ │ -│ │ └─ Rollback 机制 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 当前问题: 127文件独立目录但未抽为 package │ -│ 改动范围: 提取为 packages/cli/, 仅被 entry layer 引用 │ -│ 依赖方向: ← cli.tsx / main.tsx; → packages/agent │ -└───────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 五、Packages 目录结构 - -``` -packages/ -├── @ant/ (已有) -│ ├── computer-use-mcp/ -│ ├── computer-use-input/ -│ ├── computer-use-swift/ -│ └── claude-for-chrome-mcp/ -├── audio-capture-napi/ (已有) -├── color-diff-napi/ (已有) -├── image-processor-napi/ (已有) -│ -├── ink/ ← Phase 1 UI 框架 + Keybinding + Vim + Typeahead -├── agent-tools/ ← Phase 2 Agent 工具库 + Sandbox 系统 -├── memory/ ← Phase 2 记忆系统, 独立实现 -├── permission/ ← Phase 2 权限系统, 独立实现 -├── config/ ← Phase 2 配置管理 + FeatureFlag + GlobalConfig -├── telemetry/ ← Phase 2 遥测/诊断 (真实实现, 非空stub) -├── agent/ ← Phase 3 核心引擎 + Hook 生命周期 + Compaction + Cron -├── provider/ ← Phase 4 ProviderAdapter + AuthProvider + NetworkLayer -├── shell/ ← Phase 4 Shell 执行层 (Bash/Zsh/PowerShell) -├── swarm/ ← Phase 5 多Agent协调 + Worktree -├── ide/ ← Phase 5 IDE/LSP/CodeIndex + Claude-in-Chrome -├── server/ ← Phase 5 服务器模式 (大部分stub, 低优先级) -├── teleport/ ← Phase 5 远程执行环境 -├── updater/ ← Phase 5 自动更新 + 原生安装器 -└── cli/ ← Phase 5 CLI 传输层 + Handler 分发 -``` - ---- - -## 六、实施路线图 - -``` -Phase 0: 内部分解 (与 Phase 1-3 并行, 低风险) - ├── main.tsx → 6 个模块 - ├── REPL.tsx → 5 个 Hook - ├── query.ts → 4 个子模块 - ├── services/mcp/client.ts → 3 个模块 - ├── LocalMainSessionTask.ts → 分解 (15373行, 全库最大) - └── AppState → Domain Slicing - -Phase 1: packages/ink/ 风险: 低 - ├── Ink 框架 (reconciler/hooks/components) - ├── Keybinding 系统 (16文件 → 纳入 ink/) - ├── Vim 模拟 (5文件 → 纳入 ink/) - └── Typeahead/Suggestion (→ 纳入 ink/) - -Phase 2: 独立系统提取 风险: 低-中 - ├── packages/agent-tools/ Agent 工具库 + Sandbox - ├── packages/memory/ 记忆系统 - ├── packages/permission/ 权限系统 - ├── packages/config/ 配置管理 + FeatureFlag + GlobalConfig - └── packages/telemetry/ 遥测/诊断 (真实实现, 谨慎提取) - -Phase 3: packages/agent/ 风险: 中-高 - ├── query() + QueryEngine 核心循环 - ├── Hook 生命周期 (hooks.ts 5177行 + hooks/ 5文件 → 提取, 27种事件) - ├── Compaction 服务 (services/compact/ 29文件 → 统一) - ├── Cron/Scheduler (utils/cron* → 提取) - └── FileHistory (utils/fileHistory → 提取) - -Phase 4: packages/provider/ + packages/shell/ 风险: 中 - ├── LLM Provider 适配器 (核心价值最高) - ├── Auth Provider 适配器 - ├── Context Pipeline - ├── NetworkLayer (proxy/mTLS/CA证书) - └── Shell 执行层 (bash/powershell → 提取) - -Phase 5: 扩展系统 风险: 中-高 - ├── Tool Registry / Plugin - ├── Storage Backend / Command System - ├── Output Target / Feature Flag Provider - ├── packages/swarm/ 多Agent协调 + Worktree - ├── packages/ide/ IDE/LSP + CodeIndex + Chrome - ├── packages/server/ 服务器 (大部分stub, 可延后) - ├── packages/teleport/ 远程执行 - ├── packages/updater/ 自动更新 + 安装器 - └── packages/cli/ CLI 传输层 -``` - ---- - -## 七、优先级矩阵 - -| 项目 | 用户价值 | 风险 | 优先级 | -|------|---------|------|--------| -| **LLM Provider 适配器** | 高 (多模型, 已有OpenAI参考实现) | 中 | **P1** | -| **Auth Provider 适配器** | 高 (安全, 7种认证约3857行) | 高 | **P1** | -| **Tool Registry** | 高 (生态, 54个工具) | 低 | **P1** | -| **Hook 生命周期** | 高 (核心扩展点, 27种事件) | 中 | **P1** | -| **LocalMainSessionTask分解** | 高 (15373行巨文件) | 中 | **P1** | -| **Shell 执行层** | 高 (BashTool底层, bash/12310行) | 中 | **P2** | -| **配置管理** | 高 (基础设施, config.ts 1821行) | 低-中 | **P2** | -| **记忆系统** | 高 (跨会话记忆, 24文件 6330行) | 低-中 | **P2** | -| **权限系统** | 高 (安全/可扩展, 24文件 9416行) | 中 | **P2** | -| **Compaction 服务** | 中 (上下文管理, 29文件 4267行) | 低 | **P2** | -| **命令系统** | 中 (可扩展命令, 93目录 96命令) | 低 | P2 | -| Context Pipeline | 中 (自定义prompt) | 低 | P2 | -| Storage Backend | 中 (云同步/测试, 5文件约6968行) | 低-中 | P2 | -| **遥测/诊断** | 中 (可观测性, 真实实现非stub) | 中 | P2 | -| main.tsx 分解 | 中 (可维护性, 4680行) | 低 | P2 | -| REPL.tsx 分解 | 中 (可维护性, 5005行) | 中 | P2 | -| query.ts 分解 | 中 (可维护性, 1732行) | 低 | P3 | -| AppState Slicing | 低 (199行类型) | 低 | P3 | -| mcp/client.ts 分解 | 低 (3351行, services/mcp/) | 低 | P3 | -| Output Target | 中 (596个组件, 范围大) | 中-高 | P3 | -| Feature Flag Provider | 低 (181处使用, 30+文件) | 中 | P3 | -| **Swarm/多Agent** | 高 (并行能力, 22文件 7548行) | 高 | **P3** | -| **IDE/LSP 集成** | 中 (编辑器体验) | 中 | P3 | -| **Server 模式** | 低 (6/11文件为stub, 仅371行有效) | 低 | P4 | -| **Teleport 远程执行** | 中 (远程开发, 约2190行) | 中 | P3 | -| **自动更新/安装器** | 低 (运维, 约3579行) | 低 | P3 | -| **CLI 传输层** | 低 (内部架构, 127文件) | 低 | P3 | - ---- - -## 八、命令系统 - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ 用户输入 "/" 触发 │ -│ │ -│ REPL.tsx (用户按 Enter) │ -│ │ │ -│ ▼ │ -│ handlePromptSubmit.ts (610行) │ -│ │ │ -│ ▼ │ -│ processUserInput.ts (605行) ─── 识别 "/" 前缀 │ -│ │ │ -│ ▼ │ -│ processSlashCommand.tsx (921行) ─── 命令分发器 │ -│ │ │ -│ │ 解析命令名 + 参数 │ -│ │ findCommand() → 查找 Command 对象 │ -│ │ │ -│ ├──────────────┬──────────────────┬─────────────────┐ │ -│ ▼ ▼ ▼ ▼ │ -│ ┌─────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ local │ │ local-jsx │ │ prompt │ │ 未知命令 │ │ -│ │ │ │ │ │ │ │ → 错误 │ │ -│ │ load() │ │ load() │ │ getPrompt() │ └──────────┘ │ -│ │ →call() │ │ →call() │ │ → 注入消息 │ │ -│ │ →结果 │ │ → ReactNode│ │ → 发送查询 │ │ -│ └─────────┘ └─────────────┘ └──────────────┘ │ -│ │ │ │ │ -│ └──────────────┴──────────────────┘ │ -│ │ │ -│ ▼ │ -│ 返回结果给 REPL │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 命令注册 (src/commands.ts, ~470行) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ COMMANDS() ─── 静态注册 ~96 个命令 │ │ -│ │ (71个静态导入 + 条件feature控制 + ~25个INTERNAL_ONLY) │ │ -│ │ │ │ -│ │ ┌─ src/commands/ (93个目录, 228文件) ─── 每个命令: │ │ -│ │ │ name, description, aliases, type │ │ -│ │ │ load: () => import('./impl.js') ← 懒加载 │ │ -│ │ │ isEnabled(), availability │ │ -│ │ └───────────────────────────────────────────────────────── │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ + │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ getCommands() ─── 动态源 (合并到最终列表) │ │ -│ │ │ │ -│ │ ├─ getSkillDirCommands() .claude/commands/ + skills/ │ │ -│ │ ├─ getBundledSkills() 内置 skill │ │ -│ │ ├─ getPluginSkills() 插件 skill │ │ -│ │ ├─ getPluginCommands() 插件命令 │ │ -│ │ ├─ getWorkflowCommands() workflow 脚本命令 │ │ -│ │ └─ getDynamicSkills() 运行时动态发现 │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 过滤: isEnabled → meetsAvailability → 去重 │ -│ │ │ -│ ▼ │ -│ findCommand() / getCommand() / hasCommand() │ -│ (按 name, alias 查找, Fuse.js 模糊匹配) │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 自动补全 (useTypeahead, 1384行) │ -│ │ -│ 用户键入 "/" │ -│ │ │ -│ ▼ │ -│ generateCommandSuggestions() (567行) │ -│ │ │ -│ ▼ │ -│ Fuse.js 模糊搜索 ─── 精确 > alias > 前缀 > 模糊 │ -│ │ │ -│ ▼ │ -│ Ghost Text 补全 / 列表选择 / Tab/Enter 确认 │ -└───────────────────────────────────────────────────────────────────┘ - -耦合特征: - ● commands.ts (~470行) 静态导入所有命令, 新增命令必须手动注册 - ● src/commands/ 93个目录 228文件, 每个命令独立目录 - ● processSlashCommand.tsx switch 分发, 新命令类型需改分发器 - ● SkillTool (1109行) 提供第二条路径: 模型直接调用 skill - ● REMOTE_SAFE_COMMANDS / BRIDGE_SAFE_COMMANDS 硬编码安全列表 -``` - ---- - -## 九、记忆系统 - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ 记忆系统总览 │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ CLAUDE.md 指令文件 (用户管理) claudemd.ts (1479行) │ │ -│ │ │ │ -│ │ 加载优先级 (低→高, 后者覆盖前者): │ │ -│ │ 1. Managed /etc/claude-code/CLAUDE.md │ │ -│ │ 2. User ~/.claude/CLAUDE.md │ │ -│ │ 3. Project CLAUDE.md, .claude/CLAUDE.md, .claude/rules/ │ │ -│ │ 4. Local CLAUDE.local.md (gitignored) │ │ -│ │ 5. AutoMem MEMORY.md (agent 管理) │ │ -│ │ 6. TeamMem MEMORY.md (团队共享) │ │ -│ │ │ │ -│ │ 特性: │ │ -│ │ ├─ @include 指令 (递归解析, 最深 5 层) │ │ -│ │ ├─ frontmatter paths: 条件规则 │ │ -│ │ ├─ HTML 注释剥离, 循环引用防护 │ │ -│ │ └─ MEMORY.md 截断: 200行 / 25KB │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Auto-Memory (Agent 管理) memdir/ (9文件, 1743行)│ │ -│ │ │ │ -│ │ 存储位置: ~/.claude/projects//memory/ │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────────────┐ │ │ -│ │ │ 四类型分类法 │ │ │ -│ │ │ │ │ │ -│ │ │ user 用户角色/偏好/知识 (私有) │ │ │ -│ │ │ feedback 方法指导/纠正/确认 (默认私有) │ │ │ -│ │ │ project 项目工作/目标/决策 (团队共享) │ │ │ -│ │ │ reference 外部系统指针 (团队共享) │ │ │ -│ │ └─────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ -│ │ │ MemoryStore │ │ MemoryRecall │ │ MemoryExtract │ │ │ -│ │ │ 存储抽象 │ │ 相关性检索 │ │ 后台提取 │ │ │ -│ │ │ │ │ │ │ │ │ │ -│ │ │ 写入 .md │ │ Sonnet 侧查 │ │ 每轮对话后触发 │ │ │ -│ │ │ + frontmatter│ │ → 选 5 条最 │ │ → fork subagent │ │ │ -│ │ │ + MEMORY.md │ │ 相关记忆 │ │ → 只读工具+写记忆│ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────┐ │ │ -│ │ │ MemoryConsolidate│ autoDream.ts (326行) │ │ -│ │ │ 合并/清理/修剪 │ 后台运行, 防并发锁 │ │ -│ │ └──────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────────┐ │ │ -│ │ │ extractMemories/ (2文件, 769行) │ │ │ -│ │ │ 后台异步提取, fork subagent, 只读工具+写记忆 │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────────┐ │ │ -│ │ │ teamMemorySync/ (4文件, 2167行) │ │ │ -│ │ │ 团队记忆同步, 跨会话/跨Agent共享 │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ 集成点 │ │ -│ │ │ │ -│ │ context.ts ─── getMemoryFiles() + getClaudeMds() │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ 每轮对话注入指令上下文 │ │ -│ │ │ │ -│ │ stopHooks.ts ─── handleStopHooks() │ │ -│ │ │ │ │ -│ │ ├─→ executeExtractMemories() (异步提取) │ │ -│ │ └─→ executeAutoDream() (异步合并) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ │ -│ 特性门控: │ -│ CLAUDE_CODE_DISABLE_AUTO_MEMORY, autoMemoryEnabled, │ -│ tengu_passport_quail (提取), tengu_moth_copse (附件模式), │ -│ tengu_herring_clock (团队记忆), EXTRACT_MEMORIES, TEAMMEM │ -│ │ -│ 总规模: 24文件, 约6330行 │ -│ ├─ memdir/ (9文件, 1743行) 存储抽象 │ -│ ├─ services/autoDream/ (4文件, 552行) 合并/清理 │ -│ ├─ services/extractMemories/ (2文件, 769行) 后台提取 │ -│ ├─ services/teamMemorySync/ (4文件, 2167行) 团队同步 │ -│ ├─ tasks/DreamTask/ (1文件, 157行) 后台任务 │ -│ └─ utils/辅助文件 (4文件, ~542行) 检测/操作 │ -└───────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 十、权限系统 - -``` -┌───────────────────────────────────────────────────────────────────┐ -│ 权限模式 (PermissionMode) │ -│ PermissionMode.ts (141行) │ -│ │ -│ ┌──────────────┐ Shift+Tab 循环 │ -│ │ default │ ←→ acceptEdits ←→ plan ←→ bypassPermissions │ -│ │ (每步确认) │ ↑ (YOLO模式) │ -│ └──────────────┘ │ │ -│ auto (仅内部) │ -│ bubble (子agent) │ -│ dontAsk (静默拒绝) │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 权限检查管线 (核心, 1486行) │ -│ permissions.ts: hasPermissionsToUseToolInner() │ -│ │ -│ Tool 调用请求 │ -│ │ │ -│ ┌────▼────────────────────────────────────────────────────┐ │ -│ │ Step 1: 强制检查 (不可被模式跳过) │ │ -│ │ │ │ -│ │ 1a. deny 规则匹配 → 立即拒绝 │ │ -│ │ 1b. ask 规则匹配 → 需要确认 │ │ -│ │ 1c. tool.checkPermissions() → 工具自检 │ │ -│ │ ├─ 文件工具 → filesystem.ts (1778行) │ │ -│ │ │ 读: UNC/Windows/拒绝/工作目录/内部路径 │ │ -│ │ │ 写: .git/.claude/.bashrc 安全检查 │ │ -│ │ └─ Bash → bashPermissions.ts (2621行) │ │ -│ │ 子命令级别权限校验 │ │ -│ │ 1d. 工具级拒绝 │ │ -│ │ 1e. requiresUserInteraction() → 强制交互 │ │ -│ │ 1f. 内容级 ask 规则 (bypass-immune) │ │ -│ │ 1g. 安全路径检查 (.git/ .claude/ .bashrc) │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────▼────────────────────────────────────────────────────┐ │ -│ │ Step 2: 模式决策 │ │ -│ │ │ │ -│ │ 2a. bypassPermissions → 放行 (除非被 Step 1 拦截) │ │ -│ │ 2b. alwaysAllow 规则匹配 → 放行 │ │ -│ │ 2c. 无匹配 → 转为 ask │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────▼────────────────────────────────────────────────────┐ │ -│ │ Step 3: 模式后处理 │ │ -│ │ │ │ -│ │ dontAsk → ask 变 deny (静默拒绝) │ │ -│ │ auto → YOLO Classifier (1495行) │ │ -│ │ ├─ Stage 1: 快速分类 (无 thinking) │ │ -│ │ └─ Stage 2: 深度分析 (chain-of-thought) │ │ -│ │ headless → 先跑 PermissionRequest hooks, 再 auto-deny│ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ 结果: allow / deny / ask │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 规则来源 (优先级从低到高) │ -│ permissionSetup.ts (1533行) │ -│ │ -│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ -│ │ userSettings │ │ projectSettings │ │ localSettings │ │ -│ │ ~/.claude/ │ │ .claude/ │ │ .claude/local │ │ -│ │ settings.json │ │ settings.json │ │ settings.json │ │ -│ └────────────────┘ └─────────────────┘ └──────────────────┘ │ -│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ -│ │ policySettings │ │ flagSettings │ │ cliArg │ │ -│ │ (企业管理) │ │ (GrowthBook) │ │ --allowed-tools │ │ -│ └────────────────┘ └─────────────────┘ └──────────────────┘ │ -│ ┌────────────────┐ ┌─────────────────┐ │ -│ │ command │ │ session │ │ -│ │ /permissions │ │ (临时, "始终允许"│ │ -│ │ │ │ 按钮产生) │ │ -│ └────────────────┘ └─────────────────┘ │ -│ │ -│ 规则格式: "Bash(git push:*)", "Edit(.claude/**)" │ -│ 解析: permissionRuleParser.ts (198行) │ -│ 三种行为: allow / deny / ask │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ 交互式审批流 (竞速模式) │ -│ interactiveHandler.ts (536行) │ -│ │ -│ ask 结果到达 │ -│ │ │ -│ ▼ │ -│ 推送 ToolUseConfirm 到 React 确认队列 │ -│ │ │ -│ ├─────────────┬──────────────┬──────────────┐ │ -│ ▼ ▼ ▼ ▼ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Terminal │ │ Bridge │ │ Channel Relay│ │ Hooks │ │ -│ │ 本地 Y/N │ │ claude.ai│ │ Telegram/IM │ │ 外部程序审批 │ │ -│ └──────────┘ └──────────┘ └──────────────┘ └──────────────┘ │ -│ │ │ │ │ │ -│ └─────────────┴──────────────┴──────────────┘ │ -│ │ │ -│ createResolveOnce 原子竞争 │ -│ 第一个响应者胜出 │ -└───────────────────────────────────────────────────────────────────┘ - -┌───────────────────────────────────────────────────────────────────┐ -│ ToolPermissionContext (核心状态对象) │ -│ 存储于 AppState.toolPermissionContext │ -│ │ -│ ├─ mode: PermissionMode │ -│ ├─ additionalWorkingDirectories: Map │ -│ ├─ alwaysAllowRules: { [source]: string[] } │ -│ ├─ alwaysDenyRules: { [source]: string[] } │ -│ ├─ alwaysAskRules: { [source]: string[] } │ -│ ├─ isBypassPermissionsModeAvailable: boolean │ -│ ├─ isAutoModeAvailable?: boolean │ -│ ├─ strippedDangerousRules?: { [source]: string[] } │ -│ ├─ shouldAvoidPermissionPrompts?: boolean │ -│ └─ prePlanMode?: PermissionMode │ -└───────────────────────────────────────────────────────────────────┘ - -当前问题: - ● 权限逻辑集中在 src/utils/permissions/ (24文件, 9416行) - ● 核心文件: permissions.ts (1486行), permissionSetup.ts (1533行) - ● filesystem.ts (1778行) 文件工具权限, yoloClassifier.ts (1495行) AI分类 - ● bashPermissions 不在 bash/ 目录, bash分类逻辑在 permissions/bashClassifier.ts - ● 规则来源 7 种, 优先级隐含在加载顺序中 - ● UI 组件 src/components/permissions/ (79文件) 与逻辑混合 -``` diff --git a/biome.json b/biome.json index 237d5e8de..4e9e9443a 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist", "!!**/packages/@ant"] + "includes": ["**", "!!**/dist"] }, "formatter": { "enabled": true, diff --git a/packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts b/packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts index 846284778..3669f8fdd 100644 --- a/packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts +++ b/packages/@ant/claude-for-chrome-mcp/src/bridgeClient.ts @@ -3,9 +3,9 @@ * Communicates with the Chrome extension via the office bridge server's /chrome path. */ -import WebSocket from "ws"; +import WebSocket from 'ws' -import { SocketConnectionError } from "./mcpSocketClient.js"; +import { SocketConnectionError } from './mcpSocketClient.js' import { localPlatformLabel, type BridgePermissionRequest, @@ -14,124 +14,122 @@ import { type PermissionMode, type PermissionOverrides, type SocketClient, -} from "./types.js"; +} from './types.js' /** Timeout for list_extensions response from the bridge. */ -const DISCOVERY_TIMEOUT_MS = 5000; +const DISCOVERY_TIMEOUT_MS = 5000 /** How long to wait for a peer_connected event when 0 extensions are found. */ -const PEER_WAIT_TIMEOUT_MS = 10_000; +const PEER_WAIT_TIMEOUT_MS = 10_000 interface PendingToolCall { - resolve: (value: unknown) => void; - reject: (reason: Error) => void; - timer: NodeJS.Timeout; - results: unknown[]; - isTabsContext: boolean; - onPermissionRequest?: (request: BridgePermissionRequest) => Promise; - startTime: number; - toolName: string; + resolve: (value: unknown) => void + reject: (reason: Error) => void + timer: NodeJS.Timeout + results: unknown[] + isTabsContext: boolean + onPermissionRequest?: (request: BridgePermissionRequest) => Promise + startTime: number + toolName: string } export class BridgeClient implements SocketClient { - private ws: WebSocket | null = null; - private connected = false; - private authenticated = false; - private connecting = false; - private reconnectTimer: NodeJS.Timeout | null = null; - private reconnectAttempts = 0; - private pendingCalls = new Map(); + private ws: WebSocket | null = null + private connected = false + private authenticated = false + private connecting = false + private reconnectTimer: NodeJS.Timeout | null = null + private reconnectAttempts = 0 + private pendingCalls = new Map() private notificationHandler: | ((notification: { - method: string; - params?: Record; + method: string + params?: Record }) => void) - | null = null; - private context: ClaudeForChromeContext; - private permissionMode: PermissionMode = "ask"; - private allowedDomains: string[] | undefined; - private tabsContextCollectionTimeoutMs = 2000; - private toolCallTimeoutMs = 120_000; - private connectionStartTime: number | null = null; - private connectionEstablishedTime: number | null = null; + | null = null + private context: ClaudeForChromeContext + private permissionMode: PermissionMode = 'ask' + private allowedDomains: string[] | undefined + private tabsContextCollectionTimeoutMs = 2000 + private toolCallTimeoutMs = 120_000 + private connectionStartTime: number | null = null + private connectionEstablishedTime: number | null = null /** The device_id of the selected Chrome extension for targeted routing. */ - private selectedDeviceId: string | undefined; + private selectedDeviceId: string | undefined /** True after first discovery attempt completes (success or timeout). */ - private discoveryComplete = false; + private discoveryComplete = false /** Shared promise so concurrent callTool invocations join the same discovery. */ - private discoveryPromise: Promise | null = null; + private discoveryPromise: Promise | null = null /** Pending discovery response from bridge. */ private pendingDiscovery: { - resolve: (extensions: ChromeExtensionInfo[]) => void; - timeout: NodeJS.Timeout; - } | null = null; + resolve: (extensions: ChromeExtensionInfo[]) => void + timeout: NodeJS.Timeout + } | null = null /** The device_id we had selected before a peer_disconnected — for auto-reselect. */ - private previousSelectedDeviceId: string | undefined; + private previousSelectedDeviceId: string | undefined /** Callbacks waiting for the next peer_connected event. Receives `true` on peer arrival, `false` on abort. */ - private peerConnectedWaiters: Array<(arrived: boolean) => void> = []; + private peerConnectedWaiters: Array<(arrived: boolean) => void> = [] /** The request_id of the current pending pairing broadcast. */ - private pendingPairingRequestId: string | undefined; - /** True while a pairing broadcast is in flight and no response yet. */ - private pairingInProgress = false; + private pendingPairingRequestId: string | undefined /** The deviceId from a previous persisted pairing. */ - private persistedDeviceId: string | undefined; + private persistedDeviceId: string | undefined /** Resolve callback for a blocking switchBrowser() call. */ private pendingSwitchResolve: | ((result: { deviceId: string; name: string } | null) => void) - | null = null; + | null = null constructor(context: ClaudeForChromeContext) { - this.context = context; + this.context = context if (context.initialPermissionMode) { - this.permissionMode = context.initialPermissionMode; + this.permissionMode = context.initialPermissionMode } } public async ensureConnected(): Promise { - const { logger, serverName } = this.context; + const { logger, serverName } = this.context logger.info( `[${serverName}] ensureConnected called, connected=${this.connected}, authenticated=${this.authenticated}, wsState=${this.ws?.readyState}`, - ); + ) if ( this.connected && this.authenticated && this.ws?.readyState === WebSocket.OPEN ) { - logger.info(`[${serverName}] Already connected and authenticated`); - return true; + logger.info(`[${serverName}] Already connected and authenticated`) + return true } if (!this.connecting) { - logger.info(`[${serverName}] Not connecting, starting connection...`); - await this.connect(); + logger.info(`[${serverName}] Not connecting, starting connection...`) + await this.connect() } else { - logger.info(`[${serverName}] Already connecting, waiting...`); + logger.info(`[${serverName}] Already connecting, waiting...`) } // Wait for authentication with timeout - return new Promise((resolve) => { + return new Promise(resolve => { const timeout = setTimeout(() => { logger.info( `[${serverName}] Connection timeout, connected=${this.connected}, authenticated=${this.authenticated}`, - ); - resolve(false); - }, 10_000); + ) + resolve(false) + }, 10_000) const check = () => { if (this.connected && this.authenticated) { - logger.info(`[${serverName}] Connection successful`); - clearTimeout(timeout); - resolve(true); + logger.info(`[${serverName}] Connection successful`) + clearTimeout(timeout) + resolve(true) } else if (!this.connecting) { - logger.info(`[${serverName}] No longer connecting, giving up`); - clearTimeout(timeout); - resolve(false); + logger.info(`[${serverName}] No longer connecting, giving up`) + clearTimeout(timeout) + resolve(false) } else { - setTimeout(check, 200); + setTimeout(check, 200) } - }; - check(); - }); + } + check() + }) } public async callTool( @@ -139,10 +137,10 @@ export class BridgeClient implements SocketClient { args: Record, permissionOverrides?: PermissionOverrides, ): Promise { - const { logger, serverName, trackEvent } = this.context; + const { logger, serverName, trackEvent } = this.context if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new SocketConnectionError(`[${serverName}] Bridge not connected`); + throw new SocketConnectionError(`[${serverName}] Bridge not connected`) } // Lazy discovery: run on first tool call if no extension selected yet. @@ -150,10 +148,10 @@ export class BridgeClient implements SocketClient { if (!this.selectedDeviceId && !this.discoveryComplete) { this.discoveryPromise ??= this.discoverAndSelectExtension().finally( () => { - this.discoveryPromise = null; + this.discoveryPromise = null }, - ); - await this.discoveryPromise; + ) + await this.discoveryPromise } // TODO: Once all extensions support pairing, throw here for multi-extension @@ -161,59 +159,59 @@ export class BridgeClient implements SocketClient { // routing — it auto-routes to a single extension or returns an error for // multiple extensions without a target_device_id. - const toolUseId = crypto.randomUUID(); - const isTabsContext = name === "tabs_context_mcp"; - const startTime = Date.now(); + const toolUseId = crypto.randomUUID() + const isTabsContext = name === 'tabs_context_mcp' + const startTime = Date.now() const timeoutMs = isTabsContext ? this.tabsContextCollectionTimeoutMs - : this.toolCallTimeoutMs; + : this.toolCallTimeoutMs // Track tool call start - trackEvent?.("chrome_bridge_tool_call_started", { + trackEvent?.('chrome_bridge_tool_call_started', { tool_name: name, tool_use_id: toolUseId, - }); + }) // Per-call overrides (from session context) take priority over // instance values (from set_permission_mode on the singleton). const effectivePermissionMode = - permissionOverrides?.permissionMode ?? this.permissionMode; + permissionOverrides?.permissionMode ?? this.permissionMode const effectiveAllowedDomains = - permissionOverrides?.allowedDomains ?? this.allowedDomains; + permissionOverrides?.allowedDomains ?? this.allowedDomains return new Promise((resolve, reject) => { const timer = setTimeout(() => { - const pending = this.pendingCalls.get(toolUseId); + const pending = this.pendingCalls.get(toolUseId) if (pending) { - this.pendingCalls.delete(toolUseId); - const durationMs = Date.now() - pending.startTime; + this.pendingCalls.delete(toolUseId) + const durationMs = Date.now() - pending.startTime if (isTabsContext && pending.results.length > 0) { // For tabs_context, resolve with collected results even on timeout - trackEvent?.("chrome_bridge_tool_call_completed", { + trackEvent?.('chrome_bridge_tool_call_completed', { tool_name: name, tool_use_id: toolUseId, duration_ms: durationMs, - }); - resolve(this.mergeTabsResults(pending.results)); + }) + resolve(this.mergeTabsResults(pending.results)) } else { logger.warn( `[${serverName}] Tool call timeout: ${name} (${toolUseId.slice(0, 8)}) after ${durationMs}ms, pending calls: ${this.pendingCalls.size}`, - ); - trackEvent?.("chrome_bridge_tool_call_timeout", { + ) + trackEvent?.('chrome_bridge_tool_call_timeout', { tool_name: name, tool_use_id: toolUseId, duration_ms: durationMs, timeout_ms: timeoutMs, - }); + }) reject( new SocketConnectionError( `[${serverName}] Tool call timed out: ${name}`, ), - ); + ) } } - }, timeoutMs); + }, timeoutMs) this.pendingCalls.set(toolUseId, { resolve, @@ -224,38 +222,38 @@ export class BridgeClient implements SocketClient { onPermissionRequest: permissionOverrides?.onPermissionRequest, startTime, toolName: name, - }); + }) const message: Record = { - type: "tool_call", + type: 'tool_call', tool_use_id: toolUseId, client_type: this.context.clientTypeId, tool: name, args, - }; + } // Target the selected extension for routing if (this.selectedDeviceId) { - message.target_device_id = this.selectedDeviceId; + message.target_device_id = this.selectedDeviceId } // Only include permission fields when a value exists. // Priority: per-call override (from session context) > instance value (from set_permission_mode). if (effectivePermissionMode) { - message.permission_mode = effectivePermissionMode; + message.permission_mode = effectivePermissionMode } if (effectiveAllowedDomains?.length) { - message.allowed_domains = effectiveAllowedDomains; + message.allowed_domains = effectiveAllowedDomains } if (permissionOverrides?.onPermissionRequest) { - message.handle_permission_prompts = true; + message.handle_permission_prompts = true } logger.debug( `[${serverName}] Sending tool_call: ${name} (${toolUseId.slice(0, 8)})`, - ); - this.ws!.send(JSON.stringify(message)); - }); + ) + this.ws!.send(JSON.stringify(message)) + }) } public isConnected(): boolean { @@ -263,28 +261,28 @@ export class BridgeClient implements SocketClient { this.connected && this.authenticated && this.ws?.readyState === WebSocket.OPEN - ); + ) } public disconnect(): void { - this.cleanup(); + this.cleanup() } public setNotificationHandler( handler: (notification: { - method: string; - params?: Record; + method: string + params?: Record }) => void, ): void { - this.notificationHandler = handler; + this.notificationHandler = handler } public async setPermissionMode( mode: PermissionMode, allowedDomains?: string[], ): Promise { - this.permissionMode = mode; - this.allowedDomains = allowedDomains; + this.permissionMode = mode + this.allowedDomains = allowedDomains } // =========================================================================== @@ -296,57 +294,57 @@ export class BridgeClient implements SocketClient { * Called lazily on the first tool call. */ private async discoverAndSelectExtension(): Promise { - const { logger, serverName } = this.context; + const { logger, serverName } = this.context - this.persistedDeviceId ??= this.context.getPersistedDeviceId?.(); + this.persistedDeviceId ??= this.context.getPersistedDeviceId?.() - let extensions = await this.queryBridgeExtensions(); + let extensions = await this.queryBridgeExtensions() if (extensions.length === 0) { logger.info( `[${serverName}] No extensions connected, waiting up to ${PEER_WAIT_TIMEOUT_MS}ms for peer_connected`, - ); - const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS); + ) + const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS) if (peerArrived) { - extensions = await this.queryBridgeExtensions(); + extensions = await this.queryBridgeExtensions() } } - this.discoveryComplete = true; + this.discoveryComplete = true if (extensions.length === 0) { // Still nothing — callTool will throw a clear error - logger.info(`[${serverName}] No extensions found after waiting`); - return; + logger.info(`[${serverName}] No extensions found after waiting`) + return } // Single extension: auto-select silently if (extensions.length === 1) { - const ext = extensions[0]!; + const ext = extensions[0]! if (!this.isLocalExtension(ext)) { - this.context.onRemoteExtensionWarning?.(ext); + this.context.onRemoteExtensionWarning?.(ext) } - this.selectExtension(ext.deviceId); - return; + this.selectExtension(ext.deviceId) + return } // Multiple extensions: check for persisted selection if (this.persistedDeviceId) { const persisted = extensions.find( - (e) => e.deviceId === this.persistedDeviceId, - ); + e => e.deviceId === this.persistedDeviceId, + ) if (persisted) { logger.info( `[${serverName}] Auto-connecting to persisted extension: ${persisted.name || persisted.deviceId.slice(0, 8)}`, - ); - this.selectExtension(persisted.deviceId); - return; + ) + this.selectExtension(persisted.deviceId) + return } } // Multiple extensions, no valid persisted selection: broadcast and fail fast - this.broadcastPairingRequest(); - this.pairingInProgress = true; + this.broadcastPairingRequest() + this.pairingInProgress = true } /** @@ -355,36 +353,36 @@ export class BridgeClient implements SocketClient { * may report stale duplicates (e.g. after a service worker restart). */ private async queryBridgeExtensions(): Promise { - const raw: ChromeExtensionInfo[] = await new Promise((resolve) => { + const raw: ChromeExtensionInfo[] = await new Promise(resolve => { const timeout = setTimeout(() => { - this.pendingDiscovery = null; - resolve([]); - }, DISCOVERY_TIMEOUT_MS); + this.pendingDiscovery = null + resolve([]) + }, DISCOVERY_TIMEOUT_MS) - this.pendingDiscovery = { resolve, timeout }; - this.ws?.send(JSON.stringify({ type: "list_extensions" })); - }); + this.pendingDiscovery = { resolve, timeout } + this.ws?.send(JSON.stringify({ type: 'list_extensions' })) + }) - const byDeviceId = new Map(); + const byDeviceId = new Map() for (const ext of raw) { - const existing = byDeviceId.get(ext.deviceId); + const existing = byDeviceId.get(ext.deviceId) if (!existing || ext.connectedAt > existing.connectedAt) { - byDeviceId.set(ext.deviceId, ext); + byDeviceId.set(ext.deviceId, ext) } } - return [...byDeviceId.values()]; + return [...byDeviceId.values()] } /** * Select an extension by device ID for per-message targeted routing. */ private selectExtension(deviceId: string): void { - const { logger, serverName } = this.context; - this.selectedDeviceId = deviceId; - this.previousSelectedDeviceId = undefined; + const { logger, serverName } = this.context + this.selectedDeviceId = deviceId + this.previousSelectedDeviceId = undefined logger.info( `[${serverName}] Selected Chrome extension: ${deviceId.slice(0, 8)}...`, - ); + ) } /** @@ -394,8 +392,8 @@ export class BridgeClient implements SocketClient { * email is the primary differentiator shown in the selection dialog. */ private isLocalExtension(ext: ChromeExtensionInfo): boolean { - if (!ext.osPlatform) return false; - return ext.osPlatform === localPlatformLabel(); + if (!ext.osPlatform) return false + return ext.osPlatform === localPlatformLabel() } /** @@ -403,21 +401,21 @@ export class BridgeClient implements SocketClient { * fires, or `false` if the timeout elapses first. */ private waitForPeerConnected(timeoutMs: number): Promise { - return new Promise((resolve) => { + return new Promise(resolve => { const timer = setTimeout(() => { this.peerConnectedWaiters = this.peerConnectedWaiters.filter( - (w) => w !== onPeer, - ); - resolve(false); - }, timeoutMs); + w => w !== onPeer, + ) + resolve(false) + }, timeoutMs) const onPeer = (arrived: boolean) => { - clearTimeout(timer); - resolve(arrived); - }; + clearTimeout(timer) + resolve(arrived) + } - this.peerConnectedWaiters.push(onPeer); - }); + this.peerConnectedWaiters.push(onPeer) + }) } /** @@ -425,15 +423,15 @@ export class BridgeClient implements SocketClient { * Non-blocking — the pairing_response handler will select the extension. */ private broadcastPairingRequest(): void { - const requestId = crypto.randomUUID(); - this.pendingPairingRequestId = requestId; + const requestId = crypto.randomUUID() + this.pendingPairingRequestId = requestId this.ws?.send( JSON.stringify({ - type: "pairing_request", + type: 'pairing_request', request_id: requestId, client_type: this.context.clientTypeId, }), - ); + ) } /** @@ -443,271 +441,269 @@ export class BridgeClient implements SocketClient { */ public async switchBrowser(): Promise< | { - deviceId: string; - name: string; + deviceId: string + name: string } - | "no_other_browsers" + | 'no_other_browsers' | null > { - const extensions = await this.queryBridgeExtensions(); + const extensions = await this.queryBridgeExtensions() const currentDeviceId = - this.selectedDeviceId ?? this.previousSelectedDeviceId; + this.selectedDeviceId ?? this.previousSelectedDeviceId if ( extensions.length === 0 || (extensions.length === 1 && (!currentDeviceId || extensions[0]!.deviceId === currentDeviceId)) ) { - return "no_other_browsers"; + return 'no_other_browsers' } - this.previousSelectedDeviceId = this.selectedDeviceId; - this.selectedDeviceId = undefined; - this.discoveryComplete = false; - this.pairingInProgress = false; + this.previousSelectedDeviceId = this.selectedDeviceId + this.selectedDeviceId = undefined + this.discoveryComplete = false + this.pairingInProgress = false - const requestId = crypto.randomUUID(); - this.pendingPairingRequestId = requestId; + const requestId = crypto.randomUUID() + this.pendingPairingRequestId = requestId if (this.ws?.readyState !== WebSocket.OPEN) { - return null; + return null } this.ws.send( JSON.stringify({ - type: "pairing_request", + type: 'pairing_request', request_id: requestId, client_type: this.context.clientTypeId, }), - ); + ) // Resolve any previous pending switch so the caller doesn't hang forever if (this.pendingSwitchResolve) { - this.pendingSwitchResolve(null); + this.pendingSwitchResolve(null) } // Block for switch_browser since user is actively engaged - return new Promise((resolve) => { + return new Promise(resolve => { const timer = setTimeout(() => { if (this.pendingPairingRequestId === requestId) { - this.pendingPairingRequestId = undefined; + this.pendingPairingRequestId = undefined } - this.pendingSwitchResolve = null; - resolve(null); - }, 120_000); + this.pendingSwitchResolve = null + resolve(null) + }, 120_000) - this.pendingSwitchResolve = (result) => { - clearTimeout(timer); - this.pendingSwitchResolve = null; - resolve(result); - }; - }); + this.pendingSwitchResolve = result => { + clearTimeout(timer) + this.pendingSwitchResolve = null + resolve(result) + } + }) } private async connect(): Promise { - const { logger, serverName, bridgeConfig, trackEvent } = this.context; + const { logger, serverName, bridgeConfig, trackEvent } = this.context if (!bridgeConfig) { - logger.error(`[${serverName}] No bridge config provided`); - return; + logger.error(`[${serverName}] No bridge config provided`) + return } if (this.connecting) { - return; + return } - this.connecting = true; - this.authenticated = false; - this.connectionStartTime = Date.now(); - this.closeSocket(); + this.connecting = true + this.authenticated = false + this.connectionStartTime = Date.now() + this.closeSocket() // Get user ID for the connection path - let userId: string; - let token: string | undefined; + let userId: string + let token: string | undefined if (bridgeConfig.devUserId) { - userId = bridgeConfig.devUserId; - logger.debug(`[${serverName}] Using dev user ID for bridge connection`); + userId = bridgeConfig.devUserId + logger.debug(`[${serverName}] Using dev user ID for bridge connection`) } else { - logger.debug(`[${serverName}] Fetching user ID for bridge connection`); - const fetchedUserId = await bridgeConfig.getUserId(); + logger.debug(`[${serverName}] Fetching user ID for bridge connection`) + const fetchedUserId = await bridgeConfig.getUserId() if (!fetchedUserId) { - const durationMs = Date.now() - this.connectionStartTime; + const durationMs = Date.now() - this.connectionStartTime logger.error( `[${serverName}] No user ID available after ${durationMs}ms`, - ); - trackEvent?.("chrome_bridge_connection_failed", { + ) + trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, - error_type: "no_user_id", + error_type: 'no_user_id', reconnect_attempt: this.reconnectAttempts, - }); - this.connecting = false; - this.context.onAuthenticationError?.(); - return; + }) + this.connecting = false + this.context.onAuthenticationError?.() + return } - userId = fetchedUserId; + userId = fetchedUserId - logger.debug( - `[${serverName}] Fetching OAuth token for bridge connection`, - ); - token = await bridgeConfig.getOAuthToken(); + logger.debug(`[${serverName}] Fetching OAuth token for bridge connection`) + token = await bridgeConfig.getOAuthToken() if (!token) { - const durationMs = Date.now() - this.connectionStartTime; + const durationMs = Date.now() - this.connectionStartTime logger.error( `[${serverName}] No OAuth token available after ${durationMs}ms`, - ); - trackEvent?.("chrome_bridge_connection_failed", { + ) + trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, - error_type: "no_oauth_token", + error_type: 'no_oauth_token', reconnect_attempt: this.reconnectAttempts, - }); - this.connecting = false; - this.context.onAuthenticationError?.(); - return; + }) + this.connecting = false + this.context.onAuthenticationError?.() + return } } // Connect to user-specific endpoint: /chrome/ - const wsUrl = `${bridgeConfig.url}/chrome/${userId}`; - logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`); + const wsUrl = `${bridgeConfig.url}/chrome/${userId}` + logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`) // Track connection started - trackEvent?.("chrome_bridge_connection_started", { + trackEvent?.('chrome_bridge_connection_started', { bridge_url: wsUrl, - }); + }) try { - this.ws = new WebSocket(wsUrl); + this.ws = new WebSocket(wsUrl) } catch (error) { - const durationMs = Date.now() - this.connectionStartTime; + const durationMs = Date.now() - this.connectionStartTime logger.error( `[${serverName}] Failed to create WebSocket after ${durationMs}ms:`, error, - ); - trackEvent?.("chrome_bridge_connection_failed", { + ) + trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, - error_type: "websocket_error", + error_type: 'websocket_error', reconnect_attempt: this.reconnectAttempts, - }); - this.connecting = false; - this.scheduleReconnect(); - return; + }) + this.connecting = false + this.scheduleReconnect() + return } - this.ws.on("open", () => { + this.ws.on('open', () => { logger.info( `[${serverName}] WebSocket connected, sending connect message`, - ); + ) // First message must be connect (same format as office path) const connectMessage: Record = { - type: "connect", + type: 'connect', client_type: this.context.clientTypeId, - }; + } if (bridgeConfig.devUserId) { - connectMessage.dev_user_id = bridgeConfig.devUserId; + connectMessage.dev_user_id = bridgeConfig.devUserId } else { - connectMessage.oauth_token = token; + connectMessage.oauth_token = token } - this.ws?.send(JSON.stringify(connectMessage)); - }); + this.ws?.send(JSON.stringify(connectMessage)) + }) - this.ws.on("message", (data: WebSocket.Data) => { + this.ws.on('message', (data: WebSocket.Data) => { try { - const message = JSON.parse(data.toString()) as Record; + const message = JSON.parse(data.toString()) as Record logger.debug( `[${serverName}] Bridge received: ${JSON.stringify(message)}`, - ); - this.handleMessage(message); + ) + this.handleMessage(message) } catch (error) { - logger.error(`[${serverName}] Failed to parse bridge message:`, error); + logger.error(`[${serverName}] Failed to parse bridge message:`, error) } - }); + }) - this.ws.on("close", (code: number) => { + this.ws.on('close', (code: number) => { const durationSinceConnect = this.connectionEstablishedTime ? Date.now() - this.connectionEstablishedTime - : 0; + : 0 logger.info( `[${serverName}] Bridge connection closed (code: ${code}, duration: ${durationSinceConnect}ms)`, - ); - trackEvent?.("chrome_bridge_disconnected", { + ) + trackEvent?.('chrome_bridge_disconnected', { close_code: code, duration_since_connect_ms: durationSinceConnect, reconnect_attempt: this.reconnectAttempts + 1, - }); - this.connected = false; - this.authenticated = false; - this.connecting = false; - this.connectionEstablishedTime = null; - this.scheduleReconnect(); - }); + }) + this.connected = false + this.authenticated = false + this.connecting = false + this.connectionEstablishedTime = null + this.scheduleReconnect() + }) - this.ws.on("error", (error: Error) => { + this.ws.on('error', (error: Error) => { const durationMs = this.connectionStartTime ? Date.now() - this.connectionStartTime - : 0; + : 0 logger.error( `[${serverName}] Bridge WebSocket error after ${durationMs}ms: ${error.message}`, - ); - trackEvent?.("chrome_bridge_connection_failed", { + ) + trackEvent?.('chrome_bridge_connection_failed', { duration_ms: durationMs, - error_type: "websocket_error", + error_type: 'websocket_error', reconnect_attempt: this.reconnectAttempts, - }); - this.connected = false; - this.authenticated = false; - this.connecting = false; - }); + }) + this.connected = false + this.authenticated = false + this.connecting = false + }) } private handleMessage(message: Record): void { - const { logger, serverName, trackEvent } = this.context; + const { logger, serverName, trackEvent } = this.context switch (message.type) { - case "paired": { + case 'paired': { const durationMs = this.connectionStartTime ? Date.now() - this.connectionStartTime - : 0; + : 0 logger.info( `[${serverName}] Paired with Chrome extension (duration: ${durationMs}ms)`, - ); - this.connected = true; - this.authenticated = true; - this.connecting = false; - this.reconnectAttempts = 0; - this.connectionEstablishedTime = Date.now(); - trackEvent?.("chrome_bridge_connection_succeeded", { + ) + this.connected = true + this.authenticated = true + this.connecting = false + this.reconnectAttempts = 0 + this.connectionEstablishedTime = Date.now() + trackEvent?.('chrome_bridge_connection_succeeded', { duration_ms: durationMs, - status: "paired", - }); - break; + status: 'paired', + }) + break } - case "waiting": { + case 'waiting': { const durationMs = this.connectionStartTime ? Date.now() - this.connectionStartTime - : 0; + : 0 logger.info( `[${serverName}] Waiting for Chrome extension to connect (duration: ${durationMs}ms)`, - ); - this.connected = true; - this.authenticated = true; - this.connecting = false; - this.reconnectAttempts = 0; - this.connectionEstablishedTime = Date.now(); - trackEvent?.("chrome_bridge_connection_succeeded", { + ) + this.connected = true + this.authenticated = true + this.connecting = false + this.reconnectAttempts = 0 + this.connectionEstablishedTime = Date.now() + trackEvent?.('chrome_bridge_connection_succeeded', { duration_ms: durationMs, - status: "waiting", - }); - break; + status: 'waiting', + }) + break } - case "peer_connected": - logger.info(`[${serverName}] Chrome extension connected to bridge`); - trackEvent?.("chrome_bridge_peer_connected", null); + case 'peer_connected': + logger.info(`[${serverName}] Chrome extension connected to bridge`) + trackEvent?.('chrome_bridge_peer_connected', null) // If no extension selected, mark discovery as needed (next tool call will discover) if (!this.selectedDeviceId) { - this.discoveryComplete = false; + this.discoveryComplete = false } // Auto-reselect if the previously selected extension reconnected (e.g., service worker restart) if ( @@ -717,131 +713,129 @@ export class BridgeClient implements SocketClient { ) { logger.info( `[${serverName}] Previously selected extension reconnected, auto-reselecting`, - ); - this.selectExtension(this.previousSelectedDeviceId); - this.previousSelectedDeviceId = undefined; + ) + this.selectExtension(this.previousSelectedDeviceId) + this.previousSelectedDeviceId = undefined } if (this.peerConnectedWaiters.length > 0) { - const waiters = this.peerConnectedWaiters; - this.peerConnectedWaiters = []; + const waiters = this.peerConnectedWaiters + this.peerConnectedWaiters = [] for (const waiter of waiters) { - waiter(true); + waiter(true) } } - break; + break - case "peer_disconnected": - logger.info( - `[${serverName}] Chrome extension disconnected from bridge`, - ); - trackEvent?.("chrome_bridge_peer_disconnected", null); + case 'peer_disconnected': + logger.info(`[${serverName}] Chrome extension disconnected from bridge`) + trackEvent?.('chrome_bridge_peer_disconnected', null) // If the selected extension disconnected, clear selection for re-discovery if (message.deviceId && message.deviceId === this.selectedDeviceId) { logger.info( `[${serverName}] Selected extension disconnected, clearing selection`, - ); - this.previousSelectedDeviceId = this.selectedDeviceId; - this.selectedDeviceId = undefined; - this.discoveryComplete = false; + ) + this.previousSelectedDeviceId = this.selectedDeviceId + this.selectedDeviceId = undefined + this.discoveryComplete = false } - break; + break - case "extensions_list": + case 'extensions_list': // Response to list_extensions — resolve pending discovery if (this.pendingDiscovery) { - clearTimeout(this.pendingDiscovery.timeout); + clearTimeout(this.pendingDiscovery.timeout) this.pendingDiscovery.resolve( (message.extensions as ChromeExtensionInfo[]) ?? [], - ); - this.pendingDiscovery = null; + ) + this.pendingDiscovery = null } - break; + break - case "pairing_response": { - const requestId = message.request_id as string; - const responseDeviceId = message.device_id as string; - const responseName = message.name as string; + case 'pairing_response': { + const requestId = message.request_id as string + const responseDeviceId = message.device_id as string + const responseName = message.name as string if ( this.pendingPairingRequestId === requestId && responseDeviceId && responseName ) { - this.pendingPairingRequestId = undefined; - this.pairingInProgress = false; - this.selectExtension(responseDeviceId); - this.context.onExtensionPaired?.(responseDeviceId, responseName); + this.pendingPairingRequestId = undefined + this.pairingInProgress = false + this.selectExtension(responseDeviceId) + this.context.onExtensionPaired?.(responseDeviceId, responseName) logger.info( `[${serverName}] Paired with "${responseName}" (${responseDeviceId.slice(0, 8)})`, - ); + ) if (this.pendingSwitchResolve) { this.pendingSwitchResolve({ deviceId: responseDeviceId, name: responseName, - }); - this.pendingSwitchResolve = null; + }) + this.pendingSwitchResolve = null } } - break; + break } - case "ping": - this.ws?.send(JSON.stringify({ type: "pong" })); - break; + case 'ping': + this.ws?.send(JSON.stringify({ type: 'pong' })) + break - case "pong": + case 'pong': // Response to our keepalive, nothing to do - break; + break - case "tool_result": - this.handleToolResult(message); - break; + case 'tool_result': + this.handleToolResult(message) + break - case "permission_request": - void this.handlePermissionRequest(message); - break; + case 'permission_request': + void this.handlePermissionRequest(message) + break - case "notification": + case 'notification': if (this.notificationHandler) { this.notificationHandler({ method: message.method as string, params: message.params as Record | undefined, - }); + }) } - break; + break - case "error": - logger.warn(`[${serverName}] Bridge error: ${message.error}`); + case 'error': + logger.warn(`[${serverName}] Bridge error: ${message.error}`) // If we had a selected extension, the error may indicate it's gone // (e.g., extension disconnected between list and select). Clear state // so the next tool call re-discovers. if (this.selectedDeviceId) { - this.selectedDeviceId = undefined; - this.discoveryComplete = false; + this.selectedDeviceId = undefined + this.discoveryComplete = false } - break; + break default: logger.warn( `[${serverName}] Unrecognized bridge message type: ${message.type}`, - ); + ) } } private async handlePermissionRequest( message: Record, ): Promise { - const { logger, serverName } = this.context; - const toolUseId = message.tool_use_id as string; - const requestId = message.request_id as string; + const { logger, serverName } = this.context + const toolUseId = message.tool_use_id as string + const requestId = message.request_id as string if (!toolUseId || !requestId) { logger.warn( `[${serverName}] permission_request missing tool_use_id or request_id`, - ); - return; + ) + return } - const pending = this.pendingCalls.get(toolUseId); + const pending = this.pendingCalls.get(toolUseId) if (!pending?.onPermissionRequest) { // Don't auto-deny — the bridge broadcasts permission_request to all // connected MCP clients, and only the client that made the tool call @@ -849,110 +843,109 @@ export class BridgeClient implements SocketClient { // client's handler when multiple Desktop instances are connected. logger.debug( `[${serverName}] Ignoring permission_request for unknown tool_use_id ${toolUseId.slice(0, 8)} (not our call)`, - ); - return; + ) + return } const request: BridgePermissionRequest = { toolUseId, requestId, - toolType: (message.tool_type as string) ?? "unknown", - url: (message.url as string) ?? "", + toolType: (message.tool_type as string) ?? 'unknown', + url: (message.url as string) ?? '', actionData: message.action_data as Record | undefined, - }; + } try { - const allowed = await pending.onPermissionRequest(request); - this.sendPermissionResponse(requestId, allowed); + const allowed = await pending.onPermissionRequest(request) + this.sendPermissionResponse(requestId, allowed) } catch (error) { - logger.error(`[${serverName}] Error handling permission request:`, error); - this.sendPermissionResponse(requestId, false); + logger.error(`[${serverName}] Error handling permission request:`, error) + this.sendPermissionResponse(requestId, false) } } private sendPermissionResponse(requestId: string, allowed: boolean): void { if (this.ws?.readyState === WebSocket.OPEN) { const message: Record = { - type: "permission_response", + type: 'permission_response', request_id: requestId, allowed, - }; - if (this.selectedDeviceId) { - message.target_device_id = this.selectedDeviceId; } - this.ws.send(JSON.stringify(message)); + if (this.selectedDeviceId) { + message.target_device_id = this.selectedDeviceId + } + this.ws.send(JSON.stringify(message)) } } private handleToolResult(message: Record): void { - const { logger, serverName, trackEvent } = this.context; - const toolUseId = message.tool_use_id as string; + const { logger, serverName, trackEvent } = this.context + const toolUseId = message.tool_use_id as string if (!toolUseId) { - logger.warn(`[${serverName}] Received tool_result without tool_use_id`); - return; + logger.warn(`[${serverName}] Received tool_result without tool_use_id`) + return } - const pending = this.pendingCalls.get(toolUseId); + const pending = this.pendingCalls.get(toolUseId) if (!pending) { logger.debug( `[${serverName}] Received tool_result for unknown call: ${toolUseId.slice(0, 8)}`, - ); - return; + ) + return } - const durationMs = Date.now() - pending.startTime; + const durationMs = Date.now() - pending.startTime // Normalize bridge response format to match socket client format. // Bridge sends: { type, tool_use_id, content: [...], is_error?: boolean } // Socket sends: { result: { content: [...] } } or { error: { content: [...] } } - const normalized = this.normalizeBridgeResponse(message); - const isError = Boolean(message.is_error) || "error" in normalized; + const normalized = this.normalizeBridgeResponse(message) + const isError = Boolean(message.is_error) || 'error' in normalized if (pending.isTabsContext && !this.selectedDeviceId) { // No extension selected: collect results from all extensions (pre-selection / backward compat) - pending.results.push(normalized); + pending.results.push(normalized) // Don't resolve yet — let the timer handle collection } else { // For other tools, resolve on first result - clearTimeout(pending.timer); - this.pendingCalls.delete(toolUseId); + clearTimeout(pending.timer) + this.pendingCalls.delete(toolUseId) if (isError) { // Extract error message for telemetry const errorContent = (normalized as { error?: { content?: unknown[] } }) - .error?.content; - let errorMessage = "Unknown error"; + .error?.content + let errorMessage = 'Unknown error' if (Array.isArray(errorContent)) { const textItem = errorContent.find( - (item) => - typeof item === "object" && item !== null && "text" in item, - ) as { text?: string } | undefined; + item => typeof item === 'object' && item !== null && 'text' in item, + ) as { text?: string } | undefined if (textItem?.text) { - errorMessage = textItem.text.slice(0, 200); + errorMessage = textItem.text.slice(0, 200) } } logger.warn( `[${serverName}] Tool call error: ${pending.toolName} (${toolUseId.slice(0, 8)}) after ${durationMs}ms`, - ); - trackEvent?.("chrome_bridge_tool_call_error", { + ) + trackEvent?.('chrome_bridge_tool_call_error', { tool_name: pending.toolName, tool_use_id: toolUseId, duration_ms: durationMs, error_message: errorMessage, - }); + }) } else { logger.debug( `[${serverName}] Tool call completed: ${pending.toolName} (${toolUseId.slice(0, 8)}) in ${durationMs}ms`, - ); - trackEvent?.("chrome_bridge_tool_call_completed", { + ) + trackEvent?.('chrome_bridge_tool_call_completed', { tool_name: pending.toolName, tool_use_id: toolUseId, duration_ms: durationMs, - }); + }) } - pending.resolve(normalized); + pending.resolve(normalized) } } @@ -961,43 +954,43 @@ export class BridgeClient implements SocketClient { ): Record { // Already has result/error wrapper (socket format) — pass through if (message.result || message.error) { - return message; + return message } // Bridge format has content at top level — wrap it if (message.content) { if (message.is_error) { - return { error: { content: message.content } }; + return { error: { content: message.content } } } - return { result: { content: message.content } }; + return { result: { content: message.content } } } - return message; + return message } private mergeTabsResults(results: unknown[]): unknown { - const mergedTabs: unknown[] = []; + const mergedTabs: unknown[] = [] for (const result of results) { - const msg = result as Record; + const msg = result as Record const resultData = msg.result as | { content?: Array<{ type: string; text?: string }> } - | undefined; - const content = resultData?.content; + | undefined + const content = resultData?.content - if (!content || !Array.isArray(content)) continue; + if (!content || !Array.isArray(content)) continue for (const item of content) { - if (item.type === "text" && item.text) { + if (item.type === 'text' && item.text) { try { - const parsed = JSON.parse(item.text); + const parsed = JSON.parse(item.text) if (Array.isArray(parsed)) { - mergedTabs.push(...parsed); + mergedTabs.push(...parsed) } else if ( parsed?.availableTabs && Array.isArray(parsed.availableTabs) ) { - mergedTabs.push(...parsed.availableTabs); + mergedTabs.push(...parsed.availableTabs) } } catch { // Not JSON, skip @@ -1008,119 +1001,116 @@ export class BridgeClient implements SocketClient { if (mergedTabs.length > 0) { const tabListText = mergedTabs - .map((t) => { - const tab = t as { tabId: number; title: string; url: string }; - return ` \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})`; + .map(t => { + const tab = t as { tabId: number; title: string; url: string } + return ` \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})` }) - .join("\n"); + .join('\n') return { result: { content: [ { - type: "text", + type: 'text', text: JSON.stringify({ availableTabs: mergedTabs }), }, { - type: "text", + type: 'text', text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`, }, ], }, - }; + } } // Return first result as fallback - return results[0]; + return results[0] } private scheduleReconnect(): void { - const { logger, serverName, trackEvent } = this.context; + const { logger, serverName, trackEvent } = this.context - if (this.reconnectTimer) return; + if (this.reconnectTimer) return - this.reconnectAttempts++; + this.reconnectAttempts++ if (this.reconnectAttempts > 100) { logger.warn( `[${serverName}] Giving up bridge reconnection after 100 attempts`, - ); - trackEvent?.("chrome_bridge_reconnect_exhausted", { + ) + trackEvent?.('chrome_bridge_reconnect_exhausted', { total_attempts: 100, - }); - this.reconnectAttempts = 0; - return; + }) + this.reconnectAttempts = 0 + return } - const delay = Math.min( - 2000 * Math.pow(1.5, this.reconnectAttempts - 1), - 30_000, - ); + const delay = Math.min(2000 * 1.5 ** (this.reconnectAttempts - 1), 30_000) if (this.reconnectAttempts <= 10 || this.reconnectAttempts % 10 === 0) { logger.info( `[${serverName}] Bridge reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`, - ); + ) } this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - void this.connect(); - }, delay); + this.reconnectTimer = null + void this.connect() + }, delay) } private closeSocket(): void { if (this.ws) { - this.ws.removeAllListeners(); - this.ws.close(); - this.ws = null; + this.ws.removeAllListeners() + this.ws.close() + this.ws = null } - this.connected = false; - this.authenticated = false; + this.connected = false + this.authenticated = false // Clear extension selection state so reconnections start fresh - this.selectedDeviceId = undefined; - this.discoveryComplete = false; - this.pendingPairingRequestId = undefined; - this.pairingInProgress = false; + this.selectedDeviceId = undefined + this.discoveryComplete = false + this.pendingPairingRequestId = undefined + this.pairingInProgress = false if (this.pendingSwitchResolve) { - this.pendingSwitchResolve(null); - this.pendingSwitchResolve = null; + this.pendingSwitchResolve(null) + this.pendingSwitchResolve = null } if (this.pendingDiscovery) { - clearTimeout(this.pendingDiscovery.timeout); - this.pendingDiscovery.resolve([]); - this.pendingDiscovery = null; + clearTimeout(this.pendingDiscovery.timeout) + this.pendingDiscovery.resolve([]) + this.pendingDiscovery = null } // Unblock any in-progress waitForPeerConnected so it doesn't hang until its timeout if (this.peerConnectedWaiters.length > 0) { - const waiters = this.peerConnectedWaiters; - this.peerConnectedWaiters = []; + const waiters = this.peerConnectedWaiters + this.peerConnectedWaiters = [] for (const waiter of waiters) { - waiter(false); + waiter(false) } } } private cleanup(): void { if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null } // Reject all pending calls for (const [id, pending] of this.pendingCalls) { - clearTimeout(pending.timer); - pending.reject(new SocketConnectionError("Bridge client disconnected")); - this.pendingCalls.delete(id); + clearTimeout(pending.timer) + pending.reject(new SocketConnectionError('Bridge client disconnected')) + this.pendingCalls.delete(id) } - this.closeSocket(); - this.reconnectAttempts = 0; + this.closeSocket() + this.reconnectAttempts = 0 } } export function createBridgeClient( context: ClaudeForChromeContext, ): BridgeClient { - return new BridgeClient(context); + return new BridgeClient(context) } diff --git a/packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts b/packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts index 1443d7f5c..f359667ad 100644 --- a/packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts +++ b/packages/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts @@ -1,327 +1,324 @@ -import { promises as fsPromises } from "fs"; -import { createConnection } from "net"; -import type { Socket } from "net"; -import { platform } from "os"; -import { dirname } from "path"; +import { promises as fsPromises } from 'fs' +import { createConnection } from 'net' +import type { Socket } from 'net' +import { platform } from 'os' +import { dirname } from 'path' import type { ClaudeForChromeContext, PermissionMode, PermissionOverrides, -} from "./types.js"; +} from './types.js' export class SocketConnectionError extends Error { constructor(message: string) { - super(message); - this.name = "SocketConnectionError"; + super(message) + this.name = 'SocketConnectionError' } } interface ToolRequest { - method: string; // "execute_tool" + method: string // "execute_tool" params?: { - client_id?: string; // "desktop" | "claude-code" - tool?: string; - args?: Record; - }; + client_id?: string // "desktop" | "claude-code" + tool?: string + args?: Record + } } interface ToolResponse { - result?: unknown; - error?: string; + result?: unknown + error?: string } interface Notification { - method: string; - params?: Record; + method: string + params?: Record } -type SocketMessage = ToolResponse | Notification; +type SocketMessage = ToolResponse | Notification function isToolResponse(message: SocketMessage): message is ToolResponse { - return "result" in message || "error" in message; + return 'result' in message || 'error' in message } function isNotification(message: SocketMessage): message is Notification { - return "method" in message && typeof message.method === "string"; + return 'method' in message && typeof message.method === 'string' } class McpSocketClient { - private socket: Socket | null = null; - private connected = false; - private connecting = false; - private responseCallback: ((response: ToolResponse) => void) | null = null; + private socket: Socket | null = null + private connected = false + private connecting = false + private responseCallback: ((response: ToolResponse) => void) | null = null private notificationHandler: ((notification: Notification) => void) | null = - null; - private responseBuffer = Buffer.alloc(0); - private reconnectAttempts = 0; - private maxReconnectAttempts = 10; - private reconnectDelay = 1000; - private reconnectTimer: NodeJS.Timeout | null = null; - private context: ClaudeForChromeContext; + null + private responseBuffer = Buffer.alloc(0) + private reconnectAttempts = 0 + private maxReconnectAttempts = 10 + private reconnectDelay = 1000 + private reconnectTimer: NodeJS.Timeout | null = null + private context: ClaudeForChromeContext // When true, disables automatic reconnection. Used by McpSocketPool which // manages reconnection externally by rescanning available sockets. - public disableAutoReconnect = false; + public disableAutoReconnect = false constructor(context: ClaudeForChromeContext) { - this.context = context; + this.context = context } private async connect(): Promise { - const { serverName, logger } = this.context; + const { serverName, logger } = this.context if (this.connecting) { logger.info( `[${serverName}] Already connecting, skipping duplicate attempt`, - ); - return; + ) + return } - this.closeSocket(); - this.connecting = true; + this.closeSocket() + this.connecting = true - const socketPath = - this.context.getSocketPath?.() ?? this.context.socketPath; - logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`); + const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath + logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`) try { - await this.validateSocketSecurity(socketPath); + await this.validateSocketSecurity(socketPath) } catch (error) { - this.connecting = false; - logger.info(`[${serverName}] Security validation failed:`, error); + this.connecting = false + logger.info(`[${serverName}] Security validation failed:`, error) // Don't retry on security failures (wrong perms/owner) - those won't // self-resolve. Only the error handler retries on transient errors. - return; + return } - this.socket = createConnection(socketPath); + this.socket = createConnection(socketPath) // Timeout the initial connection attempt - if socket file exists but native // host is dead, the connect can hang indefinitely const connectTimeout = setTimeout(() => { if (!this.connected) { - logger.info( - `[${serverName}] Connection attempt timed out after 5000ms`, - ); - this.closeSocket(); - this.scheduleReconnect(); + logger.info(`[${serverName}] Connection attempt timed out after 5000ms`) + this.closeSocket() + this.scheduleReconnect() } - }, 5000); + }, 5000) - this.socket.on("connect", () => { - clearTimeout(connectTimeout); - this.connected = true; - this.connecting = false; - this.reconnectAttempts = 0; - logger.info(`[${serverName}] Successfully connected to bridge server`); - }); + this.socket.on('connect', () => { + clearTimeout(connectTimeout) + this.connected = true + this.connecting = false + this.reconnectAttempts = 0 + logger.info(`[${serverName}] Successfully connected to bridge server`) + }) - this.socket.on("data", (data: Buffer) => { - this.responseBuffer = Buffer.concat([this.responseBuffer, data]); + this.socket.on('data', (data: Buffer) => { + this.responseBuffer = Buffer.concat([this.responseBuffer, data]) while (this.responseBuffer.length >= 4) { - const length = this.responseBuffer.readUInt32LE(0); + const length = this.responseBuffer.readUInt32LE(0) if (this.responseBuffer.length < 4 + length) { - break; + break } - const messageBytes = this.responseBuffer.slice(4, 4 + length); - this.responseBuffer = this.responseBuffer.slice(4 + length); + const messageBytes = this.responseBuffer.slice(4, 4 + length) + this.responseBuffer = this.responseBuffer.slice(4 + length) try { const message = JSON.parse( - messageBytes.toString("utf-8"), - ) as SocketMessage; + messageBytes.toString('utf-8'), + ) as SocketMessage if (isNotification(message)) { logger.info( `[${serverName}] Received notification: ${message.method}`, - ); + ) if (this.notificationHandler) { - this.notificationHandler(message); + this.notificationHandler(message) } } else if (isToolResponse(message)) { - logger.info(`[${serverName}] Received tool response: ${message}`); - this.handleResponse(message); + logger.info(`[${serverName}] Received tool response: ${message}`) + this.handleResponse(message) } else { - logger.info(`[${serverName}] Received unknown message: ${message}`); + logger.info(`[${serverName}] Received unknown message: ${message}`) } } catch (error) { - logger.info(`[${serverName}] Failed to parse message:`, error); + logger.info(`[${serverName}] Failed to parse message:`, error) } } - }); + }) - this.socket.on("error", (error: Error & { code?: string }) => { - clearTimeout(connectTimeout); - logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error); - this.connected = false; - this.connecting = false; + this.socket.on('error', (error: Error & { code?: string }) => { + clearTimeout(connectTimeout) + logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error) + this.connected = false + this.connecting = false if ( error.code && [ - "ECONNREFUSED", // Native host not listening (stale socket) - "ECONNRESET", // Connection reset by peer - "EPIPE", // Broken pipe (native host died mid-write) - "ENOENT", // Socket file was deleted - "EOPNOTSUPP", // Socket file exists but is not a valid socket - "ECONNABORTED", // Connection aborted + 'ECONNREFUSED', // Native host not listening (stale socket) + 'ECONNRESET', // Connection reset by peer + 'EPIPE', // Broken pipe (native host died mid-write) + 'ENOENT', // Socket file was deleted + 'EOPNOTSUPP', // Socket file exists but is not a valid socket + 'ECONNABORTED', // Connection aborted ].includes(error.code) ) { - this.scheduleReconnect(); + this.scheduleReconnect() } - }); + }) - this.socket.on("close", () => { - clearTimeout(connectTimeout); - this.connected = false; - this.connecting = false; - this.scheduleReconnect(); - }); + this.socket.on('close', () => { + clearTimeout(connectTimeout) + this.connected = false + this.connecting = false + this.scheduleReconnect() + }) } private scheduleReconnect(): void { - const { serverName, logger } = this.context; + const { serverName, logger } = this.context if (this.disableAutoReconnect) { - return; + return } if (this.reconnectTimer) { - logger.info(`[${serverName}] Reconnect already scheduled, skipping`); - return; + logger.info(`[${serverName}] Reconnect already scheduled, skipping`) + return } - this.reconnectAttempts++; + this.reconnectAttempts++ // Give up after extended polling (~50 min). A new ensureConnected() call // from a tool request will restart the cycle if needed. - const maxTotalAttempts = 100; + const maxTotalAttempts = 100 if (this.reconnectAttempts > maxTotalAttempts) { logger.info( `[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`, - ); - this.reconnectAttempts = 0; - return; + ) + this.reconnectAttempts = 0 + return } // Use aggressive backoff for first 10 attempts, then slow poll every 30s. const delay = Math.min( - this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), + this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1), 30000, - ); + ) if (this.reconnectAttempts <= this.maxReconnectAttempts) { logger.info( `[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${ this.reconnectAttempts })`, - ); + ) } else if (this.reconnectAttempts % 10 === 0) { // Log every 10th slow-poll attempt to avoid log spam logger.info( `[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`, - ); + ) } this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - void this.connect(); - }, delay); + this.reconnectTimer = null + void this.connect() + }, delay) } private handleResponse(response: ToolResponse): void { if (this.responseCallback) { - const callback = this.responseCallback; - this.responseCallback = null; - callback(response); + const callback = this.responseCallback + this.responseCallback = null + callback(response) } } public setNotificationHandler( handler: (notification: Notification) => void, ): void { - this.notificationHandler = handler; + this.notificationHandler = handler } public async ensureConnected(): Promise { - const { serverName } = this.context; + const { serverName } = this.context if (this.connected && this.socket) { - return true; + return true } if (!this.socket && !this.connecting) { - await this.connect(); + await this.connect() } // Wait for connection with timeout return new Promise((resolve, reject) => { - let checkTimeoutId: NodeJS.Timeout | null = null; + let checkTimeoutId: NodeJS.Timeout | null = null const timeout = setTimeout(() => { if (checkTimeoutId) { - clearTimeout(checkTimeoutId); + clearTimeout(checkTimeoutId) } reject( new SocketConnectionError( `[${serverName}] Connection attempt timed out after 5000ms`, ), - ); - }, 5000); + ) + }, 5000) const checkConnection = () => { if (this.connected) { - clearTimeout(timeout); - resolve(true); + clearTimeout(timeout) + resolve(true) } else { - checkTimeoutId = setTimeout(checkConnection, 500); + checkTimeoutId = setTimeout(checkConnection, 500) } - }; - checkConnection(); - }); + } + checkConnection() + }) } private async sendRequest( request: ToolRequest, timeoutMs = 30000, ): Promise { - const { serverName } = this.context; + const { serverName } = this.context if (!this.socket) { throw new SocketConnectionError( `[${serverName}] Cannot send request: not connected`, - ); + ) } - const socket = this.socket; + const socket = this.socket return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - this.responseCallback = null; + this.responseCallback = null reject( new SocketConnectionError( `[${serverName}] Tool request timed out after ${timeoutMs}ms`, ), - ); - }, timeoutMs); + ) + }, timeoutMs) - this.responseCallback = (response) => { - clearTimeout(timeout); - resolve(response); - }; + this.responseCallback = response => { + clearTimeout(timeout) + resolve(response) + } - const requestJson = JSON.stringify(request); - const requestBytes = Buffer.from(requestJson, "utf-8"); + const requestJson = JSON.stringify(request) + const requestBytes = Buffer.from(requestJson, 'utf-8') - const lengthPrefix = Buffer.allocUnsafe(4); - lengthPrefix.writeUInt32LE(requestBytes.length, 0); + const lengthPrefix = Buffer.allocUnsafe(4) + lengthPrefix.writeUInt32LE(requestBytes.length, 0) - const message = Buffer.concat([lengthPrefix, requestBytes]); - socket.write(message); - }); + const message = Buffer.concat([lengthPrefix, requestBytes]) + socket.write(message) + }) } public async callTool( @@ -330,15 +327,15 @@ class McpSocketClient { _permissionOverrides?: PermissionOverrides, ): Promise { const request: ToolRequest = { - method: "execute_tool", + method: 'execute_tool', params: { client_id: this.context.clientTypeId, tool: name, args, }, - }; + } - return this.sendRequestWithRetry(request); + return this.sendRequestWithRetry(request) } /** @@ -349,23 +346,23 @@ class McpSocketClient { * and retry once. */ private async sendRequestWithRetry(request: ToolRequest): Promise { - const { serverName, logger } = this.context; + const { serverName, logger } = this.context try { - return await this.sendRequest(request); + return await this.sendRequest(request) } catch (error) { if (!(error instanceof SocketConnectionError)) { - throw error; + throw error } logger.info( `[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`, - ); + ) - this.closeSocket(); - await this.ensureConnected(); + this.closeSocket() + await this.ensureConnected() - return await this.sendRequest(request); + return await this.sendRequest(request) } } @@ -377,109 +374,109 @@ class McpSocketClient { } public isConnected(): boolean { - return this.connected; + return this.connected } private closeSocket(): void { if (this.socket) { - this.socket.removeAllListeners(); - this.socket.end(); - this.socket.destroy(); - this.socket = null; + this.socket.removeAllListeners() + this.socket.end() + this.socket.destroy() + this.socket = null } - this.connected = false; - this.connecting = false; + this.connected = false + this.connecting = false } private cleanup(): void { if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null } - this.closeSocket(); - this.reconnectAttempts = 0; - this.responseBuffer = Buffer.alloc(0); - this.responseCallback = null; + this.closeSocket() + this.reconnectAttempts = 0 + this.responseBuffer = Buffer.alloc(0) + this.responseCallback = null } public disconnect(): void { - this.cleanup(); + this.cleanup() } private async validateSocketSecurity(socketPath: string): Promise { - const { serverName, logger } = this.context; - if (platform() === "win32") { - return; + const { serverName, logger } = this.context + if (platform() === 'win32') { + return } try { // Validate the parent directory permissions if it's the socket directory // (not /tmp itself, which has mode 1777 for legacy single-socket paths) - const dirPath = dirname(socketPath); - const dirBasename = dirPath.split("/").pop() || ""; - const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-"); + const dirPath = dirname(socketPath) + const dirBasename = dirPath.split('/').pop() || '' + const isSocketDir = dirBasename.startsWith('claude-mcp-browser-bridge-') if (isSocketDir) { try { - const dirStats = await fsPromises.stat(dirPath); + const dirStats = await fsPromises.stat(dirPath) if (dirStats.isDirectory()) { - const dirMode = dirStats.mode & 0o777; + const dirMode = dirStats.mode & 0o777 if (dirMode !== 0o700) { throw new Error( `[${serverName}] Insecure socket directory permissions: ${dirMode.toString( 8, )} (expected 0700). Directory may have been tampered with.`, - ); + ) } - const currentUid = process.getuid?.(); + const currentUid = process.getuid?.() if (currentUid !== undefined && dirStats.uid !== currentUid) { throw new Error( `Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` + `Potential security risk.`, - ); + ) } } } catch (dirError) { - if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") { - throw dirError; + if ((dirError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw dirError } // Directory doesn't exist yet - native host will create it } } - const stats = await fsPromises.stat(socketPath); + const stats = await fsPromises.stat(socketPath) if (!stats.isSocket()) { throw new Error( `[${serverName}] Path exists but it's not a socket: ${socketPath}`, - ); + ) } - const mode = stats.mode & 0o777; + const mode = stats.mode & 0o777 if (mode !== 0o600) { throw new Error( `[${serverName}] Insecure socket permissions: ${mode.toString( 8, )} (expected 0600). Socket may have been tampered with.`, - ); + ) } - const currentUid = process.getuid?.(); + const currentUid = process.getuid?.() if (currentUid !== undefined && stats.uid !== currentUid) { throw new Error( `Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` + `Potential security risk.`, - ); + ) } - logger.info(`[${serverName}] Socket security validation passed`); + logger.info(`[${serverName}] Socket security validation passed`) } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { logger.info( `[${serverName}] Socket not found, will be created by server`, - ); - return; + ) + return } - throw error; + throw error } } } @@ -487,7 +484,7 @@ class McpSocketClient { export function createMcpSocketClient( context: ClaudeForChromeContext, ): McpSocketClient { - return new McpSocketClient(context); + return new McpSocketClient(context) } -export type { McpSocketClient }; +export type { McpSocketClient } diff --git a/packages/@ant/ink/src/core/ink.tsx b/packages/@ant/ink/src/core/ink.tsx index f3fdb8e6f..c8ccad037 100644 --- a/packages/@ant/ink/src/core/ink.tsx +++ b/packages/@ant/ink/src/core/ink.tsx @@ -1,37 +1,28 @@ -import autoBind from 'auto-bind' -import { - closeSync, - constants as fsConstants, - openSync, - readSync, - writeSync, -} from 'fs' -import noop from 'lodash-es/noop.js' -import throttle from 'lodash-es/throttle.js' -import React, { type ReactNode } from 'react' -import type { FiberRoot } from 'react-reconciler' -import { ConcurrentRoot } from 'react-reconciler/constants.js' -import { onExit } from 'signal-exit' -import { getYogaCounters } from './yoga-layout/index.js' -import { format } from 'util' -import { colorize } from './colorize.js' -import App from '../components/App.js' -import type { - CursorDeclaration, - CursorDeclarationSetter, -} from '../components/CursorDeclarationContext.js' -import { FRAME_INTERVAL_MS } from './constants.js' -import * as dom from './dom.js' -import { KeyboardEvent } from './events/keyboard-event.js' -import { FocusManager } from './focus.js' -import { emptyFrame, type Frame, type FrameEvent } from './frame.js' -import { dispatchClick, dispatchHover } from './hit-test.js' -import instances from './instances.js' -import { LogUpdate } from './log-update.js' -import { nodeCache } from './node-cache.js' -import { optimize } from './optimizer.js' -import Output from './output.js' -import type { ParsedKey } from './parse-keypress.js' +import autoBind from 'auto-bind'; +import { closeSync, constants as fsConstants, openSync, readSync, writeSync } from 'fs'; +import noop from 'lodash-es/noop.js'; +import throttle from 'lodash-es/throttle.js'; +import React, { type ReactNode } from 'react'; +import type { FiberRoot } from 'react-reconciler'; +import { ConcurrentRoot } from 'react-reconciler/constants.js'; +import { onExit } from 'signal-exit'; +import { getYogaCounters } from './yoga-layout/index.js'; +import { format } from 'util'; +import { colorize } from './colorize.js'; +import App from '../components/App.js'; +import type { CursorDeclaration, CursorDeclarationSetter } from '../components/CursorDeclarationContext.js'; +import { FRAME_INTERVAL_MS } from './constants.js'; +import * as dom from './dom.js'; +import { KeyboardEvent } from './events/keyboard-event.js'; +import { FocusManager } from './focus.js'; +import { emptyFrame, type Frame, type FrameEvent } from './frame.js'; +import { dispatchClick, dispatchHover } from './hit-test.js'; +import instances from './instances.js'; +import { LogUpdate } from './log-update.js'; +import { nodeCache } from './node-cache.js'; +import { optimize } from './optimizer.js'; +import Output from './output.js'; +import type { ParsedKey } from './parse-keypress.js'; import reconciler, { dispatcher, getLastCommitMs, @@ -39,17 +30,10 @@ import reconciler, { isDebugRepaintsEnabled, recordYogaMs, resetProfileCounters, -} from './reconciler.js' -import renderNodeToOutput, { - consumeFollowScroll, - didLayoutShift, -} from './render-node-to-output.js' -import { - applyPositionedHighlight, - type MatchPosition, - scanPositions, -} from './render-to-screen.js' -import createRenderer, { type Renderer } from './renderer.js' +} from './reconciler.js'; +import renderNodeToOutput, { consumeFollowScroll, didLayoutShift } from './render-node-to-output.js'; +import { applyPositionedHighlight, type MatchPosition, scanPositions } from './render-to-screen.js'; +import createRenderer, { type Renderer } from './renderer.js'; import { CellWidth, CharPool, @@ -59,8 +43,8 @@ import { isEmptyCellAt, migrateScreenPools, StylePool, -} from './screen.js' -import { applySearchHighlight } from './searchHighlight.js' +} from './screen.js'; +import { applySearchHighlight } from './searchHighlight.js'; import { applySelectionOverlay, captureScrolledRows, @@ -80,13 +64,8 @@ import { shiftSelectionForFollow, startSelection, updateSelection, -} from './selection.js' -import { - SYNC_OUTPUT_SUPPORTED, - supportsExtendedKeys, - type Terminal, - writeDiffToTerminal, -} from './terminal.js' +} from './selection.js'; +import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js'; import { CURSOR_HOME, cursorMove, @@ -96,7 +75,7 @@ import { ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, ERASE_SCREEN, -} from './termio/csi.js' +} from './termio/csi.js'; import { DBP, DFE, @@ -105,28 +84,28 @@ import { ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, SHOW_CURSOR, -} from './termio/dec.js' +} from './termio/dec.js'; import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, setClipboard, supportsTabStatus, wrapForMultiplexer, -} from './termio/osc.js' -import { TerminalWriteProvider } from '../hooks/useTerminalNotification.js' +} from './termio/osc.js'; +import { TerminalWriteProvider } from '../hooks/useTerminalNotification.js'; // Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0, // which is always false in alt-screen (TTY + content fills screen). // Reusing a frozen object saves 1 allocation per frame. -const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ x: 0, y: 0, visible: false }) +const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ x: 0, y: 0, visible: false }); const CURSOR_HOME_PATCH = Object.freeze({ type: 'stdout' as const, content: CURSOR_HOME, -}) +}); const ERASE_THEN_HOME_PATCH = Object.freeze({ type: 'stdout' as const, content: ERASE_SCREEN + CURSOR_HOME, -}) +}); // Cached per-Ink-instance, invalidated on resize. frame.cursor.y for // alt-screen is always terminalRows - 1 (renderer.ts). @@ -134,74 +113,74 @@ function makeAltScreenParkPatch(terminalRows: number) { return Object.freeze({ type: 'stdout' as const, content: cursorPosition(terminalRows, 1), - }) + }); } export type Logger = { - debug(message: string, options?: { level?: string }): void - error(error: Error | unknown): void -} + debug(message: string, options?: { level?: string }): void; + error(error: Error | unknown): void; +}; export type Options = { - stdout: NodeJS.WriteStream - stdin: NodeJS.ReadStream - stderr: NodeJS.WriteStream - exitOnCtrlC: boolean - patchConsole: boolean - waitUntilExit?: () => Promise - onFrame?: (event: FrameEvent) => void + stdout: NodeJS.WriteStream; + stdin: NodeJS.ReadStream; + stderr: NodeJS.WriteStream; + exitOnCtrlC: boolean; + patchConsole: boolean; + waitUntilExit?: () => Promise; + onFrame?: (event: FrameEvent) => void; /** Called before each render cycle. Replaces flushInteractionTime(). */ - onBeforeRender?: () => void + onBeforeRender?: () => void; /** Injected logger. Replaces logForDebugging / logError imports. */ - logger?: Logger -} + logger?: Logger; +}; /** No-op logger used when no logger is injected. */ const noopLogger: Logger = { debug() {}, error() {}, -} +}; export default class Ink { - private readonly log: LogUpdate - private readonly terminal: Terminal - private scheduleRender: (() => void) & { cancel?: () => void } + private readonly log: LogUpdate; + private readonly terminal: Terminal; + private scheduleRender: (() => void) & { cancel?: () => void }; // Ignore last render after unmounting a tree to prevent empty output before exit - private isUnmounted = false - private isPaused = false - private readonly container: FiberRoot - private rootNode: dom.DOMElement - readonly focusManager: FocusManager - private renderer: Renderer - private readonly stylePool: StylePool - private charPool: CharPool - private hyperlinkPool: HyperlinkPool - private exitPromise?: Promise - private restoreConsole?: () => void - private restoreStderr?: () => void - private readonly unsubscribeTTYHandlers?: () => void - private terminalColumns: number - private terminalRows: number - private currentNode: ReactNode = null - private frontFrame: Frame - private backFrame: Frame - private lastPoolResetTime = performance.now() - private drainTimer: ReturnType | null = null + private isUnmounted = false; + private isPaused = false; + private readonly container: FiberRoot; + private rootNode: dom.DOMElement; + readonly focusManager: FocusManager; + private renderer: Renderer; + private readonly stylePool: StylePool; + private charPool: CharPool; + private hyperlinkPool: HyperlinkPool; + private exitPromise?: Promise; + private restoreConsole?: () => void; + private restoreStderr?: () => void; + private readonly unsubscribeTTYHandlers?: () => void; + private terminalColumns: number; + private terminalRows: number; + private currentNode: ReactNode = null; + private frontFrame: Frame; + private backFrame: Frame; + private lastPoolResetTime = performance.now(); + private drainTimer: ReturnType | null = null; private lastYogaCounters: { - ms: number - visited: number - measured: number - cacheHits: number - live: number - } = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0 } - private altScreenParkPatch: Readonly<{ type: 'stdout'; content: string }> + ms: number; + visited: number; + measured: number; + cacheHits: number; + live: number; + } = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0 }; + private altScreenParkPatch: Readonly<{ type: 'stdout'; content: string }>; // Text selection state (alt-screen only). Owned here so the overlay // pass in onRender can read it and App.tsx can update it from mouse // events. Public so instances.get() callers can access. - readonly selection: SelectionState = createSelectionState() + readonly selection: SelectionState = createSelectionState(); // Search highlight query (alt-screen only). Setter below triggers // scheduleRender; applySearchHighlight in onRender inverts matching cells. - private searchHighlightQuery = '' + private searchHighlightQuery = ''; // Position-based highlight. VML scans positions ONCE (via // scanElementSubtree, when the target message is mounted), stores them // message-relative, sets this for every-frame apply. rowOffset = @@ -209,90 +188,90 @@ export default class Ink { // "current" (yellow). null clears. Positions are known upfront — // navigation is index arithmetic, no scan-feedback loop. private searchPositions: { - positions: MatchPosition[] - rowOffset: number - currentIdx: number - } | null = null + positions: MatchPosition[]; + rowOffset: number; + currentIdx: number; + } | null = null; // React-land subscribers for selection state changes (useHasSelection). // Fired alongside the terminal repaint whenever the selection mutates // so UI (e.g. footer hints) can react to selection appearing/clearing. - private readonly selectionListeners = new Set<() => void>() + private readonly selectionListeners = new Set<() => void>(); // DOM nodes currently under the pointer (mode-1003 motion). Held here // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. - private readonly hoveredNodes = new Set() + private readonly hoveredNodes = new Set(); // Set by via setAltScreenActive(). Controls the // renderer's cursor.y clamping (keeps cursor in-viewport to avoid // LF-induced scroll when screen.height === terminalRows) and gates // alt-screen-aware SIGCONT/resize/unmount handling. - private altScreenActive = false + private altScreenActive = false; // Set alongside altScreenActive so SIGCONT resume knows whether to // re-enable mouse tracking (not all uses want it). - private altScreenMouseTracking = false + private altScreenMouseTracking = false; // True when the previous frame's screen buffer cannot be trusted for // blit — selection overlay mutated it, resetFramesForAltScreen() // replaced it with blanks, or forceRedraw() reset it to 0×0. Forces // one full-render frame; steady-state frames after clear it and regain // the blit + narrow-damage fast path. - private prevFrameContaminated = false + private prevFrameContaminated = false; // Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches // INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN // synchronously in handleResize would leave the screen blank for the ~80ms // render() takes; deferring into the atomic block means old content stays // visible until the new frame is fully ready. - private needsEraseBeforePaint = false + private needsEraseBeforePaint = false; // Native cursor positioning: a component (via useDeclaredCursor) declares // where the terminal cursor should be parked after each frame. Terminal // emulators render IME preedit text at the physical cursor position, and // screen readers / screen magnifiers track it — so parking at the text // input's caret makes CJK input appear inline and lets a11y tools follow. - private cursorDeclaration: CursorDeclaration | null = null + private cursorDeclaration: CursorDeclaration | null = null; // Main-screen: physical cursor position after the declared-cursor move, // tracked separately from frame.cursor (which must stay at content-bottom // for log-update's relative-move invariants). Alt-screen doesn't need // this — every frame begins with CSI H. null = no move emitted last frame. - private displayCursor: { x: number; y: number } | null = null - private readonly logger: Logger + private displayCursor: { x: number; y: number } | null = null; + private readonly logger: Logger; constructor(private readonly options: Options) { - autoBind(this) - this.logger = options.logger ?? noopLogger + autoBind(this); + this.logger = options.logger ?? noopLogger; if (this.options.patchConsole) { - this.restoreConsole = this.patchConsole() - this.restoreStderr = this.patchStderr() + this.restoreConsole = this.patchConsole(); + this.restoreStderr = this.patchStderr(); } this.terminal = { stdout: options.stdout, stderr: options.stderr, - } + }; - this.terminalColumns = options.stdout.columns || 80 - this.terminalRows = options.stdout.rows || 24 - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) - this.stylePool = new StylePool() - this.charPool = new CharPool() - this.hyperlinkPool = new HyperlinkPool() + this.terminalColumns = options.stdout.columns || 80; + this.terminalRows = options.stdout.rows || 24; + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows); + this.stylePool = new StylePool(); + this.charPool = new CharPool(); + this.hyperlinkPool = new HyperlinkPool(); this.frontFrame = emptyFrame( this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.backFrame = emptyFrame( this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.log = new LogUpdate({ isTTY: (options.stdout.isTTY as boolean | undefined) || false, stylePool: this.stylePool, - }) + }); // scheduleRender is called from the reconciler's resetAfterCommit, which // runs BEFORE React's layout phase (ref attach + useLayoutEffect). Any @@ -303,56 +282,54 @@ export default class Ink { // a one-keystroke lag. Same event-loop tick, so throughput is unchanged. // Test env uses onImmediateRender (direct onRender, no throttle) so // existing synchronous lastFrame() tests are unaffected. - const deferredRender = (): void => queueMicrotask(this.onRender) + const deferredRender = (): void => queueMicrotask(this.onRender); this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, { leading: true, trailing: true, - }) + }); // Ignore last render after unmounting a tree to prevent empty output before exit - this.isUnmounted = false + this.isUnmounted = false; // Unmount when process exits - this.unsubscribeExit = onExit(this.unmount, { alwaysLast: false }) + this.unsubscribeExit = onExit(this.unmount, { alwaysLast: false }); if (options.stdout.isTTY) { - options.stdout.on('resize', this.handleResize) - process.on('SIGCONT', this.handleResume) + options.stdout.on('resize', this.handleResize); + process.on('SIGCONT', this.handleResume); this.unsubscribeTTYHandlers = () => { - options.stdout.off('resize', this.handleResize) - process.off('SIGCONT', this.handleResume) - } + options.stdout.off('resize', this.handleResize); + process.off('SIGCONT', this.handleResume); + }; } - this.rootNode = dom.createNode('ink-root') - this.focusManager = new FocusManager((target, event) => - dispatcher.dispatchDiscrete(target, event), - ) - this.rootNode.focusManager = this.focusManager - this.renderer = createRenderer(this.rootNode, this.stylePool) - this.rootNode.onRender = this.scheduleRender - this.rootNode.onImmediateRender = this.onRender + this.rootNode = dom.createNode('ink-root'); + this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event)); + this.rootNode.focusManager = this.focusManager; + this.renderer = createRenderer(this.rootNode, this.stylePool); + this.rootNode.onRender = this.scheduleRender; + this.rootNode.onImmediateRender = this.onRender; this.rootNode.onComputeLayout = () => { // Calculate layout during React's commit phase so useLayoutEffect hooks // have access to fresh layout data // Guard against accessing freed Yoga nodes after unmount if (this.isUnmounted) { - return + return; } if (this.rootNode.yogaNode) { - const t0 = performance.now() - this.rootNode.yogaNode.setWidth(this.terminalColumns) - this.rootNode.yogaNode.calculateLayout(this.terminalColumns) - const ms = performance.now() - t0 - recordYogaMs(ms) - const c = getYogaCounters() - this.lastYogaCounters = { ms, ...c } + const t0 = performance.now(); + this.rootNode.yogaNode.setWidth(this.terminalColumns); + this.rootNode.yogaNode.calculateLayout(this.terminalColumns); + const ms = performance.now() - t0; + recordYogaMs(ms); + const c = getYogaCounters(); + this.lastYogaCounters = { ms, ...c }; } - } + }; - // @ts-ignore createContainer arg count varies across react-reconciler versions + // @ts-expect-error createContainer arg count varies across react-reconciler versions this.container = reconciler.createContainer( this.rootNode, ConcurrentRoot, @@ -364,31 +341,31 @@ export default class Ink { noop, // onCaughtError noop, // onRecoverableError noop, // onDefaultTransitionIndicator - ) + ); - // @ts-ignore MACRO-replaced comparison — always false in production builds - if ("production" === 'development') { + // @ts-expect-error MACRO-replaced comparison — always false in production builds + if ('production' === 'development') { reconciler.injectIntoDevTools({ bundleType: 0, // Reporting React DOM's version, not Ink's // See https://github.com/facebook/react/issues/16666#issuecomment-532639905 version: '16.13.1', rendererPackageName: 'ink', - }) + }); } } private handleResume = () => { if (!this.options.stdout.isTTY) { - return + return; } // Alt screen: after SIGCONT, content is stale (shell may have written // to main screen, switching focus away) and mouse tracking was // disabled by handleSuspend. if (this.altScreenActive) { - this.reenterAltScreen() - return + this.reenterAltScreen(); + return; } // Main screen: start fresh to prevent clobbering terminal content @@ -398,20 +375,20 @@ export default class Ink { this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.backFrame = emptyFrame( this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool, - ) - this.log.reset() + ); + this.log.reset(); // Physical cursor position is unknown after the shell took over during // suspend. Clear displayCursor so the next frame's cursor preamble // doesn't emit a relative move from a stale park position. - this.displayCursor = null - } + this.displayCursor = null; + }; // NOT debounced. A debounce opens a window where stdout.columns is NEW // but this.terminalColumns/Yoga are OLD — any scheduleRender during that @@ -420,15 +397,15 @@ export default class Ink { // blank→paint flicker). useVirtualScroll's height scaling already bounds // the per-resize cost; synchronous handling keeps dimensions consistent. private handleResize = () => { - const cols = this.options.stdout.columns || 80 - const rows = this.options.stdout.rows || 24 + const cols = this.options.stdout.columns || 80; + const rows = this.options.stdout.rows || 24; // Terminals often emit 2+ resize events for one user action (window // settling). Same-dimension events are no-ops; skip to avoid redundant // frame resets and renders. - if (cols === this.terminalColumns && rows === this.terminalRows) return - this.terminalColumns = cols - this.terminalRows = rows - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + if (cols === this.terminalColumns && rows === this.terminalRows) return; + this.terminalColumns = cols; + this.terminalRows = rows; + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows); // Alt screen: reset frame buffers so the next render repaints from // scratch (prevFrameContaminated → every cell written, wrapped in @@ -442,10 +419,10 @@ export default class Ink { // can take ~80ms; erasing first leaves the screen blank that whole time. if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) + this.options.stdout.write(ENABLE_MOUSE_TRACKING); } - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true + this.resetFramesForAltScreen(); + this.needsEraseBeforePaint = true; } // Re-render the React tree with updated props so the context value changes. @@ -454,13 +431,13 @@ export default class Ink { // We don't call scheduleRender() here because that would render before the // layout is updated, causing a mismatch between viewport and content dimensions. if (this.currentNode !== null) { - this.render(this.currentNode) + this.render(this.currentNode); } - } + }; - resolveExitPromise: () => void = () => {} - rejectExitPromise: (reason?: Error) => void = () => {} - unsubscribeExit: () => void = () => {} + resolveExitPromise: () => void = () => {}; + rejectExitPromise: (reason?: Error) => void = () => {}; + unsubscribeExit: () => void = () => {}; /** * Pause Ink and hand the terminal over to an external TUI (e.g. git @@ -469,8 +446,8 @@ export default class Ink { * Call `exitAlternateScreen()` when done to restore Ink. */ enterAlternateScreen(): void { - this.pause() - this.suspendStdin() + this.pause(); + this.suspendStdin(); this.options.stdout.write( // Disable extended key reporting first — editors that don't speak // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if @@ -484,7 +461,7 @@ export default class Ink { '\x1b[?25h' + // show cursor '\x1b[2J' + // clear screen '\x1b[H', // cursor home - ) + ); } /** @@ -507,14 +484,14 @@ export default class Ink { (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE) (this.altScreenActive ? '' : '\x1b[?1049l') + // exit alt (non-fullscreen only) '\x1b[?25l', // hide cursor (Ink manages) - ) - this.resumeStdin() + ); + this.resumeStdin(); if (this.altScreenActive) { - this.resetFramesForAltScreen() + this.resetFramesForAltScreen(); } else { - this.repaint() + this.repaint(); } - this.resume() + this.resume(); // Re-enable focus reporting and extended key reporting — terminal // editors (vim, nano, etc.) write their own modifyOtherKeys level on // entry and reset it on exit, leaving us unable to distinguish @@ -523,35 +500,31 @@ export default class Ink { // without the pop we'd accumulate depth on each editor round-trip). this.options.stdout.write( '\x1b[?1004h' + - (supportsExtendedKeys() - ? DISABLE_KITTY_KEYBOARD + - ENABLE_KITTY_KEYBOARD + - ENABLE_MODIFY_OTHER_KEYS - : ''), - ) + (supportsExtendedKeys() ? DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS : ''), + ); } onRender() { if (this.isUnmounted || this.isPaused) { - return + return; } // Entering a render cancels any pending drain tick — this render will // handle the drain (and re-schedule below if needed). Prevents a // wheel-event-triggered render AND a drain-timer render both firing. if (this.drainTimer !== null) { - clearTimeout(this.drainTimer) - this.drainTimer = null + clearTimeout(this.drainTimer); + this.drainTimer = null; } // Flush deferred interaction-time update before rendering so we call // Date.now() at most once per frame instead of once per keypress. // Done before the render to avoid dirtying state that would trigger // an extra React re-render cycle. - this.options.onBeforeRender?.() + this.options.onBeforeRender?.(); - const renderStart = performance.now() - const terminalWidth = this.options.stdout.columns || 80 - const terminalRows = this.options.stdout.rows || 24 + const renderStart = performance.now(); + const terminalWidth = this.options.stdout.columns || 80; + const terminalRows = this.options.stdout.rows || 24; const frame = this.renderer({ frontFrame: this.frontFrame, @@ -561,8 +534,8 @@ export default class Ink { terminalRows, altScreen: this.altScreenActive, prevFrameContaminated: this.prevFrameContaminated, - }) - const rendererMs = performance.now() - renderStart + }); + const rendererMs = performance.now() - renderStart; // Sticky/auto-follow scrolled the ScrollBox this frame. Translate the // selection by the same delta so the highlight stays anchored to the @@ -575,7 +548,7 @@ export default class Ink { // (screen-local) so only anchor shifts — selection grows toward the // mouse as the anchor walks up. After release, both ends are text- // anchored and move as a block. - const follow = consumeFollowScroll() + const follow = consumeFollowScroll(); if ( follow && this.selection.anchor && @@ -588,7 +561,7 @@ export default class Ink { this.selection.anchor.row >= follow.viewportTop && this.selection.anchor.row <= follow.viewportBottom ) { - const { delta, viewportTop, viewportBottom } = follow + const { delta, viewportTop, viewportBottom } = follow; // captureScrolledRows and shift* are a pair: capture grabs rows about // to scroll off, shift moves the selection endpoint so the same rows // won't intersect again next frame. Capturing without shifting leaves @@ -598,15 +571,9 @@ export default class Ink { // each shift branch so the pairing can't be broken by a new guard. if (this.selection.isDragging) { if (hasSelection(this.selection)) { - captureScrolledRows( - this.selection, - this.frontFrame.screen, - viewportTop, - viewportTop + delta - 1, - 'above', - ) + captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above'); } - shiftAnchor(this.selection, -delta, viewportTop, viewportBottom) + shiftAnchor(this.selection, -delta, viewportTop, viewportBottom); } else if ( // Flag-3 guard: the anchor check above only proves ONE endpoint is // on scrollbox content. A drag from row 3 (scrollbox) into the @@ -621,30 +588,18 @@ export default class Ink { // shiftAnchor ignores focus, and the anchor DOES shift (so capture // is correct there even when focus is in the footer). !this.selection.focus || - (this.selection.focus.row >= viewportTop && - this.selection.focus.row <= viewportBottom) + (this.selection.focus.row >= viewportTop && this.selection.focus.row <= viewportBottom) ) { if (hasSelection(this.selection)) { - captureScrolledRows( - this.selection, - this.frontFrame.screen, - viewportTop, - viewportTop + delta - 1, - 'above', - ) + captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above'); } - const cleared = shiftSelectionForFollow( - this.selection, - -delta, - viewportTop, - viewportBottom, - ) + const cleared = shiftSelectionForFollow(this.selection, -delta, viewportTop, viewportBottom); // Auto-clear (both ends overshot minRow) must notify React-land // so useHasSelection re-renders and the footer copy/escape hint // disappears. notifySelectionChange() would recurse into onRender; // fire the listeners directly — they schedule a React update for // LATER, they don't re-enter this frame. - if (cleared) for (const cb of this.selectionListeners) cb() + if (cleared) for (const cb of this.selectionListeners) cb(); } } @@ -667,33 +622,29 @@ export default class Ink { // which doesn't track damage, and prev-frame overlay cells need to be // compared when selection moves/clears. prevFrameContaminated covers // the frame-after-selection-clears case. - let selActive = false - let hlActive = false + let selActive = false; + let hlActive = false; if (this.altScreenActive) { - selActive = hasSelection(this.selection) + selActive = hasSelection(this.selection); if (selActive) { - applySelectionOverlay(frame.screen, this.selection, this.stylePool) + applySelectionOverlay(frame.screen, this.selection, this.stylePool); } // Scan-highlight: inverse on ALL visible matches (less/vim style). // Position-highlight (below) overlays CURRENT (yellow) on top. - hlActive = applySearchHighlight( - frame.screen, - this.searchHighlightQuery, - this.stylePool, - ) + hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool); // Position-based CURRENT: write yellow at positions[currentIdx] + // rowOffset. No scanning — positions came from a prior scan when // the message first mounted. Message-relative + rowOffset = screen. if (this.searchPositions) { - const sp = this.searchPositions + const sp = this.searchPositions; const posApplied = applyPositionedHighlight( frame.screen, this.stylePool, sp.positions, sp.rowOffset, sp.currentIdx, - ) - hlActive = hlActive || posApplied + ); + hlActive = hlActive || posApplied; } } @@ -702,18 +653,13 @@ export default class Ink { // cells at sibling boundaries that per-node damage tracking misses. // Selection/highlight overlays write via setCellStyleId which doesn't // track damage. prevFrameContaminated covers the cleanup frame. - if ( - didLayoutShift() || - selActive || - hlActive || - this.prevFrameContaminated - ) { + if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) { frame.screen.damage = { x: 0, y: 0, width: frame.screen.width, height: frame.screen.height, - } + }; } // Alt-screen: anchor the physical cursor to (0,0) before every diff. @@ -726,12 +672,12 @@ export default class Ink { // can't do this — cursor.y tracks scrollback rows CSI H can't reach. // The CSI H write is deferred until after the diff is computed so we // can skip it for empty diffs (no writes → physical cursor unused). - let prevFrame = this.frontFrame + let prevFrame = this.frontFrame; if (this.altScreenActive) { - prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR } + prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR }; } - const tDiff = performance.now() + const tDiff = performance.now(); const diff = this.log.render( prevFrame, frame, @@ -741,48 +687,45 @@ export default class Ink { // tmux is the main case (re-emits DECSTBM with its own timing and // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). SYNC_OUTPUT_SUPPORTED, - ) - const diffMs = performance.now() - tDiff + ); + const diffMs = performance.now() - tDiff; // Swap buffers - this.backFrame = this.frontFrame - this.frontFrame = frame + this.backFrame = this.frontFrame; + this.frontFrame = frame; // Periodically reset char/hyperlink pools to prevent unbounded growth // during long sessions. 5 minutes is infrequent enough that the O(cells) // migration cost is negligible. Reuses renderStart to avoid extra clock call. if (renderStart - this.lastPoolResetTime > 5 * 60 * 1000) { - this.resetPools() - this.lastPoolResetTime = renderStart + this.resetPools(); + this.lastPoolResetTime = renderStart; } - const flickers: FrameEvent['flickers'] = [] + const flickers: FrameEvent['flickers'] = []; for (const patch of diff) { if (patch.type === 'clearTerminal') { flickers.push({ desiredHeight: frame.screen.height, availableHeight: frame.viewport.height, reason: patch.reason, - }) + }); if (isDebugRepaintsEnabled() && patch.debug) { - const chain = dom.findOwnerChainAtRow( - this.rootNode, - patch.debug.triggerY, - ) + const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY); this.logger.debug( `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + ` prev: "${patch.debug.prevLine}"\n` + ` next: "${patch.debug.nextLine}"\n` + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, { level: 'warn' }, - ) + ); } } } - const tOptimize = performance.now() - const optimized = optimize(diff) - const optimizeMs = performance.now() - tOptimize - const hasDiff = optimized.length > 0 + const tOptimize = performance.now(); + const optimized = optimize(diff); + const optimizeMs = performance.now() - tOptimize; + const hasDiff = optimized.length > 0; if (this.altScreenActive && hasDiff) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing @@ -804,12 +747,12 @@ export default class Ink { // synchronously in handleResize would blank the screen for the ~80ms // render() takes. if (this.needsEraseBeforePaint) { - this.needsEraseBeforePaint = false - optimized.unshift(ERASE_THEN_HOME_PATCH) + this.needsEraseBeforePaint = false; + optimized.unshift(ERASE_THEN_HOME_PATCH); } else { - optimized.unshift(CURSOR_HOME_PATCH) + optimized.unshift(CURSOR_HOME_PATCH); } - optimized.push(this.altScreenParkPatch) + optimized.push(this.altScreenParkPatch); } // Native cursor positioning: park the terminal cursor at the declared @@ -819,29 +762,25 @@ export default class Ink { // translation) — if the declared node didn't render (stale declaration // after remount, or scrolled out of view), it won't be in the cache // and no move is emitted. - const decl = this.cursorDeclaration - const rect = decl !== null ? nodeCache.get(decl.node) : undefined + const decl = this.cursorDeclaration; + const rect = decl !== null ? nodeCache.get(decl.node) : undefined; const target = - decl !== null && rect !== undefined - ? { x: rect.x + decl.relativeX, y: rect.y + decl.relativeY } - : null - const parked = this.displayCursor + decl !== null && rect !== undefined ? { x: rect.x + decl.relativeX, y: rect.y + decl.relativeY } : null; + const parked = this.displayCursor; // Preserve the empty-diff zero-write fast path: skip all cursor writes // when nothing rendered AND the park target is unchanged. - const targetMoved = - target !== null && - (parked === null || parked.x !== target.x || parked.y !== target.y) + const targetMoved = target !== null && (parked === null || parked.x !== target.x || parked.y !== target.y); if (hasDiff || targetMoved || (target === null && parked !== null)) { // Main-screen preamble: log-update's relative moves assume the // physical cursor is at prevFrame.cursor. If last frame parked it // elsewhere, move back before the diff runs. Alt-screen's CSI H // already resets to (0,0) so no preamble needed. if (parked !== null && !this.altScreenActive && hasDiff) { - const pdx = prevFrame.cursor.x - parked.x - const pdy = prevFrame.cursor.y - parked.y + const pdx = prevFrame.cursor.x - parked.x; + const pdy = prevFrame.cursor.y - parked.y; if (pdx !== 0 || pdy !== 0) { - optimized.unshift({ type: 'stdout', content: cursorMove(pdx, pdy) }) + optimized.unshift({ type: 'stdout', content: cursorMove(pdx, pdy) }); } } @@ -849,24 +788,21 @@ export default class Ink { if (this.altScreenActive) { // Absolute CUP (1-indexed); next frame's CSI H resets regardless. // Emitted after altScreenParkPatch so the declared position wins. - const row = Math.min(Math.max(target.y + 1, 1), terminalRows) - const col = Math.min(Math.max(target.x + 1, 1), terminalWidth) - optimized.push({ type: 'stdout', content: cursorPosition(row, col) }) + const row = Math.min(Math.max(target.y + 1, 1), terminalRows); + const col = Math.min(Math.max(target.x + 1, 1), terminalWidth); + optimized.push({ type: 'stdout', content: cursorPosition(row, col) }); } else { // After the diff (or preamble), cursor is at frame.cursor. If no // diff AND previously parked, it's still at the old park position // (log-update wrote nothing). Otherwise it's at frame.cursor. - const from = - !hasDiff && parked !== null - ? parked - : { x: frame.cursor.x, y: frame.cursor.y } - const dx = target.x - from.x - const dy = target.y - from.y + const from = !hasDiff && parked !== null ? parked : { x: frame.cursor.x, y: frame.cursor.y }; + const dx = target.x - from.x; + const dy = target.y - from.y; if (dx !== 0 || dy !== 0) { - optimized.push({ type: 'stdout', content: cursorMove(dx, dy) }) + optimized.push({ type: 'stdout', content: cursorMove(dx, dy) }); } } - this.displayCursor = target + this.displayCursor = target; } else { // Declaration cleared (input blur, unmount). Restore physical cursor // to frame.cursor before forgetting the park position — otherwise @@ -876,29 +812,25 @@ export default class Ink { // !hasDiff (e.g. accessibility mode where blur doesn't change // renderedValue since invert is identity). if (parked !== null && !this.altScreenActive && !hasDiff) { - const rdx = frame.cursor.x - parked.x - const rdy = frame.cursor.y - parked.y + const rdx = frame.cursor.x - parked.x; + const rdy = frame.cursor.y - parked.y; if (rdx !== 0 || rdy !== 0) { - optimized.push({ type: 'stdout', content: cursorMove(rdx, rdy) }) + optimized.push({ type: 'stdout', content: cursorMove(rdx, rdy) }); } } - this.displayCursor = null + this.displayCursor = null; } } - const tWrite = performance.now() - writeDiffToTerminal( - this.terminal, - optimized, - this.altScreenActive && !SYNC_OUTPUT_SUPPORTED, - ) - const writeMs = performance.now() - tWrite + const tWrite = performance.now(); + writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED); + const writeMs = performance.now() - tWrite; // Update blit safety for the NEXT frame. The frame just rendered // becomes frontFrame (= next frame's prevScreen). If we applied the // selection overlay, that buffer has inverted cells. selActive/hlActive // are only ever true in alt-screen; in main-screen this is false→false. - this.prevFrameContaminated = selActive || hlActive + this.prevFrameContaminated = selActive || hlActive; // A ScrollBox has pendingScrollDelta left to drain — schedule the next // frame. MUST NOT call this.scheduleRender() here: we're inside a @@ -913,24 +845,21 @@ export default class Ink { // quarter interval (~250fps, setTimeout practical floor) for max scroll // speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle. if (frame.scrollDrainPending) { - this.drainTimer = setTimeout( - () => this.onRender(), - FRAME_INTERVAL_MS >> 2, - ) + this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2); } - const yogaMs = getLastYogaMs() - const commitMs = getLastCommitMs() - const yc = this.lastYogaCounters + const yogaMs = getLastYogaMs(); + const commitMs = getLastCommitMs(); + const yc = this.lastYogaCounters; // Reset so drain-only frames (no React commit) don't repeat stale values. - resetProfileCounters() + resetProfileCounters(); this.lastYogaCounters = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0, - } + }; this.options.onFrame?.({ durationMs: performance.now() - renderStart, phases: { @@ -947,21 +876,21 @@ export default class Ink { yogaLive: yc.live, }, flickers, - }) + }); } pause(): void { // Flush pending React updates and render before pausing. - // @ts-ignore flushSyncFromReconciler exists in react-reconciler but not in @types - reconciler.flushSyncFromReconciler() - this.onRender() + // @ts-expect-error flushSyncFromReconciler exists in react-reconciler but not in @types + reconciler.flushSyncFromReconciler(); + this.onRender(); - this.isPaused = true + this.isPaused = true; } resume(): void { - this.isPaused = false - this.onRender() + this.isPaused = false; + this.onRender(); } /** @@ -976,19 +905,19 @@ export default class Ink { this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.backFrame = emptyFrame( this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool, - ) - this.log.reset() + ); + this.log.reset(); // Physical cursor position is unknown after external terminal corruption. // Clear displayCursor so the cursor preamble doesn't emit a stale // relative move from where we last parked it. - this.displayCursor = null + this.displayCursor = null; } /** @@ -1000,18 +929,18 @@ export default class Ink { * unchanged cells don't need repainting. Scrollback is preserved. */ forceRedraw(): void { - if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return - this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME) + if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return; + this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME); if (this.altScreenActive) { - this.resetFramesForAltScreen() + this.resetFramesForAltScreen(); } else { - this.repaint() + this.repaint(); // repaint() resets frontFrame to 0×0. Without this flag the next // frame's blit optimization copies from that empty screen and the // diff sees no content. onRender resets the flag at frame end. - this.prevFrameContaminated = true + this.prevFrameContaminated = true; } - this.onRender() + this.onRender(); } /** @@ -1025,7 +954,7 @@ export default class Ink { * onRender resets the flag at frame end so it's one-shot. */ invalidatePrevFrame(): void { - this.prevFrameContaminated = true + this.prevFrameContaminated = true; } /** @@ -1036,18 +965,18 @@ export default class Ink { * a full redraw with no stale diff state. */ setAltScreenActive(active: boolean, mouseTracking = false): void { - if (this.altScreenActive === active) return - this.altScreenActive = active - this.altScreenMouseTracking = active && mouseTracking + if (this.altScreenActive === active) return; + this.altScreenActive = active; + this.altScreenMouseTracking = active && mouseTracking; if (active) { - this.resetFramesForAltScreen() + this.resetFramesForAltScreen(); } else { - this.repaint() + this.repaint(); } } get isAltScreenActive(): boolean { - return this.altScreenActive + return this.altScreenActive; } /** @@ -1072,33 +1001,29 @@ export default class Ink { * handleResize. */ reassertTerminalModes = (includeAltScreen = false): void => { - if (!this.options.stdout.isTTY) return + if (!this.options.stdout.isTTY) return; // Don't touch the terminal during an editor handoff — re-enabling kitty // keyboard here would undo enterAlternateScreen's disable and nano would // start seeing CSI-u sequences again. - if (this.isPaused) return + if (this.isPaused) return; // Extended keys — re-assert if enabled (App.tsx enables these on // allowlisted terminals at raw-mode entry; a terminal reset clears them). // Pop-before-push keeps Kitty stack depth at 1 instead of accumulating // on each call. if (supportsExtendedKeys()) { - this.options.stdout.write( - DISABLE_KITTY_KEYBOARD + - ENABLE_KITTY_KEYBOARD + - ENABLE_MODIFY_OTHER_KEYS, - ) + this.options.stdout.write(DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS); } - if (!this.altScreenActive) return + if (!this.altScreenActive) return; // Mouse tracking — idempotent, safe to re-assert on every stdin gap. if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) + this.options.stdout.write(ENABLE_MOUSE_TRACKING); } // Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that // have a strong signal the terminal actually dropped mode 1049. if (includeAltScreen) { - this.reenterAltScreen() + this.reenterAltScreen(); } - } + }; /** * Mark this instance as unmounted so future unmount() calls early-return. @@ -1112,28 +1037,28 @@ export default class Ink { * as restoring the saved cursor position — clobbering the resume hint. */ detachForShutdown(): void { - this.isUnmounted = true + this.isUnmounted = true; // Cancel any pending throttled render so it doesn't fire between // cleanupTerminalModes() and process.exit() and write to main screen. - this.scheduleRender.cancel?.() + this.scheduleRender.cancel?.(); // Restore stdin from raw mode. unmount() used to do this via React // unmount (App.componentWillUnmount → handleSetRawMode(false)) but we're // short-circuiting that path. Must use this.options.stdin — NOT // process.stdin — because getStdinOverride() may have opened /dev/tty // when stdin is piped. const stdin = this.options.stdin as NodeJS.ReadStream & { - isRaw?: boolean - setRawMode?: (m: boolean) => void - } - this.drainStdin() + isRaw?: boolean; + setRawMode?: (m: boolean) => void; + }; + this.drainStdin(); if (stdin.isTTY && stdin.isRaw && stdin.setRawMode) { - stdin.setRawMode(false) + stdin.setRawMode(false); } } /** @see drainStdin */ drainStdin(): void { - drainStdin(this.options.stdin) + drainStdin(this.options.stdin); } /** @@ -1145,12 +1070,9 @@ export default class Ink { */ private reenterAltScreen(): void { this.options.stdout.write( - ENTER_ALT_SCREEN + - ERASE_SCREEN + - CURSOR_HOME + - (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''), - ) - this.resetFramesForAltScreen() + ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''), + ); + this.resetFramesForAltScreen(); } /** @@ -1169,29 +1091,23 @@ export default class Ink { * matches the physical cursor after ENTER_ALT_SCREEN + CSI H (home). */ private resetFramesForAltScreen(): void { - const rows = this.terminalRows - const cols = this.terminalColumns + const rows = this.terminalRows; + const cols = this.terminalColumns; const blank = (): Frame => ({ - screen: createScreen( - cols, - rows, - this.stylePool, - this.charPool, - this.hyperlinkPool, - ), + screen: createScreen(cols, rows, this.stylePool, this.charPool, this.hyperlinkPool), viewport: { width: cols, height: rows + 1 }, cursor: { x: 0, y: 0, visible: true }, - }) - this.frontFrame = blank() - this.backFrame = blank() - this.log.reset() + }); + this.frontFrame = blank(); + this.backFrame = blank(); + this.log.reset(); // Defense-in-depth: alt-screen skips the cursor preamble anyway (CSI H // resets), but a stale displayCursor would be misleading if we later // exit to main-screen without an intervening render. - this.displayCursor = null + this.displayCursor = null; // Fresh frontFrame is blank rows×cols — blitting from it would copy // blanks over content. Next alt-screen frame must full-render. - this.prevFrameContaminated = true + this.prevFrameContaminated = true; } /** @@ -1200,16 +1116,16 @@ export default class Ink { * region stays visible after the automatic copy. */ copySelectionNoClear(): string { - if (!hasSelection(this.selection)) return '' - const text = getSelectedText(this.selection, this.frontFrame.screen) + if (!hasSelection(this.selection)) return ''; + const text = getSelectedText(this.selection, this.frontFrame.screen); if (text) { // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux // drops it silently unless allow-passthrough is on — no regression). void setClipboard(text).then(raw => { - if (raw) this.options.stdout.write(raw) - }) + if (raw) this.options.stdout.write(raw); + }); } - return text + return text; } /** @@ -1217,18 +1133,18 @@ export default class Ink { * and clear the selection. Returns the copied text (empty if no selection). */ copySelection(): string { - if (!hasSelection(this.selection)) return '' - const text = this.copySelectionNoClear() - clearSelection(this.selection) - this.notifySelectionChange() - return text + if (!hasSelection(this.selection)) return ''; + const text = this.copySelectionNoClear(); + clearSelection(this.selection); + this.notifySelectionChange(); + return text; } /** Clear the current text selection without copying. */ clearTextSelection(): void { - if (!hasSelection(this.selection)) return - clearSelection(this.selection) - this.notifySelectionChange() + if (!hasSelection(this.selection)) return; + clearSelection(this.selection); + this.notifySelectionChange(); } /** @@ -1239,9 +1155,9 @@ export default class Ink { * damage, so the overlay forces full-frame damage while active. */ setSearchHighlight(query: string): void { - if (this.searchHighlightQuery === query) return - this.searchHighlightQuery = query - this.scheduleRender() + if (this.searchHighlightQuery === query) return; + this.searchHighlightQuery = query; + this.scheduleRender(); } /** Paint an EXISTING DOM subtree to a fresh Screen at its natural @@ -1255,39 +1171,33 @@ export default class Ink { * * ~1-2ms (paint only, no reconcile — the DOM is already built). */ scanElementSubtree(el: dom.DOMElement): MatchPosition[] { - if (!this.searchHighlightQuery || !el.yogaNode) return [] - const width = Math.ceil(el.yogaNode.getComputedWidth()) - const height = Math.ceil(el.yogaNode.getComputedHeight()) - if (width <= 0 || height <= 0) return [] + if (!this.searchHighlightQuery || !el.yogaNode) return []; + const width = Math.ceil(el.yogaNode.getComputedWidth()); + const height = Math.ceil(el.yogaNode.getComputedHeight()); + if (width <= 0 || height <= 0) return []; // renderNodeToOutput adds el's OWN computedLeft/Top to offsetX/Y. // Passing -elLeft/-elTop nets to 0 → paints at (0,0) in our buffer. - const elLeft = el.yogaNode.getComputedLeft() - const elTop = el.yogaNode.getComputedTop() - const screen = createScreen( - width, - height, - this.stylePool, - this.charPool, - this.hyperlinkPool, - ) + const elLeft = el.yogaNode.getComputedLeft(); + const elTop = el.yogaNode.getComputedTop(); + const screen = createScreen(width, height, this.stylePool, this.charPool, this.hyperlinkPool); const output = new Output({ width, height, stylePool: this.stylePool, screen, - }) + }); renderNodeToOutput(el, output, { offsetX: -elLeft, offsetY: -elTop, prevScreen: undefined, - }) - const rendered = output.get() + }); + const rendered = output.get(); // renderNodeToOutput wrote our offset positions to nodeCache — // corrupts the main render (it'd blit from wrong coords). Mark the // subtree dirty so the next main render repaints + re-caches // correctly. One extra paint of this message, but correct > fast. - dom.markDirty(el) - const positions = scanPositions(rendered, this.searchHighlightQuery) + dom.markDirty(el); + const positions = scanPositions(rendered, this.searchHighlightQuery); this.logger.debug( `scanElementSubtree: q='${this.searchHighlightQuery}' ` + `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` + @@ -1296,8 +1206,8 @@ export default class Ink { .map(p => `${p.row}:${p.col}`) .join(',')}` + `${positions.length > 10 ? ',…' : ''}]`, - ) - return positions + ); + return positions; } /** Set the position-based highlight state. Every frame, writes CURRENT @@ -1307,13 +1217,13 @@ export default class Ink { * screen-top); positions stay stable (message-relative). */ setSearchPositions( state: { - positions: MatchPosition[] - rowOffset: number - currentIdx: number + positions: MatchPosition[]; + rowOffset: number; + currentIdx: number; } | null, ): void { - this.searchPositions = state - this.scheduleRender() + this.searchPositions = state; + this.scheduleRender(); } /** @@ -1334,17 +1244,17 @@ export default class Ink { // Wrap a NUL marker, then split on it to extract the open/close SGR. // colorize returns the input unchanged if the color string is bad — // no NUL-split then, so fall through to null (inverse fallback). - const wrapped = colorize('\0', color, 'background') - const nul = wrapped.indexOf('\0') + const wrapped = colorize('\0', color, 'background'); + const nul = wrapped.indexOf('\0'); if (nul <= 0 || nul === wrapped.length - 1) { - this.stylePool.setSelectionBg(null) - return + this.stylePool.setSelectionBg(null); + return; } this.stylePool.setSelectionBg({ type: 'ansi', code: wrapped.slice(0, nul), endCode: wrapped.slice(nul + 1), // always \x1b[49m for bg - }) + }); // No scheduleRender: this is called from a React effect that already // runs inside the render cycle, and the bg only matters once a // selection exists (which itself triggers a full-damage frame). @@ -1356,18 +1266,8 @@ export default class Ink { * screen buffer still holds the outgoing content. Accumulated into * the selection state and joined back in by getSelectedText. */ - captureScrolledRows( - firstRow: number, - lastRow: number, - side: 'above' | 'below', - ): void { - captureScrolledRows( - this.selection, - this.frontFrame.screen, - firstRow, - lastRow, - side, - ) + captureScrolledRows(firstRow: number, lastRow: number, side: 'above' | 'below'): void { + captureScrolledRows(this.selection, this.frontFrame.screen, firstRow, lastRow, side); } /** @@ -1378,20 +1278,14 @@ export default class Ink { * edge. Supplies screen.width for the col-reset-on-clamp boundary. */ shiftSelectionForScroll(dRow: number, minRow: number, maxRow: number): void { - const hadSel = hasSelection(this.selection) - shiftSelection( - this.selection, - dRow, - minRow, - maxRow, - this.frontFrame.screen.width, - ) + const hadSel = hasSelection(this.selection); + shiftSelection(this.selection, dRow, minRow, maxRow, this.frontFrame.screen.width); // shiftSelection clears when both endpoints overshoot the same edge // (Home/g/End/G page-jump past the selection). Notify subscribers so // useHasSelection updates. Safe to call notifySelectionChange here — // this runs from keyboard handlers, not inside onRender(). if (hadSel && !hasSelection(this.selection)) { - this.notifySelectionChange() + this.notifySelectionChange(); } } @@ -1404,49 +1298,49 @@ export default class Ink { * char mode. No-op outside alt-screen or without an active selection. */ moveSelectionFocus(move: FocusMove): void { - if (!this.altScreenActive) return - const { focus } = this.selection - if (!focus) return - const { width, height } = this.frontFrame.screen - const maxCol = width - 1 - const maxRow = height - 1 - let { col, row } = focus + if (!this.altScreenActive) return; + const { focus } = this.selection; + if (!focus) return; + const { width, height } = this.frontFrame.screen; + const maxCol = width - 1; + const maxRow = height - 1; + let { col, row } = focus; switch (move) { case 'left': - if (col > 0) col-- + if (col > 0) col--; else if (row > 0) { - col = maxCol - row-- + col = maxCol; + row--; } - break + break; case 'right': - if (col < maxCol) col++ + if (col < maxCol) col++; else if (row < maxRow) { - col = 0 - row++ + col = 0; + row++; } - break + break; case 'up': - if (row > 0) row-- - break + if (row > 0) row--; + break; case 'down': - if (row < maxRow) row++ - break + if (row < maxRow) row++; + break; case 'lineStart': - col = 0 - break + col = 0; + break; case 'lineEnd': - col = maxCol - break + col = maxCol; + break; } - if (col === focus.col && row === focus.row) return - moveFocus(this.selection, col, row) - this.notifySelectionChange() + if (col === focus.col && row === focus.row) return; + moveFocus(this.selection, col, row); + this.notifySelectionChange(); } /** Whether there is an active text selection. */ hasTextSelection(): boolean { - return hasSelection(this.selection) + return hasSelection(this.selection); } /** @@ -1454,13 +1348,13 @@ export default class Ink { * is started, updated, cleared, or copied. Returns an unsubscribe fn. */ subscribeToSelectionChange(cb: () => void): () => void { - this.selectionListeners.add(cb) - return () => this.selectionListeners.delete(cb) + this.selectionListeners.add(cb); + return () => this.selectionListeners.delete(cb); } private notifySelectionChange(): void { - this.onRender() - for (const cb of this.selectionListeners) cb() + this.onRender(); + for (const cb of this.selectionListeners) cb(); } /** @@ -1471,33 +1365,28 @@ export default class Ink { * nodeCache rects map 1:1 to terminal cells (no scrollback offset). */ dispatchClick(col: number, row: number): boolean { - if (!this.altScreenActive) return false - const blank = isEmptyCellAt(this.frontFrame.screen, col, row) - return dispatchClick(this.rootNode, col, row, blank) + if (!this.altScreenActive) return false; + const blank = isEmptyCellAt(this.frontFrame.screen, col, row); + return dispatchClick(this.rootNode, col, row, blank); } dispatchHover(col: number, row: number): void { - if (!this.altScreenActive) return - dispatchHover(this.rootNode, col, row, this.hoveredNodes) + if (!this.altScreenActive) return; + dispatchHover(this.rootNode, col, row, this.hoveredNodes); } dispatchKeyboardEvent(parsedKey: ParsedKey): void { - const target = this.focusManager.activeElement ?? this.rootNode - const event = new KeyboardEvent(parsedKey) - dispatcher.dispatchDiscrete(target, event) + const target = this.focusManager.activeElement ?? this.rootNode; + const event = new KeyboardEvent(parsedKey); + dispatcher.dispatchDiscrete(target, event); // Tab cycling is the default action — only fires if no handler // called preventDefault(). Mirrors browser behavior. - if ( - !event.defaultPrevented && - parsedKey.name === 'tab' && - !parsedKey.ctrl && - !parsedKey.meta - ) { + if (!event.defaultPrevented && parsedKey.name === 'tab' && !parsedKey.ctrl && !parsedKey.meta) { if (parsedKey.shift) { - this.focusManager.focusPrevious(this.rootNode) + this.focusManager.focusPrevious(this.rootNode); } else { - this.focusManager.focusNext(this.rootNode) + this.focusManager.focusNext(this.rootNode); } } } @@ -1511,23 +1400,23 @@ export default class Ink { * the browser-open action via a timer. */ getHyperlinkAt(col: number, row: number): string | undefined { - if (!this.altScreenActive) return undefined - const screen = this.frontFrame.screen - const cell = cellAt(screen, col, row) - let url = cell?.hyperlink + if (!this.altScreenActive) return undefined; + const screen = this.frontFrame.screen; + const cell = cellAt(screen, col, row); + let url = cell?.hyperlink; // SpacerTail cells (right half of wide/CJK/emoji chars) store the // hyperlink on the head cell at col-1. if (!url && cell?.width === CellWidth.SpacerTail && col > 0) { - url = cellAt(screen, col - 1, row)?.hyperlink + url = cellAt(screen, col - 1, row)?.hyperlink; } - return url ?? findPlainTextUrlAt(screen, col, row) + return url ?? findPlainTextUrlAt(screen, col, row); } /** * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen * mode. Set by FullscreenLayout via useLayoutEffect. */ - onHyperlinkClick: ((url: string) => void) | undefined + onHyperlinkClick: ((url: string) => void) | undefined; /** * Stable prototype wrapper for onHyperlinkClick. Passed to as @@ -1535,7 +1424,7 @@ export default class Ink { * the mutable field at call time — not the undefined-at-render value. */ openHyperlink(url: string): void { - this.onHyperlinkClick?.(url) + this.onHyperlinkClick?.(url); } /** @@ -1546,18 +1435,18 @@ export default class Ink { * char-mode startSelection if the click lands on a noSelect cell. */ handleMultiClick(col: number, row: number, count: 2 | 3): void { - if (!this.altScreenActive) return - const screen = this.frontFrame.screen + if (!this.altScreenActive) return; + const screen = this.frontFrame.screen; // selectWordAt/selectLineAt no-op on noSelect/out-of-bounds. Seed with // a char-mode selection so the press still starts a drag even if the // word/line scan finds nothing selectable. - startSelection(this.selection, col, row) - if (count === 2) selectWordAt(this.selection, screen, col, row) - else selectLineAt(this.selection, screen, row) + startSelection(this.selection, col, row); + if (count === 2) selectWordAt(this.selection, screen, col, row); + else selectLineAt(this.selection, screen, row); // Ensure hasSelection is true so release doesn't re-dispatch onClickAt. // selectWordAt no-ops on noSelect; selectLineAt no-ops out-of-bounds. - if (!this.selection.focus) this.selection.focus = this.selection.anchor - this.notifySelectionChange() + if (!this.selection.focus) this.selection.focus = this.selection.anchor; + this.notifySelectionChange(); } /** @@ -1567,85 +1456,84 @@ export default class Ink { * altScreenActive for the same reason as dispatchClick. */ handleSelectionDrag(col: number, row: number): void { - if (!this.altScreenActive) return - const sel = this.selection + if (!this.altScreenActive) return; + const sel = this.selection; if (sel.anchorSpan) { - extendSelection(sel, this.frontFrame.screen, col, row) + extendSelection(sel, this.frontFrame.screen, col, row); } else { - updateSelection(sel, col, row) + updateSelection(sel, col, row); } - this.notifySelectionChange() + this.notifySelectionChange(); } // Methods to properly suspend stdin for external editor usage // This is needed to prevent Ink from swallowing keystrokes when an external editor is active private stdinListeners: Array<{ - event: string - listener: (...args: unknown[]) => void - }> = [] - private wasRawMode = false + event: string; + listener: (...args: unknown[]) => void; + }> = []; + private wasRawMode = false; suspendStdin(): void { - const stdin = this.options.stdin + const stdin = this.options.stdin; if (!stdin.isTTY) { - return + return; } // Store and remove all 'readable' event listeners temporarily // This prevents Ink from consuming stdin while the editor is active - const readableListeners = stdin.listeners('readable') + const readableListeners = stdin.listeners('readable'); this.logger.debug( `[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false}`, - ) + ); readableListeners.forEach(listener => { this.stdinListeners.push({ event: 'readable', listener: listener as (...args: unknown[]) => void, - }) - stdin.removeListener('readable', listener as (...args: unknown[]) => void) - }) + }); + stdin.removeListener('readable', listener as (...args: unknown[]) => void); + }); // If raw mode is enabled, disable it temporarily const stdinWithRaw = stdin as NodeJS.ReadStream & { - isRaw?: boolean - setRawMode?: (mode: boolean) => void - } + isRaw?: boolean; + setRawMode?: (mode: boolean) => void; + }; if (stdinWithRaw.isRaw && stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(false) - this.wasRawMode = true + stdinWithRaw.setRawMode(false); + this.wasRawMode = true; } } resumeStdin(): void { - const stdin = this.options.stdin + const stdin = this.options.stdin; if (!stdin.isTTY) { - return + return; } // Re-attach all the stored listeners if (this.stdinListeners.length === 0 && !this.wasRawMode) { - this.logger.debug( - '[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', - { level: 'warn' }, - ) + this.logger.debug('[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', { + level: 'warn', + }); } this.logger.debug( `[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`, - ) + ); this.stdinListeners.forEach(({ event, listener }) => { - stdin.addListener(event, listener) - }) - this.stdinListeners = [] + stdin.addListener(event, listener); + }); + this.stdinListeners = []; // Re-enable raw mode if it was enabled before if (this.wasRawMode) { const stdinWithRaw = stdin as NodeJS.ReadStream & { - setRawMode?: (mode: boolean) => void - } + setRawMode?: (mode: boolean) => void; + }; if (stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(true) + stdinWithRaw.setRawMode(true); } - this.wasRawMode = false + this.wasRawMode = false; } } @@ -1654,25 +1542,18 @@ export default class Ink { // cascades through useContext → 's useLayoutEffect dep // array → spurious exit+re-enter of the alt screen on every SIGWINCH. private writeRaw(data: string): void { - this.options.stdout.write(data) + this.options.stdout.write(data); } - private setCursorDeclaration: CursorDeclarationSetter = ( - decl, - clearIfNode, - ) => { - if ( - decl === null && - clearIfNode !== undefined && - this.cursorDeclaration?.node !== clearIfNode - ) { - return + private setCursorDeclaration: CursorDeclarationSetter = (decl, clearIfNode) => { + if (decl === null && clearIfNode !== undefined && this.cursorDeclaration?.node !== clearIfNode) { + return; } - this.cursorDeclaration = decl - } + this.cursorDeclaration = decl; + }; render(node: ReactNode): void { - this.currentNode = node + this.currentNode = node; const tree = ( - - {node} - + {node} - ) + ); - // @ts-ignore updateContainerSync exists in react-reconciler but not in @types - reconciler.updateContainerSync(tree, this.container, null, noop) - // @ts-ignore flushSyncWork exists in react-reconciler but not in @types - reconciler.flushSyncWork() + // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types + reconciler.updateContainerSync(tree, this.container, null, noop); + // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types + reconciler.flushSyncWork(); } unmount(error?: Error | number | null): void { if (this.isUnmounted) { - return + return; } - this.onRender() - this.unsubscribeExit() + this.onRender(); + this.unsubscribeExit(); if (typeof this.restoreConsole === 'function') { - this.restoreConsole() + this.restoreConsole(); } - this.restoreStderr?.() + this.restoreStderr?.(); - this.unsubscribeTTYHandlers?.() + this.unsubscribeTTYHandlers?.(); // Non-TTY environments don't handle erasing ansi escapes well, so it's better to // only render last frame of non-static output - const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame) - writeDiffToTerminal(this.terminal, optimize(diff)) + const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame); + writeDiffToTerminal(this.terminal, optimize(diff)); // Clean up terminal modes synchronously before process exit. // React's componentWillUnmount won't run in time when process.exit() is called, @@ -1739,83 +1618,82 @@ export default class Ink { if (this.altScreenActive) { // 's unmount effect won't run during signal-exit. // Exit alt screen FIRST so other cleanup sequences go to the main screen. - writeSync(1, EXIT_ALT_SCREEN) + writeSync(1, EXIT_ALT_SCREEN); } // Disable mouse tracking — unconditional because altScreenActive can be // stale if AlternateScreen's unmount (which flips the flag) raced a // blocked event loop + SIGINT. No-op if tracking was never enabled. - writeSync(1, DISABLE_MOUSE_TRACKING) + writeSync(1, DISABLE_MOUSE_TRACKING); // Drain stdin so in-flight mouse events don't leak to the shell - this.drainStdin() + this.drainStdin(); // Disable extended key reporting (both kitty and modifyOtherKeys) - writeSync(1, DISABLE_MODIFY_OTHER_KEYS) - writeSync(1, DISABLE_KITTY_KEYBOARD) + writeSync(1, DISABLE_MODIFY_OTHER_KEYS); + writeSync(1, DISABLE_KITTY_KEYBOARD); // Disable focus events (DECSET 1004) - writeSync(1, DFE) + writeSync(1, DFE); // Disable bracketed paste mode - writeSync(1, DBP) + writeSync(1, DBP); // Show cursor - writeSync(1, SHOW_CURSOR) + writeSync(1, SHOW_CURSOR); // Clear iTerm2 progress bar - writeSync(1, CLEAR_ITERM2_PROGRESS) + writeSync(1, CLEAR_ITERM2_PROGRESS); // Clear tab status (OSC 21337) so a stale dot doesn't linger - if (supportsTabStatus()) - writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)) + if (supportsTabStatus()) writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)); } /* eslint-enable custom-rules/no-sync-fs */ - this.isUnmounted = true + this.isUnmounted = true; // Cancel any pending throttled renders to prevent accessing freed Yoga nodes - this.scheduleRender.cancel?.() + this.scheduleRender.cancel?.(); if (this.drainTimer !== null) { - clearTimeout(this.drainTimer) - this.drainTimer = null + clearTimeout(this.drainTimer); + this.drainTimer = null; } - // @ts-ignore updateContainerSync exists in react-reconciler but not in @types - reconciler.updateContainerSync(null, this.container, null, noop) - // @ts-ignore flushSyncWork exists in react-reconciler but not in @types - reconciler.flushSyncWork() - instances.delete(this.options.stdout) + // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types + reconciler.updateContainerSync(null, this.container, null, noop); + // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types + reconciler.flushSyncWork(); + instances.delete(this.options.stdout); // Free the root yoga node, then clear its reference. Children are already // freed by the reconciler's removeChildFromContainer; using .free() (not // .freeRecursive()) avoids double-freeing them. - this.rootNode.yogaNode?.free() - this.rootNode.yogaNode = undefined + this.rootNode.yogaNode?.free(); + this.rootNode.yogaNode = undefined; if (error instanceof Error) { - this.rejectExitPromise(error) + this.rejectExitPromise(error); } else { - this.resolveExitPromise() + this.resolveExitPromise(); } } async waitUntilExit(): Promise { this.exitPromise ||= new Promise((resolve, reject) => { - this.resolveExitPromise = resolve - this.rejectExitPromise = reject - }) + this.resolveExitPromise = resolve; + this.rejectExitPromise = reject; + }); - return this.exitPromise + return this.exitPromise; } resetLineCount(): void { if (this.options.stdout.isTTY) { // Swap so old front becomes back (for screen reuse), then reset front - this.backFrame = this.frontFrame + this.backFrame = this.frontFrame; this.frontFrame = emptyFrame( this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool, - ) - this.log.reset() + ); + this.log.reset(); // frontFrame is reset, so frame.cursor on the next render is (0,0). // Clear displayCursor so the preamble doesn't compute a stale delta. - this.displayCursor = null + this.displayCursor = null; } } @@ -1828,41 +1706,34 @@ export default class Ink { * Call between conversation turns or periodically. */ resetPools(): void { - this.charPool = new CharPool() - this.hyperlinkPool = new HyperlinkPool() - migrateScreenPools( - this.frontFrame.screen, - this.charPool, - this.hyperlinkPool, - ) + this.charPool = new CharPool(); + this.hyperlinkPool = new HyperlinkPool(); + migrateScreenPools(this.frontFrame.screen, this.charPool, this.hyperlinkPool); // Back frame's data is zeroed by resetScreen before reads, but its pool // references are used by the renderer to intern new characters. Point // them at the new pools so the next frame's IDs are comparable. - this.backFrame.screen.charPool = this.charPool - this.backFrame.screen.hyperlinkPool = this.hyperlinkPool + this.backFrame.screen.charPool = this.charPool; + this.backFrame.screen.hyperlinkPool = this.hyperlinkPool; } patchConsole(): () => void { - // biome-ignore lint/suspicious/noConsole: intentionally patching global console - const con = console - const originals: Partial> = {} - const toDebug = (...args: unknown[]) => - this.logger.debug(`console.log: ${format(...args)}`) - const toError = (...args: unknown[]) => - this.logger.error(new Error(`console.error: ${format(...args)}`)) + const con = console; + const originals: Partial> = {}; + const toDebug = (...args: unknown[]) => this.logger.debug(`console.log: ${format(...args)}`); + const toError = (...args: unknown[]) => this.logger.error(new Error(`console.error: ${format(...args)}`)); for (const m of CONSOLE_STDOUT_METHODS) { - originals[m] = con[m] - con[m] = toDebug + originals[m] = con[m]; + con[m] = toDebug; } for (const m of CONSOLE_STDERR_METHODS) { - originals[m] = con[m] - con[m] = toError + originals[m] = con[m]; + con[m] = toError; } - originals.assert = con.assert + originals.assert = con.assert; con.assert = (condition: unknown, ...args: unknown[]) => { - if (!condition) toError(...args) - } - return () => Object.assign(con, originals) + if (!condition) toError(...args); + }; + return () => Object.assign(con, originals); } /** @@ -1878,46 +1749,42 @@ export default class Ink { * process.stdout — Ink itself writes there. */ private patchStderr(): () => void { - const stderr = process.stderr - const originalWrite = stderr.write - let reentered = false + const stderr = process.stderr; + const originalWrite = stderr.write; + let reentered = false; const intercept = ( chunk: Uint8Array | string, encodingOrCb?: BufferEncoding | ((err?: Error | null) => void), cb?: (err?: Error | null) => void, ): boolean => { - const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb; // Reentrancy guard: logger.debug → writeToStderr → here. Pass // through to the original so --debug-to-stderr still works and we // don't stack-overflow. if (reentered) { - const encoding = - typeof encodingOrCb === 'string' ? encodingOrCb : undefined - return originalWrite.call(stderr, chunk, encoding, callback) + const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined; + return originalWrite.call(stderr, chunk, encoding, callback); } - reentered = true + reentered = true; try { - const text = - typeof chunk === 'string' - ? chunk - : Buffer.from(chunk).toString('utf8') - this.logger.debug(`[stderr] ${text}`, { level: 'warn' }) + const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + this.logger.debug(`[stderr] ${text}`, { level: 'warn' }); if (this.altScreenActive && !this.isUnmounted && !this.isPaused) { - this.prevFrameContaminated = true - this.scheduleRender() + this.prevFrameContaminated = true; + this.scheduleRender(); } } finally { - reentered = false - callback?.() + reentered = false; + callback?.(); } - return true - } - stderr.write = intercept + return true; + }; + stderr.write = intercept; return () => { if (stderr.write === intercept) { - stderr.write = originalWrite + stderr.write = originalWrite; } - } + }; } } @@ -1944,7 +1811,7 @@ export default class Ink { */ /* eslint-disable custom-rules/no-sync-fs -- must be sync; called from signal handler / unmount */ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { - if (!stdin.isTTY) return + if (!stdin.isTTY) return; // Drain Node's stream buffer (bytes libuv already pulled in). read() // returns null when empty — never blocks. try { @@ -1956,27 +1823,27 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } // No /dev/tty on Windows; CONIN$ doesn't support O_NONBLOCK semantics. // Windows Terminal also doesn't buffer mouse reports the same way. - if (process.platform === 'win32') return + if (process.platform === 'win32') return; // termios is per-device: flip stdin to raw so canonical-mode line // buffering doesn't hide partial input from the non-blocking read. // Restored in the finally block. const tty = stdin as NodeJS.ReadStream & { - isRaw?: boolean - setRawMode?: (raw: boolean) => void - } - const wasRaw = tty.isRaw === true + isRaw?: boolean; + setRawMode?: (raw: boolean) => void; + }; + const wasRaw = tty.isRaw === true; // Drain the kernel TTY buffer via a fresh O_NONBLOCK fd. Bounded at 64 // reads (64KB) — a real mouse burst is a few hundred bytes; the cap // guards against a terminal that ignores O_NONBLOCK. - let fd = -1 + let fd = -1; try { // setRawMode inside try: on revoked TTY (SIGHUP/SSH disconnect) the // ioctl throws EBADF — same recovery path as openSync/readSync below. - if (!wasRaw) tty.setRawMode?.(true) - fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) - const buf = Buffer.alloc(1024) + if (!wasRaw) tty.setRawMode?.(true); + fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + const buf = Buffer.alloc(1024); for (let i = 0; i < 64; i++) { - if (readSync(fd, buf, 0, buf.length, null) <= 0) break + if (readSync(fd, buf, 0, buf.length, null) <= 0) break; } } catch { // EAGAIN (buffer empty — expected), ENXIO/ENOENT (no controlling tty), @@ -1984,14 +1851,14 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } finally { if (fd >= 0) { try { - closeSync(fd) + closeSync(fd); } catch { /* ignore */ } } if (!wasRaw) { try { - tty.setRawMode?.(false) + tty.setRawMode?.(false); } catch { /* TTY may be gone */ } @@ -2015,5 +1882,5 @@ const CONSOLE_STDOUT_METHODS = [ 'time', 'timeEnd', 'timeLog', -] as const -const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const +] as const; +const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const; diff --git a/packages/@ant/ink/src/core/log-update.ts b/packages/@ant/ink/src/core/log-update.ts index 210b1e313..939bdfda3 100644 --- a/packages/@ant/ink/src/core/log-update.ts +++ b/packages/@ant/ink/src/core/log-update.ts @@ -67,7 +67,7 @@ export class LogUpdate { const { screen } = frame const lines: string[] = [] let currentStyles: AnsiCode[] = [] - let currentHyperlink: Hyperlink = undefined + let currentHyperlink: Hyperlink for (let y = 0; y < screen.height; y++) { let line = '' for (let x = 0; x < screen.width; x++) { @@ -301,7 +301,7 @@ export class LogUpdate { cursorRestoreScroll let currentStyleId = stylePool.none - let currentHyperlink: Hyperlink = undefined + let currentHyperlink: Hyperlink // First pass: render changes to existing rows (rows < prev.screen.height) let needsFullReset = false @@ -533,7 +533,7 @@ function renderFrameSlice( stylePool: StylePool, ): VirtualScreen { let currentStyleId = stylePool.none - let currentHyperlink: Hyperlink = undefined + let currentHyperlink: Hyperlink // Track the styleId of the last rendered cell on this line (-1 if none). // Passed to visibleCellAtIndex to enable fg-only space optimization. let lastRenderedStyleId = -1 diff --git a/packages/@ant/ink/src/core/parse-keypress.ts b/packages/@ant/ink/src/core/parse-keypress.ts index a7e43adc8..892fb5cca 100644 --- a/packages/@ant/ink/src/core/parse-keypress.ts +++ b/packages/@ant/ink/src/core/parse-keypress.ts @@ -8,18 +8,17 @@ import { Buffer } from 'buffer' import { PASTE_END, PASTE_START } from './termio/csi.js' import { createTokenizer, type Tokenizer } from './termio/tokenize.js' -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/ -// eslint-disable-next-line no-control-regex const FN_KEY_RE = - // eslint-disable-next-line no-control-regex + // biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/ // CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers) // Modifier is optional - when absent, defaults to 1 (no modifiers) -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/ // xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~ @@ -27,41 +26,41 @@ const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/ // modifyOtherKeys=2 is active or via user keybinds, typically over SSH where // TERM sniffing misses Ghostty and we never push Kitty keyboard mode. // Note param order is reversed vs CSI u (modifier first, keycode second). -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/ // -- Terminal response patterns (inbound sequences from the terminal itself) -- // DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode) -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/ // DA1: CSI ? Ps ; ... c — primary device attributes response -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const DA1_RE = /^\x1b\[\?([\d;]*)c$/ // DA2: CSI > Ps ; ... c — secondary device attributes response -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const DA2_RE = /^\x1b\[>([\d;]*)c$/ // Kitty keyboard flags: CSI ? flags u — response to CSI ? u query // (private ? marker distinguishes from CSI u key events) -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/ // DECXCPR cursor position: CSI ? row ; col R // The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R, // Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous. -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/ // OSC response: OSC code ; data (BEL|ST) -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s // XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q). // xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with // their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply // goes through the pty, not the environment. -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s // SGR mouse event: CSI < button ; col ; row M (press) or m (release) // Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit). // Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click. -// eslint-disable-next-line no-control-regex +// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ function createPasteKey(content: string): ParsedKey { diff --git a/packages/@ant/ink/src/core/reconciler.ts b/packages/@ant/ink/src/core/reconciler.ts index 4d75923b8..18164bb20 100644 --- a/packages/@ant/ink/src/core/reconciler.ts +++ b/packages/@ant/ink/src/core/reconciler.ts @@ -34,8 +34,10 @@ if (process.env.NODE_ENV === 'development') { void import('./devtools.js') // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: unknown) { - if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') { - // biome-ignore lint/suspicious/noConsole: intentional warning + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND' + ) { console.warn( ` The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`, @@ -197,7 +199,6 @@ let _prepareAt = 0 /** Debug log helper — replaces fs.appendFileSync with console.warn. */ function debugLog(message: string): void { - // biome-ignore lint/suspicious/noConsole: debug instrumentation console.warn(`[ink-commit] ${message}`) } // --- END --- @@ -304,9 +305,7 @@ const reconciler = createReconciler< if (COMMIT_LOG) { const renderMs = performance.now() - _tr if (renderMs > 10) { - debugLog( - `${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`, - ) + debugLog(`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`) } } }, diff --git a/packages/@ant/ink/src/core/root.ts b/packages/@ant/ink/src/core/root.ts index 36df98c70..69caf8bef 100644 --- a/packages/@ant/ink/src/core/root.ts +++ b/packages/@ant/ink/src/core/root.ts @@ -114,7 +114,6 @@ const wrappedRender = async ( await Promise.resolve() const instance = renderSync(node, options) if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') { - // biome-ignore lint/suspicious/noConsole: debug instrumentation console.warn( `[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`, ) diff --git a/packages/@ant/ink/src/core/screen.ts b/packages/@ant/ink/src/core/screen.ts index 5b206d954..6cfcfc023 100644 --- a/packages/@ant/ink/src/core/screen.ts +++ b/packages/@ant/ink/src/core/screen.ts @@ -286,7 +286,7 @@ function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean { * @see https://mitchellh.com/writing/grapheme-clusters-in-terminals */ // const enum is inlined at compile time - no runtime object, no property access -export const enum CellWidth { +export enum CellWidth { // Not a wide character, cell width 1 Narrow = 0, // Wide character, cell width 2. This cell contains the actual character. @@ -1144,7 +1144,7 @@ type DiffCallback = ( y: number, removed: Cell | undefined, added: Cell | undefined, -) => boolean | void +) => boolean | undefined /** * Like diff(), but calls a callback for each change instead of building an array. diff --git a/packages/@ant/ink/src/core/yoga-layout/index.ts b/packages/@ant/ink/src/core/yoga-layout/index.ts index 49b9602be..d83bf181f 100644 --- a/packages/@ant/ink/src/core/yoga-layout/index.ts +++ b/packages/@ant/ink/src/core/yoga-layout/index.ts @@ -111,6 +111,7 @@ function isDefined(n: number): boolean { // NaN-safe equality for layout-cache input comparison function sameFloat(a: number, b: number): boolean { + // biome-ignore lint/suspicious/noSelfCompare: intentional NaN check (x !== x ↔ isNaN) return a === b || (a !== a && b !== b) } @@ -2372,12 +2373,14 @@ function boundAxis( if (v > maxV.value) v = maxV.value } else if (maxU === 2) { const m = (maxV.value * owner) / 100 + // biome-ignore lint/suspicious/noSelfCompare: intentional NaN check if (m === m && v > m) v = m } if (minU === 1) { if (v < minV.value) v = minV.value } else if (minU === 2) { const m = (minV.value * owner) / 100 + // biome-ignore lint/suspicious/noSelfCompare: intentional NaN check if (m === m && v < m) v = m } return v diff --git a/packages/remote-control-server/web/src/acp/client.ts b/packages/remote-control-server/web/src/acp/client.ts index 4a28ba456..80ec3cd13 100644 --- a/packages/remote-control-server/web/src/acp/client.ts +++ b/packages/remote-control-server/web/src/acp/client.ts @@ -118,8 +118,6 @@ export class ACPClient { reject: (err: Error) => void timer: ReturnType } | null = null - // Tracks the session ID being targeted by a load/resume operation - private pendingSessionTarget: string | null = null private connectResolve: ((value: undefined) => void) | null = null private connectReject: ((error: Error) => void) | null = null