From 6182015005e13c1f7633d1f169ea5288aaa4c165 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 1 May 2026 21:39:30 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E5=AE=8C=E6=88=90=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=9A=84lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 4 +- .github/workflows/ci.yml | 3 + .husky/pre-commit | 1 + CLAUDE.md | 16 +- biome.json | 223 +- build.ts | 3 +- bun.lock | 44 + docs.json | 2 +- knip.json | 40 +- package.json | 21 +- packages/acp-link/src/__tests__/cert.test.ts | 40 +- .../acp-link/src/__tests__/server.test.ts | 373 +- packages/acp-link/src/__tests__/types.test.ts | 131 +- packages/acp-link/src/cert.ts | 136 +- packages/acp-link/src/cli/app.ts | 17 +- packages/acp-link/src/cli/bin.ts | 9 +- packages/acp-link/src/cli/command.ts | 138 +- packages/acp-link/src/cli/context.ts | 5 +- packages/acp-link/src/logger.ts | 68 +- packages/acp-link/src/manager/html.ts | 2 +- packages/acp-link/src/manager/index.ts | 64 +- packages/acp-link/src/manager/manager.ts | 214 +- packages/acp-link/src/manager/routes.ts | 168 +- packages/acp-link/src/manager/types.ts | 44 +- packages/acp-link/src/rcs-upstream.ts | 286 +- packages/acp-link/src/server.ts | 1258 +- packages/acp-link/src/types.ts | 130 +- packages/acp-link/src/ws-auth.ts | 56 +- packages/acp-link/src/ws-message.ts | 51 +- packages/acp-link/tsconfig.json | 2 +- .../agent-tools/src/__tests__/compat.test.ts | 32 +- .../src/__tests__/registry.test.ts | 8 +- packages/agent-tools/src/types.ts | 5 +- packages/audio-capture-napi/package.json | 12 +- packages/audio-capture-napi/src/index.ts | 5 +- packages/builtin-tools/src/index.ts | 10 +- .../src/tools/AgentTool/AgentTool.tsx | 1089 +- .../builtin-tools/src/tools/AgentTool/UI.tsx | 765 +- .../AgentTool/__tests__/agentDisplay.test.ts | 220 +- .../__tests__/agentToolUtils.test.ts | 252 +- .../filterIncompleteToolCalls.test.ts | 8 +- .../src/tools/AgentTool/agentMemory.ts | 5 +- .../src/tools/AgentTool/agentToolUtils.ts | 25 +- .../built-in/src/tools/BashTool/toolName.ts | 2 +- .../src/tools/ExitPlanModeTool/constants.ts | 2 +- .../src/tools/FileEditTool/constants.ts | 2 +- .../built-in/src/tools/FileReadTool/prompt.ts | 2 +- .../src/tools/FileWriteTool/prompt.ts | 2 +- .../built-in/src/tools/GlobTool/prompt.ts | 2 +- .../built-in/src/tools/GrepTool/prompt.ts | 2 +- .../src/tools/NotebookEditTool/constants.ts | 2 +- .../src/tools/SendMessageTool/constants.ts | 2 +- .../built-in/src/tools/WebFetchTool/prompt.ts | 2 +- .../src/tools/WebSearchTool/prompt.ts | 2 +- .../AgentTool/built-in/src/utils/auth.ts | 2 +- .../built-in/src/utils/embeddedTools.ts | 2 +- .../built-in/src/utils/settings/settings.ts | 2 +- .../src/tools/AgentTool/forkSubagent.ts | 14 +- .../src/tools/AgentTool/src/Tool.ts | 6 +- .../components/ConfigurableShortcutHint.ts | 2 +- .../AgentTool/src/components/CtrlOToExpand.ts | 4 +- .../src/components/design-system/Byline.ts | 2 +- .../design-system/KeyboardShortcutHint.ts | 2 +- .../src/tools/AgentTool/src/types/message.ts | 4 +- .../src/tools/AgentTool/src/utils/debug.ts | 2 +- .../AgentTool/src/utils/promptCategory.ts | 2 +- .../AgentTool/src/utils/settings/constants.ts | 2 +- .../AskUserQuestionTool.tsx | 191 +- .../src/bootstrap/state.ts | 4 +- .../src/components/MessageResponse.ts | 2 +- .../src/constants/figures.ts | 2 +- .../src/utils/permissions/PermissionMode.ts | 2 +- .../src/tools/BashTool/BashTool.tsx | 905 +- .../tools/BashTool/BashToolResultMessage.tsx | 73 +- .../builtin-tools/src/tools/BashTool/UI.tsx | 159 +- .../__tests__/backslashEscaping.test.ts | 136 +- .../__tests__/commandSemantics.test.ts | 115 +- .../__tests__/compoundCommandSecurity.test.ts | 128 +- .../destructiveCommandWarning.test.ts | 172 +- .../__tests__/networkDeviceRedirect.test.ts | 152 +- .../src/tools/BashTool/bashSecurity.ts | 7 +- .../src/tools/BashTool/sedValidation.ts | 5 +- .../src/tools/BashTool/src/Tool.ts | 2 +- .../src/tools/BashTool/src/bootstrap/state.ts | 2 +- .../tools/BashTool/src/hooks/useCanUseTool.ts | 2 +- .../src/services/analytics/growthbook.ts | 2 +- .../BashTool/src/services/analytics/index.ts | 2 +- .../src/tools/BashTool/src/state/AppState.ts | 2 +- .../src/tools/BashTool/src/utils/Shell.ts | 2 +- .../src/tools/BashTool/src/utils/cwd.ts | 2 +- .../src/utils/permissions/filesystem.ts | 2 +- .../src/utils/sandbox/sandbox-ui-utils.ts | 2 +- .../builtin-tools/src/tools/BriefTool/UI.tsx | 50 +- .../builtin-tools/src/tools/ConfigTool/UI.tsx | 27 +- .../tools/CtxInspectTool/CtxInspectTool.ts | 7 +- .../__tests__/CtxInspectTool.test.ts | 4 +- .../__tests__/DiscoverSkillsTool.test.ts | 4 +- .../src/tools/EnterPlanModeTool/UI.tsx | 26 +- .../src/constants/figures.ts | 2 +- .../src/utils/permissions/PermissionMode.ts | 2 +- .../src/tools/EnterWorktreeTool/UI.tsx | 16 +- .../src/tools/ExitPlanModeTool/UI.tsx | 50 +- .../src/components/Markdown.ts | 2 +- .../src/components/MessageResponse.ts | 2 +- .../RejectedPlanMessage.ts | 2 +- .../ExitPlanModeTool/src/constants/figures.ts | 2 +- .../src/utils/permissions/PermissionMode.ts | 2 +- .../src/tools/ExitWorktreeTool/UI.tsx | 19 +- .../src/tools/FileEditTool/FileEditTool.ts | 5 +- .../src/tools/FileEditTool/UI.tsx | 130 +- .../FileEditTool/__tests__/utils.test.ts | 394 +- .../FileEditToolUseRejectedMessage.ts | 2 +- .../src/components/MessageResponse.ts | 2 +- .../src/services/analytics/index.ts | 2 +- .../src/tools/FileEditTool/src/utils/log.ts | 2 +- .../tools/FileEditTool/src/utils/messages.ts | 2 +- .../src/tools/FileEditTool/src/utils/path.ts | 2 +- .../FileEditTool/src/utils/stringUtils.ts | 2 +- .../src/tools/FileEditTool/utils.ts | 31 +- .../src/tools/FileReadTool/UI.tsx | 124 +- .../src/tools/FileReadTool/imageProcessor.ts | 4 +- .../src/services/analytics/growthbook.ts | 2 +- .../src/tools/FileReadTool/src/utils/file.ts | 2 +- .../tools/FileReadTool/src/utils/messages.ts | 2 +- .../src/tools/FileWriteTool/FileWriteTool.ts | 5 +- .../src/tools/FileWriteTool/UI.tsx | 148 +- .../src/components/MessageResponse.ts | 2 +- .../src/services/analytics/index.ts | 2 +- .../tools/FileWriteTool/src/utils/messages.ts | 2 +- .../src/tools/GlobTool/GlobTool.ts | 5 +- .../builtin-tools/src/tools/GlobTool/UI.tsx | 52 +- .../src/components/MessageResponse.ts | 2 +- .../src/tools/GlobTool/src/utils/messages.ts | 2 +- .../src/tools/GrepTool/GrepTool.ts | 5 +- .../builtin-tools/src/tools/GrepTool/UI.tsx | 131 +- .../builtin-tools/src/tools/LSPTool/UI.tsx | 109 +- .../LSPTool/__tests__/formatters.test.ts | 255 +- .../tools/LSPTool/__tests__/schemas.test.ts | 60 +- .../src/tools/ListMcpResourcesTool/UI.tsx | 30 +- .../src/tools/ListPeersTool/ListPeersTool.ts | 7 +- .../builtin-tools/src/tools/MCPTool/UI.tsx | 267 +- .../__tests__/classifyForCollapse.test.ts | 164 +- .../src/tools/MonitorTool/MonitorTool.tsx | 116 +- .../NotebookEditTool/NotebookEditTool.ts | 10 +- .../src/tools/NotebookEditTool/UI.tsx | 88 +- .../NotebookEditTool/src/types/message.ts | 4 +- .../NotebookEditTool/src/utils/fileHistory.ts | 4 +- .../NotebookEditTool/src/utils/messages.ts | 2 +- .../tools/NotebookEditTool/src/utils/theme.ts | 2 +- .../OverflowTestTool/OverflowTestTool.ts | 4 +- .../tools/PowerShellTool/PowerShellTool.tsx | 741 +- .../src/tools/PowerShellTool/UI.tsx | 113 +- .../__tests__/commandSemantics.test.ts | 272 +- .../destructiveCommandWarning.test.ts | 410 +- .../__tests__/gitSafety.test.ts | 203 +- .../__tests__/powershellSecurity.test.ts | 734 +- .../tools/PowerShellTool/pathValidation.ts | 21 +- .../PowerShellTool/src/hooks/useCanUseTool.ts | 2 +- .../PowerShellTool/src/state/AppState.ts | 2 +- .../PushNotificationTool.ts | 38 +- .../src/tools/REPLTool/REPLTool.ts | 3 +- .../src/tools/ReadMcpResourceTool/UI.tsx | 34 +- .../src/tools/RemoteTriggerTool/UI.tsx | 16 +- .../__tests__/RemoteTriggerTool.test.ts | 11 +- .../ReviewArtifactTool/ReviewArtifactTool.ts | 13 +- .../src/tools/ScheduleCronTool/UI.tsx | 47 +- .../tools/SendMessageTool/SendMessageTool.ts | 9 +- .../src/tools/SendMessageTool/UI.tsx | 27 +- .../SendUserFileTool/SendUserFileTool.ts | 10 +- .../src/tools/SkillTool/SkillTool.ts | 9 +- .../builtin-tools/src/tools/SkillTool/UI.tsx | 99 +- .../src/tools/SkillTool/src/Tool.ts | 14 +- .../tools/SkillTool/src/bootstrap/state.ts | 2 +- .../src/tools/SkillTool/src/commands.ts | 16 +- .../SkillTool/src/components/CtrlOToExpand.ts | 2 +- .../components/FallbackToolUseErrorMessage.ts | 2 +- .../FallbackToolUseRejectedMessage.ts | 2 +- .../src/tools/SkillTool/src/types/command.ts | 2 +- .../src/tools/SkillTool/src/types/message.ts | 10 +- .../src/tools/SkillTool/src/utils/debug.ts | 2 +- .../src/utils/permissions/PermissionResult.ts | 2 +- .../src/utils/permissions/permissions.ts | 2 +- .../src/utils/plugins/pluginIdentifier.ts | 4 +- .../src/utils/telemetry/pluginTelemetry.ts | 2 +- .../SleepTool/__tests__/SleepTool.test.ts | 7 +- .../tools/SubscribePRTool/SubscribePRTool.ts | 13 +- .../SuggestBackgroundPRTool.ts | 4 +- .../tools/TaskOutputTool/TaskOutputTool.tsx | 587 +- .../src/tools/TaskStopTool/UI.tsx | 36 +- .../src/tools/TeamCreateTool/UI.tsx | 6 +- .../tools/TeamDeleteTool/TeamDeleteTool.ts | 11 +- .../src/tools/TeamDeleteTool/UI.tsx | 19 +- .../TerminalCaptureTool.ts | 8 +- .../tools/TungstenTool/TungstenLiveMonitor.ts | 3 +- .../src/tools/TungstenTool/TungstenTool.ts | 6 +- .../tools/WebBrowserTool/WebBrowserPanel.ts | 5 +- .../tools/WebBrowserTool/WebBrowserTool.ts | 8 +- .../src/tools/WebFetchTool/UI.tsx | 38 +- .../WebFetchTool/__tests__/headers.test.ts | 4 +- .../__tests__/preapproved.test.ts | 118 +- .../__tests__/urlValidation.test.ts | 184 +- .../src/tools/WebFetchTool/utils.ts | 11 +- .../src/tools/WebSearchTool/UI.tsx | 86 +- .../src/tools/WebSearchTool/WebSearchTool.ts | 15 +- .../__tests__/bingAdapter.integration.ts | 8 +- .../__tests__/bingAdapter.test.ts | 12 +- .../__tests__/braveAdapter.extract.test.ts | 6 +- .../__tests__/braveAdapter.test.ts | 8 +- .../__tests__/exaAdapter.test.ts | 62 +- .../WebSearchTool/adapters/apiAdapter.ts | 60 +- .../WebSearchTool/adapters/bingAdapter.ts | 39 +- .../WebSearchTool/adapters/braveAdapter.ts | 10 +- .../WebSearchTool/adapters/exaAdapter.ts | 29 +- .../src/tools/WebSearchTool/adapters/index.ts | 5 +- .../WebSearchTool/src/constants/common.ts | 2 +- .../src/utils/model/providers.ts | 2 +- .../src/utils/permissions/PermissionResult.ts | 2 +- .../WorkflowPermissionRequest.tsx | 115 +- .../src/tools/WorkflowTool/WorkflowTool.ts | 4 +- .../__tests__/WorkflowTool.test.ts | 43 +- .../WorkflowTool/createWorkflowCommand.ts | 11 +- .../__tests__/gitOperationTracking.test.ts | 298 +- .../shared/__tests__/spawnMultiAgent.test.ts | 5 +- .../src/tools/shared/spawnMultiAgent.ts | 39 +- .../src/tools/src/types/message.ts | 8 +- .../tools/testing/TestingPermissionTool.tsx | 48 +- packages/builtin-tools/src/tools/utils.ts | 8 +- packages/color-diff-napi/package.json | 18 +- .../src/__tests__/color-diff.test.ts | 177 +- .../__tests__/language-registration.test.ts | 30 +- packages/color-diff-napi/src/index.ts | 5 +- packages/image-processor-napi/package.json | 18 +- packages/image-processor-napi/src/index.ts | 13 +- .../src/__tests__/InProcessTransport.test.ts | 24 +- .../mcp-client/src/__tests__/cache.test.ts | 15 +- .../src/__tests__/connection.test.ts | 24 +- .../src/__tests__/discovery.test.ts | 13 +- .../mcp-client/src/__tests__/manager.test.ts | 52 +- .../mcp-client/src/__tests__/strings.test.ts | 30 +- packages/mcp-client/src/connection.ts | 48 +- packages/mcp-client/src/discovery.ts | 19 +- packages/mcp-client/src/execution.ts | 45 +- packages/mcp-client/src/manager.ts | 65 +- packages/mcp-client/src/sanitization.ts | 15 +- packages/modifiers-napi/package.json | 12 +- packages/modifiers-napi/src/index.ts | 52 +- .../remote-control-server/components.json | 1 - packages/remote-control-server/package.json | 2 +- .../src/__tests__/auth.test.ts | 246 +- .../src/__tests__/automationState.test.ts | 285 +- .../src/__tests__/client-payload.test.ts | 398 +- .../src/__tests__/disconnect-monitor.test.ts | 139 +- .../src/__tests__/event-bus.test.ts | 405 +- .../src/__tests__/middleware.test.ts | 426 +- .../src/__tests__/routes.test.ts | 3057 ++-- .../src/__tests__/services.test.ts | 657 +- .../src/__tests__/sse-writer.test.ts | 297 +- .../src/__tests__/store.test.ts | 637 +- .../src/__tests__/transport-normalize.test.ts | 279 +- .../src/__tests__/work-dispatch.test.ts | 242 +- .../src/__tests__/ws-handler.test.ts | 889 +- .../remote-control-server/src/auth/api-key.ts | 14 +- .../remote-control-server/src/auth/cors.ts | 26 +- .../remote-control-server/src/auth/jwt.ts | 76 +- .../src/auth/middleware.ts | 133 +- .../remote-control-server/src/auth/token.ts | 31 +- packages/remote-control-server/src/config.ts | 37 +- packages/remote-control-server/src/index.ts | 171 +- packages/remote-control-server/src/logger.ts | 8 +- .../src/routes/acp/index.ts | 206 +- .../src/routes/v1/environments.ts | 58 +- .../src/routes/v1/environments.work.ts | 66 +- .../src/routes/v1/session-ingress.ts | 138 +- .../src/routes/v1/sessions.ts | 129 +- .../src/routes/v2/code-sessions.ts | 62 +- .../src/routes/v2/worker-events-stream.ts | 48 +- .../src/routes/v2/worker-events.ts | 175 +- .../src/routes/v2/worker.ts | 158 +- .../src/routes/web/auth.ts | 33 +- .../src/routes/web/control.ts | 152 +- .../src/routes/web/environments.ts | 18 +- .../src/routes/web/sessions.ts | 160 +- .../src/services/automationState.ts | 56 +- .../src/services/disconnect-monitor.ts | 50 +- .../src/services/environment.ts | 67 +- .../src/services/session.ts | 188 +- .../src/services/transport.ts | 116 +- .../src/services/work-dispatch.ts | 93 +- packages/remote-control-server/src/store.ts | 419 +- .../src/transport/acp-relay-handler.ts | 152 +- .../src/transport/acp-sse-writer.ts | 78 +- .../src/transport/acp-ws-handler.ts | 306 +- .../src/transport/client-payload.ts | 69 +- .../src/transport/event-bus.ts | 100 +- .../src/transport/sse-writer.ts | 218 +- .../src/transport/ws-handler.ts | 247 +- .../src/transport/ws-payload.ts | 69 +- .../src/transport/ws-shared.ts | 2 +- .../remote-control-server/src/types/api.ts | 178 +- .../src/types/messages.ts | 84 +- .../web/components/ACPConnect.tsx | 248 +- .../web/components/ACPMain.tsx | 79 +- .../web/components/ChatInterface.tsx | 607 +- .../web/components/ChatMessage.tsx | 68 +- .../web/components/ThreadHistory.tsx | 113 +- .../web/components/ai-elements/code-block.tsx | 44 +- .../components/ai-elements/conversation.tsx | 72 +- .../web/components/ai-elements/index.ts | 17 +- .../web/components/ai-elements/message.tsx | 217 +- .../ai-elements/permission-request.tsx | 47 +- .../components/ai-elements/prompt-input.tsx | 677 +- .../web/components/ai-elements/reasoning.tsx | 95 +- .../web/components/ai-elements/shimmer.tsx | 30 +- .../web/components/ai-elements/tool.tsx | 137 +- .../web/components/chat/ChatInput.tsx | 303 +- .../web/components/chat/ChatView.tsx | 87 +- .../web/components/chat/CommandMenu.tsx | 74 +- .../web/components/chat/MessageBubble.tsx | 34 +- .../web/components/chat/PermissionPanel.tsx | 24 +- .../web/components/chat/PlanView.tsx | 68 +- .../web/components/chat/SessionSidebar.tsx | 48 +- .../web/components/chat/ToolCallGroup.tsx | 63 +- .../web/components/chat/index.ts | 16 +- .../web/components/index.ts | 12 +- .../model-selector/ModelSelectorPicker.tsx | 46 +- .../model-selector/ModelSelectorPopover.tsx | 49 +- .../web/components/model-selector/index.ts | 5 +- .../web/components/ui/badge.tsx | 43 +- .../web/components/ui/button-group.tsx | 49 +- .../web/components/ui/button.tsx | 59 +- .../web/components/ui/card.tsx | 85 +- .../web/components/ui/collapsible.tsx | 35 +- .../web/components/ui/command.tsx | 130 +- .../web/components/ui/connection-status.tsx | 57 +- .../web/components/ui/dialog.tsx | 89 +- .../web/components/ui/dropdown-menu.tsx | 127 +- .../web/components/ui/hover-card.tsx | 33 +- .../web/components/ui/index.ts | 44 +- .../web/components/ui/input-group.tsx | 146 +- .../web/components/ui/input.tsx | 19 +- .../web/components/ui/label.tsx | 11 +- .../web/components/ui/popover.tsx | 35 +- .../web/components/ui/resizable.tsx | 32 +- .../web/components/ui/scroll-area.tsx | 37 +- .../web/components/ui/select.tsx | 106 +- .../web/components/ui/separator.tsx | 17 +- .../web/components/ui/tabs.tsx | 65 +- .../web/components/ui/textarea.tsx | 15 +- .../web/components/ui/theme-toggle.tsx | 30 +- .../web/components/ui/tooltip.tsx | 40 +- .../remote-control-server/web/src/App.tsx | 72 +- .../web/src/__tests__/api-client.test.ts | 258 +- .../web/src/__tests__/utils.test.ts | 341 +- .../web/src/acp/client.ts | 740 +- .../web/src/acp/index.ts | 4 +- .../web/src/acp/relay-client.ts | 18 +- .../web/src/acp/types.ts | 411 +- .../web/src/api/client.ts | 82 +- .../remote-control-server/web/src/api/sse.ts | 38 +- .../web/src/components/ACPDirectView.tsx | 31 +- .../web/src/components/ControlBar.tsx | 38 +- .../web/src/components/EnvironmentList.tsx | 30 +- .../web/src/components/EventStream.tsx | 465 +- .../web/src/components/IdentityPanel.tsx | 65 +- .../web/src/components/LoadingIndicator.tsx | 14 +- .../web/src/components/Navbar.tsx | 46 +- .../web/src/components/NewSessionDialog.tsx | 47 +- .../web/src/components/PermissionViews.tsx | 186 +- .../web/src/components/SessionList.tsx | 26 +- .../web/src/components/TaskPanel.tsx | 14 +- .../web/src/components/TokenManagerDialog.tsx | 83 +- .../web/src/hooks/index.ts | 11 +- .../web/src/hooks/useAuth.ts | 12 +- .../web/src/hooks/useCommands.ts | 31 +- .../web/src/hooks/useModels.ts | 93 +- .../web/src/hooks/useQRScanner.ts | 132 +- .../web/src/hooks/useSSE.ts | 24 +- .../web/src/hooks/useTokens.ts | 128 +- .../remote-control-server/web/src/index.css | 115 +- .../web/src/lib/rcs-chat-adapter.ts | 508 +- .../web/src/lib/rcs-transport.ts | 355 +- .../web/src/lib/theme.ts | 109 +- .../web/src/lib/types.ts | 90 +- .../web/src/lib/utils.ts | 119 +- .../remote-control-server/web/src/main.tsx | 10 +- .../web/src/pages/Dashboard.tsx | 23 +- .../web/src/pages/SessionDetail.tsx | 178 +- .../web/src/types/index.ts | 132 +- .../remote-control-server/web/vite.config.ts | 63 +- packages/url-handler-napi/package.json | 12 +- .../src/__tests__/index.test.ts | 4 +- packages/url-handler-napi/src/index.ts | 4 +- packages/weixin/src/__tests__/media.test.ts | 8 +- packages/weixin/src/__tests__/send.test.ts | 10 +- packages/weixin/src/cli.ts | 11 +- packages/weixin/src/login.ts | 8 +- packages/weixin/src/media.ts | 12 +- packages/weixin/src/monitor.ts | 22 +- packages/weixin/src/pairing.ts | 5 +- packages/weixin/src/server.ts | 18 +- scripts/check-bundle-integrity.ts | 218 +- scripts/defines.ts | 130 +- scripts/dev-debug.ts | 4 +- scripts/dev.ts | 50 +- scripts/dump-prompt.ts | 26 +- scripts/post-build.ts | 66 +- scripts/postinstall.cjs | 182 +- scripts/rcs.ts | 14 +- scripts/run-parallel.mjs | 4 +- scripts/setup-chrome-mcp.mjs | 70 +- scripts/vite-plugin-feature-flags.ts | 50 +- scripts/vite-plugin-import-meta-require.ts | 14 +- src/QueryEngine.ts | 104 +- src/__tests__/context.baseline.test.ts | 6 +- src/bootstrap/state.ts | 4 +- src/bridge/bridgeApi.ts | 4 +- src/bridge/bridgeMain.ts | 26 +- src/bridge/bridgeMessaging.ts | 18 +- src/bridge/inboundMessages.ts | 4 +- src/bridge/initReplBridge.ts | 4 +- src/bridge/rcDebugLog.ts | 5 +- src/bridge/remoteBridgeCore.ts | 20 +- src/bridge/replBridge.ts | 45 +- src/bridge/webhookSanitizer.ts | 37 +- src/buddy/CompanionSprite.tsx | 285 +- src/buddy/useBuddyNotification.tsx | 58 +- src/cli/bg.ts | 8 +- src/cli/bg/engines/detached.ts | 7 +- src/cli/bg/engines/index.ts | 7 +- src/cli/bg/engines/tmux.ts | 7 +- src/cli/handlers/__tests__/autonomy.test.ts | 27 +- src/cli/handlers/auth.ts | 8 +- src/cli/handlers/autonomy.ts | 4 +- src/cli/handlers/mcp.tsx | 357 +- src/cli/handlers/util.tsx | 104 +- src/cli/structuredIO.ts | 43 +- src/cli/transports/HybridTransport.ts | 2 +- src/cli/transports/SSETransport.ts | 14 +- src/cli/transports/Transport.ts | 2 +- src/cli/transports/WebSocketTransport.ts | 8 +- .../transports/__tests__/SSETransport.test.ts | 3 +- src/cli/transports/ccrClient.ts | 19 +- src/commands.ts | 7 +- src/commands/add-dir/add-dir.tsx | 115 +- src/commands/agents-platform/index.js | 6 +- src/commands/agents/agents.tsx | 23 +- src/commands/ant-trace/index.js | 2 +- src/commands/autofix-pr/index.js | 2 +- src/commands/backfill-sessions/index.js | 2 +- src/commands/branch/branch.ts | 10 +- src/commands/break-cache/index.js | 2 +- src/commands/bridge/bridge.tsx | 210 +- src/commands/btw/btw.tsx | 182 +- src/commands/buddy/buddy.ts | 4 +- src/commands/bughunter/index.js | 2 +- src/commands/chrome/chrome.tsx | 190 +- src/commands/clear/caches.ts | 34 +- src/commands/compact/compact.ts | 2 +- src/commands/config/config.tsx | 10 +- src/commands/context/context.tsx | 51 +- src/commands/copy/copy.tsx | 274 +- src/commands/ctx_viz/index.js | 2 +- src/commands/daemon/daemon.tsx | 48 +- src/commands/debug-tool-call/index.js | 2 +- src/commands/desktop/desktop.tsx | 13 +- src/commands/diff/diff.tsx | 10 +- src/commands/doctor/doctor.tsx | 10 +- src/commands/effort/effort.tsx | 140 +- src/commands/env/index.js | 2 +- src/commands/exit/exit.tsx | 48 +- src/commands/export/export.tsx | 102 +- src/commands/extra-usage/extra-usage.tsx | 26 +- src/commands/fast/fast.tsx | 183 +- src/commands/feedback/feedback.tsx | 37 +- src/commands/force-snip.ts | 4 +- src/commands/fork/fork.tsx | 59 +- src/commands/good-claude/index.js | 2 +- src/commands/help/help.tsx | 15 +- src/commands/hooks/hooks.tsx | 22 +- src/commands/ide/ide.tsx | 446 +- src/commands/insights.ts | 12 +- .../install-github-app/ApiKeyStep.tsx | 89 +- .../CheckExistingSecretStep.tsx | 50 +- .../install-github-app/CheckGitHubStep.tsx | 6 +- .../install-github-app/ChooseRepoStep.tsx | 73 +- .../install-github-app/CreatingStep.tsx | 52 +- src/commands/install-github-app/ErrorStep.tsx | 23 +- .../ExistingWorkflowStep.tsx | 40 +- .../install-github-app/InstallAppStep.tsx | 25 +- .../install-github-app/OAuthFlowStep.tsx | 228 +- .../install-github-app/SuccessStep.tsx | 32 +- .../install-github-app/WarningsStep.tsx | 24 +- .../install-github-app/install-github-app.tsx | 442 +- src/commands/install.tsx | 186 +- src/commands/issue/index.js | 2 +- src/commands/job/job.tsx | 28 +- src/commands/login/login.tsx | 96 +- src/commands/logout/logout.tsx | 97 +- src/commands/mcp/mcp.tsx | 87 +- src/commands/memory/memory.tsx | 88 +- src/commands/mobile/mobile.tsx | 76 +- src/commands/mock-limits/index.js | 2 +- src/commands/model/model.tsx | 272 +- src/commands/oauth-refresh/index.js | 2 +- src/commands/onboarding/index.js | 2 +- src/commands/output-style/output-style.tsx | 4 +- src/commands/passes/passes.tsx | 28 +- src/commands/perf-issue/index.js | 2 +- src/commands/permissions/permissions.tsx | 17 +- src/commands/plan/index.test.ts | 4 +- src/commands/plan/plan.tsx | 95 +- src/commands/plugin/AddMarketplace.tsx | 153 +- src/commands/plugin/BrowseMarketplace.tsx | 684 +- src/commands/plugin/DiscoverPlugins.tsx | 610 +- src/commands/plugin/ManageMarketplaces.tsx | 687 +- src/commands/plugin/ManagePlugins.tsx | 1862 +-- src/commands/plugin/PluginErrors.tsx | 109 +- src/commands/plugin/PluginOptionsDialog.tsx | 161 +- src/commands/plugin/PluginOptionsFlow.tsx | 116 +- src/commands/plugin/PluginSettings.tsx | 676 +- src/commands/plugin/PluginTrustWarning.tsx | 21 +- src/commands/plugin/UnifiedInstalledCell.tsx | 104 +- src/commands/plugin/ValidatePlugin.tsx | 74 +- .../plugin/__tests__/parseArgs.test.ts | 194 +- src/commands/plugin/index.tsx | 6 +- src/commands/plugin/plugin.tsx | 14 +- src/commands/plugin/pluginDetailsHelpers.tsx | 75 +- src/commands/plugin/types.ts | 4 +- src/commands/plugin/unifiedTypes.ts | 2 +- src/commands/poor/index.ts | 3 +- src/commands/poor/poorMode.ts | 5 +- .../privacy-settings/privacy-settings.tsx | 85 +- src/commands/provider.ts | 11 +- .../rate-limit-options/rate-limit-options.tsx | 151 +- src/commands/remote-env/remote-env.tsx | 12 +- src/commands/remote-setup/remote-setup.tsx | 140 +- src/commands/rename/generateSessionName.ts | 4 +- src/commands/reset-limits/index.js | 8 +- src/commands/reset-limits/index.ts | 6 +- src/commands/resume/resume.tsx | 260 +- .../review/UltrareviewOverageDialog.tsx | 56 +- src/commands/review/reviewRemote.ts | 10 +- src/commands/review/ultrareviewCommand.tsx | 65 +- .../sandbox-toggle/sandbox-toggle.tsx | 101 +- src/commands/session/session.tsx | 62 +- src/commands/share/index.js | 2 +- src/commands/skills/skills.tsx | 15 +- src/commands/stats/stats.tsx | 10 +- src/commands/status/status.tsx | 15 +- src/commands/statusline.tsx | 21 +- src/commands/subscribe-pr.ts | 6 +- src/commands/tag/tag.tsx | 131 +- src/commands/tasks/tasks.tsx | 15 +- src/commands/teleport/index.js | 2 +- src/commands/terminalSetup/terminalSetup.tsx | 453 +- src/commands/theme/theme.tsx | 33 +- src/commands/thinkback/thinkback.tsx | 377 +- src/commands/ultraplan.tsx | 45 +- src/commands/upgrade/upgrade.tsx | 59 +- src/commands/usage/usage.tsx | 10 +- src/commands/voice/index.ts | 4 +- src/commands/voice/voice.ts | 2 +- src/commands/workflows/index.ts | 7 +- src/components/AgentProgressLine.tsx | 65 +- src/components/ApproveApiKey.tsx | 49 +- src/components/AutoModeOptInDialog.tsx | 58 +- src/components/AutoUpdater.tsx | 203 +- src/components/AutoUpdaterWrapper.tsx | 73 +- src/components/AwsAuthStatusBox.tsx | 49 +- src/components/BaseTextInput.tsx | 87 +- src/components/BashModeProgress.tsx | 33 +- src/components/BridgeDialog.tsx | 128 +- .../BypassPermissionsModeDialog.tsx | 61 +- src/components/ChannelDowngradeDialog.tsx | 38 +- .../ClaudeCodeHint/PluginHintMenu.tsx | 59 +- src/components/ClaudeInChromeOnboarding.tsx | 57 +- .../ClaudeMdExternalIncludesDialog.tsx | 49 +- src/components/ClickableImageRef.tsx | 40 +- src/components/CompactSummary.tsx | 46 +- src/components/ConfigurableShortcutHint.tsx | 36 +- src/components/ConsoleOAuthFlow.tsx | 1677 +-- src/components/ContextSuggestions.tsx | 21 +- src/components/ContextVisualization.tsx | 328 +- src/components/CoordinatorAgentStatus.tsx | 217 +- src/components/CostThresholdDialog.tsx | 17 +- src/components/CtrlOToExpand.tsx | 48 +- src/components/CustomSelect/SelectMulti.tsx | 135 +- .../CustomSelect/select-input-option.tsx | 224 +- src/components/CustomSelect/select-option.tsx | 22 +- src/components/CustomSelect/select.tsx | 552 +- src/components/DesktopHandoff.tsx | 119 +- .../DesktopUpsell/DesktopUpsellStartup.tsx | 103 +- src/components/DevBar.tsx | 30 +- src/components/DevChannelsDialog.tsx | 52 +- src/components/DiagnosticsDisplay.tsx | 61 +- src/components/EffortCallout.tsx | 135 +- src/components/ExitFlow.tsx | 34 +- src/components/ExportDialog.tsx | 120 +- .../FallbackToolUseErrorMessage.tsx | 70 +- .../FallbackToolUseRejectedMessage.tsx | 8 +- src/components/FastIcon.tsx | 28 +- src/components/Feedback.tsx | 520 +- .../FeedbackSurvey/FeedbackSurvey.tsx | 109 +- .../FeedbackSurvey/FeedbackSurveyView.tsx | 32 +- .../FeedbackSurvey/TranscriptSharePrompt.tsx | 46 +- .../FeedbackSurvey/useFeedbackSurvey.tsx | 364 +- .../FeedbackSurvey/useMemorySurvey.tsx | 291 +- .../FeedbackSurvey/usePostCompactSurvey.tsx | 176 +- .../FeedbackSurvey/useSurveyState.tsx | 142 +- src/components/FeedbackSurvey/utils.ts | 4 +- src/components/FileEditToolDiff.tsx | 140 +- src/components/FileEditToolUpdatedMessage.tsx | 46 +- .../FileEditToolUseRejectedMessage.tsx | 33 +- src/components/FilePathLink.tsx | 14 +- src/components/FullscreenLayout.tsx | 36 +- src/components/GlobalSearchDialog.tsx | 288 +- src/components/HelpV2/Commands.tsx | 55 +- src/components/HelpV2/General.tsx | 12 +- src/components/HelpV2/HelpV2.tsx | 84 +- src/components/HighlightedCode.tsx | 96 +- src/components/HighlightedCode/Fallback.tsx | 82 +- src/components/HistorySearchDialog.tsx | 165 +- src/components/IdeAutoConnectDialog.tsx | 76 +- src/components/IdeOnboardingDialog.tsx | 69 +- src/components/IdeStatusIndicator.tsx | 38 +- src/components/IdleReturnDialog.tsx | 48 +- src/components/InterruptedByUser.tsx | 6 +- src/components/InvalidConfigDialog.tsx | 77 +- src/components/InvalidSettingsDialog.tsx | 34 +- src/components/KeybindingWarnings.tsx | 18 +- src/components/LanguagePicker.tsx | 38 +- src/components/LogSelector.tsx | 1123 +- src/components/LogoV2/AnimatedAsterisk.tsx | 54 +- src/components/LogoV2/AnimatedClawd.tsx | 74 +- src/components/LogoV2/ChannelsNotice.tsx | 123 +- src/components/LogoV2/Clawd.tsx | 36 +- src/components/LogoV2/CondensedLogo.tsx | 141 +- src/components/LogoV2/EmergencyTip.tsx | 46 +- .../LogoV2/ExperimentEnrollmentNotice.tsx | 4 +- src/components/LogoV2/Feed.tsx | 80 +- src/components/LogoV2/FeedColumn.tsx | 33 +- .../LogoV2/GateOverridesWarning.tsx | 4 +- src/components/LogoV2/GuestPassesUpsell.tsx | 57 +- src/components/LogoV2/LogoV2.tsx | 335 +- src/components/LogoV2/Opus1mMergeNotice.tsx | 44 +- src/components/LogoV2/OverageCreditUpsell.tsx | 106 +- src/components/LogoV2/VoiceModeNotice.tsx | 40 +- src/components/LogoV2/WelcomeV2.tsx | 195 +- src/components/LogoV2/feedConfigs.tsx | 71 +- .../LspRecommendationMenu.tsx | 65 +- src/components/MCPServerApprovalDialog.tsx | 61 +- .../MCPServerDesktopImportDialog.tsx | 97 +- src/components/MCPServerDialogCopy.tsx | 9 +- src/components/MCPServerMultiselectDialog.tsx | 74 +- .../ManagedSettingsSecurityDialog.tsx | 70 +- src/components/Markdown.tsx | 137 +- src/components/MarkdownTable.tsx | 295 +- src/components/MemoryUsageIndicator.tsx | 24 +- src/components/Message.tsx | 307 +- src/components/MessageModel.tsx | 29 +- src/components/MessageResponse.tsx | 36 +- src/components/MessageRow.tsx | 269 +- src/components/MessageSelector.tsx | 670 +- src/components/MessageTimestamp.tsx | 38 +- src/components/Messages.tsx | 904 +- src/components/NativeAutoUpdater.tsx | 162 +- .../NotebookEditToolUseRejectedMessage.tsx | 36 +- src/components/OffscreenFreeze.tsx | 22 +- src/components/Onboarding.tsx | 173 +- src/components/OutputStylePicker.tsx | 73 +- src/components/PackageManagerAutoUpdater.tsx | 92 +- src/components/Passes/Passes.tsx | 166 +- src/components/PrBadge.tsx | 50 +- src/components/PressEnterToContinue.tsx | 6 +- .../PromptInput/HistorySearchInput.tsx | 28 +- .../PromptInput/IssueFlagBanner.tsx | 10 +- src/components/PromptInput/Notifications.tsx | 269 +- src/components/PromptInput/PromptInput.tsx | 2105 ++- .../PromptInput/PromptInputFooter.tsx | 61 +- .../PromptInput/PromptInputFooterLeftSide.tsx | 508 +- .../PromptInputFooterSuggestions.tsx | 192 +- .../PromptInput/PromptInputHelpMenu.tsx | 93 +- .../PromptInput/PromptInputModeIndicator.tsx | 80 +- .../PromptInput/PromptInputQueuedCommands.tsx | 116 +- .../PromptInput/PromptInputStashNotice.tsx | 18 +- .../PromptInput/SandboxPromptFooterHint.tsx | 55 +- src/components/PromptInput/ShimmeredInput.tsx | 79 +- src/components/PromptInput/VoiceIndicator.tsx | 53 +- src/components/QuickOpenDialog.tsx | 148 +- src/components/RemoteCallout.tsx | 74 +- src/components/RemoteEnvironmentDialog.tsx | 171 +- src/components/ResumeTask.tsx | 235 +- .../SandboxViolationExpandedView.tsx | 51 +- src/components/ScrollKeybindingHandler.tsx | 743 +- src/components/SearchBox.tsx | 2 +- src/components/SessionBackgroundHint.tsx | 86 +- src/components/SessionPreview.tsx | 83 +- src/components/Settings/Config.tsx | 13 +- src/components/Settings/Settings.tsx | 81 +- src/components/Settings/Status.tsx | 121 +- src/components/Settings/Usage.tsx | 236 +- src/components/ShowInIDEPrompt.tsx | 70 +- src/components/SkillImprovementSurvey.tsx | 66 +- src/components/Spinner.tsx | 441 +- src/components/Spinner/FlashingChar.tsx | 47 +- src/components/Spinner/GlimmerMessage.tsx | 104 +- src/components/Spinner/ShimmerChar.tsx | 36 +- .../Spinner/SpinnerAnimationRow.tsx | 249 +- src/components/Spinner/SpinnerGlyph.tsx | 66 +- .../Spinner/TeammateSpinnerLine.tsx | 240 +- .../Spinner/TeammateSpinnerTree.tsx | 88 +- src/components/Spinner/types.ts | 4 +- src/components/Stats.tsx | 822 +- src/components/StatusLine.tsx | 290 +- src/components/StatusNotices.tsx | 37 +- src/components/StructuredDiff.tsx | 134 +- src/components/StructuredDiff/Fallback.tsx | 375 +- src/components/StructuredDiffList.tsx | 35 +- src/components/TagTabs.tsx | 145 +- src/components/TaskListV2.tsx | 279 +- src/components/TeammateViewHeader.tsx | 20 +- src/components/TeleportError.tsx | 120 +- src/components/TeleportProgress.tsx | 86 +- src/components/TeleportRepoMismatchDialog.tsx | 74 +- src/components/TeleportResumeWrapper.tsx | 69 +- src/components/TeleportStash.tsx | 106 +- src/components/TextInput.tsx | 115 +- src/components/ThemePicker.tsx | 119 +- src/components/ThinkingToggle.tsx | 78 +- src/components/TokenWarning.tsx | 112 +- src/components/ToolUseLoader.tsx | 32 +- src/components/TrustDialog/TrustDialog.tsx | 159 +- src/components/ValidationErrorsList.tsx | 122 +- src/components/VimTextInput.tsx | 39 +- src/components/VirtualMessageList.tsx | 770 +- src/components/WorkflowMultiselectDialog.tsx | 68 +- src/components/WorktreeExitDialog.tsx | 242 +- src/components/agents/AgentDetail.tsx | 78 +- src/components/agents/AgentEditor.tsx | 189 +- .../agents/AgentNavigationFooter.tsx | 20 +- src/components/agents/AgentsList.tsx | 255 +- src/components/agents/AgentsMenu.tsx | 201 +- src/components/agents/ColorPicker.tsx | 84 +- src/components/agents/ModelSelector.tsx | 36 +- src/components/agents/ToolSelector.tsx | 373 +- src/components/agents/generateAgent.ts | 10 +- .../new-agent-creation/CreateAgentWizard.tsx | 65 +- .../agents/new-agent-creation/types.ts | 2 +- .../wizard-steps/ColorStep.tsx | 48 +- .../wizard-steps/ConfirmStep.tsx | 105 +- .../wizard-steps/ConfirmStepWrapper.tsx | 84 +- .../wizard-steps/DescriptionStep.tsx | 62 +- .../wizard-steps/GenerateStep.tsx | 147 +- .../wizard-steps/LocationStep.tsx | 33 +- .../wizard-steps/MemoryStep.tsx | 66 +- .../wizard-steps/MethodStep.tsx | 36 +- .../wizard-steps/ModelStep.tsx | 38 +- .../wizard-steps/PromptStep.tsx | 64 +- .../wizard-steps/ToolsStep.tsx | 47 +- .../wizard-steps/TypeStep.tsx | 60 +- src/components/design-system/Byline.tsx | 2 +- src/components/design-system/Dialog.tsx | 2 +- src/components/design-system/Divider.tsx | 2 +- src/components/design-system/FuzzyPicker.tsx | 2 +- .../design-system/KeyboardShortcutHint.tsx | 2 +- src/components/design-system/ListItem.tsx | 2 +- src/components/design-system/LoadingState.tsx | 2 +- src/components/design-system/Pane.tsx | 2 +- src/components/design-system/ProgressBar.tsx | 2 +- src/components/design-system/Ratchet.tsx | 2 +- src/components/design-system/StatusIcon.tsx | 2 +- src/components/design-system/Tabs.tsx | 2 +- .../design-system/ThemeProvider.tsx | 2 +- src/components/design-system/ThemedBox.tsx | 2 +- src/components/design-system/ThemedText.tsx | 2 +- src/components/diff/DiffDetailView.tsx | 56 +- src/components/diff/DiffDialog.tsx | 170 +- src/components/diff/DiffFileList.tsx | 102 +- src/components/grove/Grove.tsx | 212 +- src/components/hooks/HooksConfigMenu.tsx | 218 +- src/components/hooks/PromptDialog.tsx | 44 +- src/components/hooks/SelectEventMode.tsx | 80 +- src/components/hooks/SelectHookMode.tsx | 50 +- src/components/hooks/SelectMatcherMode.tsx | 73 +- src/components/hooks/ViewHookMode.tsx | 78 +- src/components/mcp/CapabilitiesSection.tsx | 28 +- src/components/mcp/ElicitationDialog.tsx | 1454 +- src/components/mcp/MCPAgentServerMenu.tsx | 153 +- src/components/mcp/MCPListPanel.tsx | 279 +- src/components/mcp/MCPReconnect.tsx | 94 +- src/components/mcp/MCPRemoteServerMenu.tsx | 582 +- src/components/mcp/MCPSettings.tsx | 165 +- src/components/mcp/MCPStdioServerMenu.tsx | 149 +- src/components/mcp/MCPToolDetailView.tsx | 106 +- src/components/mcp/MCPToolListView.tsx | 94 +- src/components/mcp/McpParsingWarnings.tsx | 74 +- src/components/mcp/types.ts | 14 +- src/components/mcp/utils/reconnectHelpers.tsx | 38 +- src/components/memory/MemoryFileSelector.tsx | 250 +- .../memory/MemoryUpdateNotification.tsx | 40 +- src/components/messageActions.tsx | 344 +- src/components/messages/AdvisorMessage.tsx | 73 +- .../AssistantRedactedThinkingMessage.tsx | 14 +- .../messages/AssistantTextMessage.tsx | 128 +- .../messages/AssistantThinkingMessage.tsx | 47 +- .../messages/AssistantToolUseMessage.tsx | 215 +- src/components/messages/AttachmentMessage.tsx | 371 +- .../messages/CollapsedReadSearchContent.tsx | 457 +- .../messages/CompactBoundaryMessage.tsx | 18 +- .../messages/GroupedToolUseContent.tsx | 60 +- .../messages/HighlightedThinkingText.tsx | 64 +- .../messages/HookProgressMessage.tsx | 49 +- .../messages/PlanApprovalMessage.tsx | 136 +- src/components/messages/RateLimitMessage.tsx | 111 +- src/components/messages/ShutdownMessage.tsx | 77 +- .../messages/SnipBoundaryMessage.ts | 5 +- .../messages/SystemAPIErrorMessage.tsx | 71 +- src/components/messages/SystemTextMessage.tsx | 348 +- .../messages/TaskAssignmentMessage.tsx | 39 +- .../messages/UserAgentNotificationMessage.tsx | 39 +- .../messages/UserBashInputMessage.tsx | 25 +- .../messages/UserBashOutputMessage.tsx | 24 +- .../messages/UserChannelMessage.tsx | 49 +- .../messages/UserCommandMessage.tsx | 44 +- .../messages/UserCrossSessionMessage.ts | 5 +- .../messages/UserForkBoilerplateMessage.ts | 6 +- .../messages/UserGitHubWebhookMessage.ts | 6 +- src/components/messages/UserImageMessage.tsx | 31 +- .../UserLocalCommandOutputMessage.tsx | 67 +- .../messages/UserMemoryInputMessage.tsx | 33 +- src/components/messages/UserPlanMessage.tsx | 27 +- src/components/messages/UserPromptMessage.tsx | 101 +- .../messages/UserResourceUpdateMessage.tsx | 68 +- .../messages/UserTeammateMessage.tsx | 141 +- src/components/messages/UserTextMessage.tsx | 139 +- .../RejectedPlanMessage.tsx | 14 +- .../RejectedToolUseMessage.tsx | 8 +- .../UserToolCanceledMessage.tsx | 8 +- .../UserToolErrorMessage.tsx | 90 +- .../UserToolRejectMessage.tsx | 52 +- .../UserToolResultMessage.tsx | 69 +- .../UserToolSuccessMessage.tsx | 119 +- .../messages/UserToolResultMessage/utils.tsx | 20 +- .../messages/nullRenderingAttachments.ts | 4 +- src/components/messages/teamMemCollapsed.tsx | 74 +- .../AskUserQuestionPermissionRequest.tsx | 470 +- .../PreviewBox.tsx | 112 +- .../PreviewQuestionView.tsx | 328 +- .../QuestionNavigationBar.tsx | 119 +- .../QuestionView.tsx | 270 +- .../SubmitQuestionsView.tsx | 70 +- .../BashPermissionRequest.tsx | 390 +- .../bashToolUseOptions.tsx | 105 +- .../ComputerUseApproval.tsx | 193 +- .../EnterPlanModePermissionRequest.tsx | 58 +- .../ExitPlanModePermissionRequest.tsx | 727 +- .../permissions/FallbackPermissionRequest.tsx | 121 +- .../FileEditPermissionRequest.tsx | 58 +- .../FilePermissionDialog.tsx | 178 +- .../permissionOptions.tsx | 131 +- .../FileWritePermissionRequest.tsx | 75 +- .../FileWriteToolDiff.tsx | 52 +- .../FilesystemPermissionRequest.tsx | 52 +- .../MonitorPermissionRequest.tsx | 115 +- .../NotebookEditPermissionRequest.tsx | 51 +- .../NotebookEditToolDiff.tsx | 122 +- .../PermissionDecisionDebugInfo.tsx | 223 +- .../permissions/PermissionDialog.tsx | 37 +- .../permissions/PermissionExplanation.tsx | 107 +- .../permissions/PermissionPrompt.tsx | 200 +- .../permissions/PermissionRequest.tsx | 207 +- .../permissions/PermissionRequestTitle.tsx | 27 +- .../permissions/PermissionRuleExplanation.tsx | 83 +- .../PowerShellPermissionRequest.tsx | 213 +- .../powershellToolUseOptions.tsx | 62 +- .../ReviewArtifactPermissionRequest.tsx | 57 +- .../permissions/SandboxPermissionRequest.tsx | 54 +- .../SedEditPermissionRequest.tsx | 90 +- .../SkillPermissionRequest.tsx | 156 +- .../WebFetchPermissionRequest.tsx | 114 +- src/components/permissions/WorkerBadge.tsx | 23 +- .../permissions/WorkerPendingPermission.tsx | 40 +- .../permissions/rules/AddPermissionRules.tsx | 119 +- .../rules/AddWorkspaceDirectory.tsx | 196 +- .../rules/PermissionRuleDescription.tsx | 29 +- .../permissions/rules/PermissionRuleInput.tsx | 85 +- .../permissions/rules/PermissionRuleList.tsx | 564 +- .../permissions/rules/RecentDenialsTab.tsx | 105 +- .../rules/RemoveWorkspaceDirectory.tsx | 52 +- .../permissions/rules/WorkspaceTab.tsx | 80 +- .../permissions/shellPermissionHelpers.tsx | 123 +- src/components/sandbox/SandboxConfigTab.tsx | 75 +- .../sandbox/SandboxDependenciesTab.tsx | 76 +- .../sandbox/SandboxDoctorSection.tsx | 30 +- .../sandbox/SandboxOverridesTab.tsx | 90 +- src/components/sandbox/SandboxSettings.tsx | 131 +- .../shell/ExpandShellOutputContext.tsx | 20 +- src/components/shell/OutputLine.tsx | 82 +- src/components/shell/ShellProgressMessage.tsx | 72 +- src/components/shell/ShellTimeDisplay.tsx | 31 +- src/components/skills/SkillsMenu.tsx | 172 +- .../tasks/AsyncAgentDetailDialog.tsx | 124 +- src/components/tasks/BackgroundTask.tsx | 95 +- src/components/tasks/BackgroundTaskStatus.tsx | 232 +- .../tasks/BackgroundTasksDialog.tsx | 709 +- src/components/tasks/DreamDetailDialog.tsx | 90 +- .../tasks/InProcessTeammateDetailDialog.tsx | 125 +- .../tasks/MonitorMcpDetailDialog.tsx | 64 +- .../tasks/RemoteSessionDetailDialog.tsx | 448 +- .../tasks/RemoteSessionProgress.tsx | 124 +- src/components/tasks/ShellDetailDialog.tsx | 193 +- src/components/tasks/ShellProgress.tsx | 52 +- src/components/tasks/WorkflowDetailDialog.tsx | 64 +- src/components/tasks/renderToolActivity.tsx | 38 +- .../tasks/src/tasks/DreamTask/DreamTask.ts | 15 +- .../LocalWorkflowTask/LocalWorkflowTask.ts | 2 +- src/components/tasks/src/types/utils.ts | 2 +- src/components/tasks/taskStatusUtils.tsx | 107 +- src/components/teams/TeamStatus.tsx | 38 +- src/components/ui/OrderedList.tsx | 45 +- src/components/ui/OrderedListItem.tsx | 18 +- src/components/ui/TreeSelect.tsx | 216 +- src/components/ui/option.ts | 2 +- .../ultraplan/UltraplanChoiceDialog.tsx | 33 +- .../ultraplan/UltraplanLaunchDialog.tsx | 44 +- src/components/wizard/WizardDialogLayout.tsx | 37 +- .../wizard/WizardNavigationFooter.tsx | 31 +- src/components/wizard/WizardProvider.tsx | 88 +- src/components/wizard/types.ts | 6 +- src/constants/betas.ts | 2 +- src/constants/querySource.ts | 2 +- src/constants/tools.ts | 4 +- src/context/QueuedMessageContext.tsx | 43 +- src/context/fpsMetrics.tsx | 27 +- src/context/mailbox.tsx | 24 +- src/context/modalContext.tsx | 27 +- src/context/notifications.tsx | 201 +- src/context/overlayContext.tsx | 52 +- src/context/promptOverlayContext.tsx | 68 +- src/context/stats.tsx | 171 +- src/context/voice.tsx | 55 +- src/dialogLaunchers.tsx | 124 +- src/entrypoints/agentSdkTypes.ts | 4 +- src/entrypoints/cli.tsx | 398 +- src/entrypoints/sdk/controlSchemas.ts | 3 - src/entrypoints/sdk/controlTypes.js | 14 +- src/entrypoints/sdk/controlTypes.ts | 40 +- src/entrypoints/sdk/coreSchemas.ts | 11 +- src/entrypoints/sdk/coreTypes.generated.ts | 130 +- src/entrypoints/sdk/runtimeTypes.js | 2 +- src/environment-runner/main.ts | 5 +- .../replBridgePermissionHandlers.test.ts | 17 +- .../__tests__/swarmPermissionPoller.test.ts | 10 +- .../useCanSwitchToExistingSubscription.tsx | 42 +- .../useDeprecationWarningNotification.tsx | 24 +- src/hooks/notifs/useFastModeNotification.tsx | 80 +- src/hooks/notifs/useIDEStatusIndicator.tsx | 142 +- src/hooks/notifs/useInstallMessages.tsx | 18 +- .../useLspInitializationNotification.tsx | 93 +- src/hooks/notifs/useMcpConnectivityStatus.tsx | 71 +- .../notifs/useModelMigrationNotifications.tsx | 34 +- .../notifs/useNpmDeprecationNotification.tsx | 26 +- .../usePluginAutoupdateNotification.tsx | 58 +- .../notifs/usePluginInstallationStatus.tsx | 84 +- .../useRateLimitWarningNotification.tsx | 71 +- src/hooks/notifs/useSettingsErrors.tsx | 42 +- src/hooks/toolPermission/PermissionContext.ts | 3 +- .../handlers/interactiveHandler.ts | 8 +- src/hooks/useArrowKeyHistory.tsx | 247 +- src/hooks/useBlink.ts | 6 +- src/hooks/useCanUseTool.tsx | 262 +- src/hooks/useChromeExtensionNotification.tsx | 49 +- src/hooks/useClaudeCodeHintRecommendation.tsx | 112 +- src/hooks/useCommandKeybindings.tsx | 58 +- src/hooks/useDirectConnect.ts | 5 +- src/hooks/useGlobalKeybindings.tsx | 174 +- src/hooks/useIDEIntegration.tsx | 52 +- src/hooks/useLspPluginRecommendation.tsx | 190 +- src/hooks/useManagePlugins.ts | 15 +- src/hooks/useMasterMonitor.ts | 22 +- .../useOfficialMarketplaceNotification.tsx | 51 +- src/hooks/usePipeIpc.ts | 2 +- src/hooks/usePipeMuteSync.ts | 16 +- src/hooks/usePipeRelay.ts | 25 +- src/hooks/usePluginRecommendationBase.tsx | 63 +- src/hooks/usePromptsFromClaudeInChrome.tsx | 137 +- src/hooks/useRemoteSession.ts | 19 +- src/hooks/useReplBridge.tsx | 845 +- src/hooks/useSSHSession.ts | 9 +- src/hooks/useScheduledTasks.ts | 14 +- src/hooks/useTeleportResume.tsx | 80 +- src/hooks/useTypeahead.tsx | 7 +- src/hooks/useVoice.ts | 14 +- src/hooks/useVoiceIntegration.tsx | 520 +- src/keybindings/KeybindingContext.tsx | 2 +- src/keybindings/KeybindingProviderSetup.tsx | 48 +- .../confirmation-keybindings.test.ts | 14 +- src/main.tsx | 12153 +++++++--------- src/memdir/memoryShapeTelemetry.ts | 18 +- src/moreright/useMoreRight.tsx | 20 +- src/native-ts/file-index/index.ts | 7 +- src/query/stopHooks.ts | 7 +- src/remote/sdkMessageAdapter.ts | 24 +- src/replLauncher.tsx | 26 +- src/screens/Doctor.tsx | 286 +- src/screens/ResumeConversation.tsx | 384 +- src/self-hosted-runner/main.ts | 5 +- src/server/backends/dangerousBackend.ts | 6 +- src/server/connectHeadless.ts | 5 +- src/server/lockfile.ts | 8 +- src/server/parseConnectUrl.ts | 8 +- src/server/server.ts | 7 +- src/server/serverBanner.ts | 4 +- src/server/serverLog.ts | 4 +- src/server/sessionManager.ts | 8 +- .../__tests__/agentSummary.test.ts | 4 +- .../__tests__/summaryContext.test.ts | 6 +- src/services/AgentSummary/agentSummary.ts | 6 +- src/services/MagicDocs/prompts.ts | 4 +- .../PromptSuggestion/promptSuggestion.ts | 10 +- src/services/PromptSuggestion/speculation.ts | 7 +- src/services/SessionMemory/prompts.ts | 4 +- src/services/acp/__tests__/agent.test.ts | 32 +- src/services/acp/__tests__/bridge.test.ts | 12 +- .../acp/__tests__/permissions.test.ts | 43 +- src/services/acp/agent.ts | 405 +- src/services/acp/bridge.ts | 195 +- src/services/acp/entry.ts | 7 +- src/services/acp/permissions.ts | 51 +- src/services/analytics/datadog.ts | 6 +- .../firstPartyEventLoggingExporter.ts | 4 +- src/services/analytics/metadata.ts | 1 - src/services/api/claude.ts | 60 +- src/services/api/gemini/index.ts | 19 +- .../api/grok/__tests__/client.test.ts | 2 +- src/services/api/grok/index.ts | 50 +- src/services/api/logging.ts | 18 +- .../api/openai/__tests__/thinking.test.ts | 31 +- src/services/api/openai/client.ts | 8 +- src/services/api/openai/index.ts | 108 +- src/services/api/openai/requestBody.ts | 35 +- src/services/api/promptCacheBreakDetection.ts | 3 +- src/services/api/withRetry.ts | 5 +- .../compact/__tests__/grouping.test.ts | 178 +- src/services/compact/__tests__/prompt.test.ts | 129 +- .../compact/__tests__/snipCompact.test.ts | 16 +- .../compact/__tests__/snipProjection.test.ts | 16 +- src/services/compact/cachedMCConfig.ts | 9 +- src/services/compact/compact.ts | 22 +- src/services/compact/microCompact.ts | 4 +- src/services/compact/reactiveCompact.ts | 31 +- src/services/compact/sessionMemoryCompact.ts | 4 +- src/services/compact/snipProjection.ts | 2 +- src/services/contextCollapse/operations.ts | 7 +- src/services/contextCollapse/persist.ts | 4 +- src/services/doubaoSTT.ts | 48 +- .../extractMemories/extractMemories.ts | 4 +- .../langfuse/__tests__/langfuse.test.ts | 456 +- src/services/langfuse/client.ts | 6 +- src/services/langfuse/convert.ts | 92 +- src/services/langfuse/index.ts | 24 +- src/services/langfuse/sanitize.ts | 9 +- src/services/langfuse/tracing.ts | 168 +- .../lsp/__tests__/closeAllFiles.test.ts | 2 +- src/services/lsp/types.ts | 6 +- src/services/mcp/MCPConnectionManager.tsx | 71 +- .../mcp/__tests__/channelNotification.test.ts | 64 +- .../mcp/__tests__/channelPermissions.test.ts | 272 +- .../mcp/__tests__/envExpansion.test.ts | 218 +- .../mcp/__tests__/filterUtils.test.ts | 68 +- .../mcp/__tests__/mcpStringUtils.test.ts | 192 +- .../mcp/__tests__/normalization.test.ts | 78 +- .../mcp/__tests__/officialRegistry.test.ts | 60 +- src/services/mcp/adapter/analytics.ts | 8 +- src/services/mcp/adapter/imageProcessor.ts | 6 +- src/services/mcp/adapter/storage.ts | 11 +- src/services/mcp/channelAllowlist.ts | 2 +- src/services/mcp/channelNotification.ts | 5 +- src/services/mcp/client.ts | 34 +- src/services/mcp/config.ts | 2 +- src/services/mcp/oauthPort.ts | 3 +- src/services/mcp/useManageMCPConnections.ts | 8 +- src/services/mcpServerApproval.tsx | 37 +- src/services/notifier.ts | 4 +- src/services/oauth/types.ts | 22 +- .../remoteManagedSettings/securityCheck.jsx | 8 +- .../remoteManagedSettings/securityCheck.tsx | 65 +- .../sessionTranscript/sessionTranscript.ts | 12 +- .../__tests__/projectContext.test.ts | 9 +- src/services/skillSearch/prefetch.ts | 6 +- src/services/skillSearch/remoteSkillLoader.ts | 21 +- src/services/skillSearch/remoteSkillState.ts | 10 +- src/services/skillSearch/telemetry.ts | 16 +- src/services/tips/types.ts | 4 +- src/services/tokenEstimation.ts | 11 +- .../toolUseSummary/toolUseSummaryGenerator.ts | 4 +- src/services/tools/StreamingToolExecutor.ts | 5 +- .../__tests__/StreamingToolExecutor.test.ts | 16 +- src/services/tools/toolHooks.ts | 21 +- src/services/tools/toolOrchestration.ts | 33 +- src/services/vcr.ts | 5 +- src/state/AppState.tsx | 137 +- src/state/__tests__/store.test.ts | 205 +- src/tasks/LocalAgentTask/LocalAgentTask.tsx | 538 +- .../__tests__/LocalAgentTask.test.ts | 722 +- src/tasks/LocalMainSessionTask.ts | 24 +- src/tasks/LocalShellTask/LocalShellTask.tsx | 464 +- .../LocalWorkflowTask/LocalWorkflowTask.ts | 7 +- src/tasks/MonitorMcpTask/MonitorMcpTask.ts | 5 +- src/tasks/RemoteAgentTask/RemoteAgentTask.tsx | 825 +- src/tools.ts | 45 +- src/types/connectorText.ts | 21 +- src/types/fileSuggestion.ts | 2 +- src/types/global.d.ts | 17 +- src/types/ink-elements.d.ts | 67 +- src/types/ink-jsx.d.ts | 65 +- src/types/internal-modules.d.ts | 33 +- src/types/message.ts | 9 +- src/types/messageQueueTypes.ts | 2 +- src/types/notebook.ts | 14 +- src/types/sdk-stubs.d.ts | 73 +- src/types/statusLine.ts | 2 +- src/types/tools.ts | 22 +- src/types/utils.ts | 4 +- src/utils/__tests__/CircularBuffer.test.ts | 178 +- src/utils/__tests__/abortController.test.ts | 178 +- .../__tests__/argumentSubstitution.test.ts | 204 +- src/utils/__tests__/array.test.ts | 92 +- src/utils/__tests__/autonomyRuns.test.ts | 4 +- src/utils/__tests__/bufferedWriter.test.ts | 178 +- src/utils/__tests__/bunHashPolyfill.test.ts | 4 +- src/utils/__tests__/claudemd.test.ts | 220 +- .../__tests__/collapseHookSummaries.test.ts | 206 +- .../collapseTeammateShutdowns.test.ts | 157 +- src/utils/__tests__/configConstants.test.ts | 104 +- src/utils/__tests__/contentArray.test.ts | 118 +- .../__tests__/controlMessageCompat.test.ts | 160 +- src/utils/__tests__/cron.test.ts | 428 +- .../__tests__/cronScheduler.baseline.test.ts | 16 +- .../__tests__/cronTasks.baseline.test.ts | 27 +- src/utils/__tests__/detectRepository.test.ts | 174 +- src/utils/__tests__/diff.test.ts | 191 +- .../__tests__/directMemberMessage.test.ts | 161 +- src/utils/__tests__/displayTags.test.ts | 186 +- src/utils/__tests__/effort.test.ts | 3 +- src/utils/__tests__/envUtils.test.ts | 441 +- src/utils/__tests__/envValidation.test.ts | 150 +- src/utils/__tests__/errors.test.ts | 422 +- src/utils/__tests__/file.test.ts | 138 +- src/utils/__tests__/fileStateCache.test.ts | 7 +- src/utils/__tests__/fingerprint.test.ts | 185 +- src/utils/__tests__/format.test.ts | 400 +- .../__tests__/formatBriefTimestamp.test.ts | 124 +- src/utils/__tests__/frontmatterParser.test.ts | 233 +- src/utils/__tests__/generators.test.ts | 174 +- src/utils/__tests__/git.test.ts | 204 +- src/utils/__tests__/gitDiff.test.ts | 398 +- src/utils/__tests__/glob.test.ts | 68 +- src/utils/__tests__/groupToolUses.test.ts | 196 +- src/utils/__tests__/hash.test.ts | 118 +- src/utils/__tests__/horizontalScroll.test.ts | 226 +- src/utils/__tests__/hyperlink.test.ts | 126 +- src/utils/__tests__/imageResizer.test.ts | 28 +- src/utils/__tests__/json.test.ts | 214 +- src/utils/__tests__/lanBeacon.test.ts | 13 +- src/utils/__tests__/lazySchema.test.ts | 90 +- src/utils/__tests__/markdown.test.ts | 96 +- src/utils/__tests__/memoize.test.ts | 308 +- .../__tests__/messageQueueManager.test.ts | 296 +- src/utils/__tests__/messages.test.ts | 712 +- src/utils/__tests__/modelCost.test.ts | 40 +- src/utils/__tests__/notebook.test.ts | 220 +- src/utils/__tests__/objectGroupBy.test.ts | 92 +- src/utils/__tests__/path.test.ts | 322 +- src/utils/__tests__/privacyLevel.test.ts | 143 +- src/utils/__tests__/queueProcessor.test.ts | 254 +- src/utils/__tests__/sanitization.test.ts | 106 +- src/utils/__tests__/semanticBoolean.test.ts | 68 +- src/utils/__tests__/semanticNumber.test.ts | 74 +- src/utils/__tests__/semver.test.ts | 178 +- src/utils/__tests__/sequential.test.ts | 156 +- src/utils/__tests__/set.test.ts | 102 +- .../__tests__/slashCommandParsing.test.ts | 84 +- src/utils/__tests__/sleep.test.ts | 198 +- src/utils/__tests__/sliceAnsi.test.ts | 132 +- src/utils/__tests__/stream.test.ts | 246 +- src/utils/__tests__/stringUtils.test.ts | 298 +- src/utils/__tests__/systemPrompt.test.ts | 116 +- src/utils/__tests__/taggedId.test.ts | 145 +- src/utils/__tests__/taskStateMessage.test.ts | 105 +- src/utils/__tests__/teammateMailbox.test.ts | 16 +- src/utils/__tests__/textHighlighting.test.ts | 184 +- src/utils/__tests__/tokenBudget.test.ts | 220 +- src/utils/__tests__/tokens.test.ts | 262 +- src/utils/__tests__/treeify.test.ts | 162 +- src/utils/__tests__/truncate.test.ts | 268 +- src/utils/__tests__/udsMessaging.test.ts | 4 +- src/utils/__tests__/udsResponseReader.test.ts | 4 +- .../__tests__/userPromptKeywords.test.ts | 86 +- src/utils/__tests__/uuid.test.ts | 84 +- src/utils/__tests__/windowsPaths.test.ts | 167 +- src/utils/__tests__/withResolvers.test.ts | 100 +- src/utils/__tests__/words.test.ts | 110 +- src/utils/__tests__/xdg.test.ts | 130 +- src/utils/__tests__/xml.test.ts | 66 +- src/utils/__tests__/zodToJsonSchema.test.ts | 116 +- src/utils/analyzeContext.ts | 30 +- src/utils/attachments.ts | 46 +- src/utils/attributionHooks.ts | 6 +- src/utils/attributionTrailer.ts | 7 +- src/utils/autoRunIssue.tsx | 56 +- src/utils/betas.ts | 5 +- src/utils/ccshareResume.ts | 9 +- src/utils/claudeInChrome/toolRendering.tsx | 233 +- src/utils/claudemd.ts | 5 +- src/utils/cliHighlight.ts | 9 +- .../collapseBackgroundBashNotifications.ts | 7 +- src/utils/collapseReadSearch.ts | 74 +- src/utils/computerUse/escHotkey.ts | 4 +- src/utils/computerUse/executor.ts | 57 +- src/utils/computerUse/hostAdapter.ts | 6 +- src/utils/computerUse/platforms/darwin.ts | 10 +- src/utils/computerUse/platforms/index.ts | 25 +- src/utils/computerUse/platforms/linux.ts | 171 +- src/utils/computerUse/platforms/win32.ts | 7 +- src/utils/computerUse/toolRendering.tsx | 99 +- src/utils/computerUse/win32/bridgeClient.ts | 2 +- src/utils/computerUse/win32/comExcel.ts | 16 +- src/utils/computerUse/win32/comWord.ts | 18 +- src/utils/computerUse/wrapper.tsx | 223 +- src/utils/conversationRecovery.ts | 24 +- src/utils/debug.ts | 4 +- src/utils/earlyInput.ts | 40 +- src/utils/eventLoopStallDetector.ts | 4 +- src/utils/exportRenderer.tsx | 75 +- src/utils/filePersistence/filePersistence.ts | 3 +- src/utils/filePersistence/outputsScanner.ts | 4 +- src/utils/filePersistence/types.ts | 2 +- src/utils/forkedAgent.ts | 9 +- .../git/__tests__/gitConfigParser.test.ts | 224 +- src/utils/gracefulShutdown.ts | 22 +- src/utils/groupToolUses.ts | 46 +- src/utils/highlightMatch.tsx | 30 +- src/utils/hooks.ts | 30 +- src/utils/hooks/apiQueryHookHelper.ts | 4 +- src/utils/hooks/execAgentHook.ts | 4 +- src/utils/hooks/execPromptHook.ts | 4 +- src/utils/hooks/hooksConfigManager.ts | 2 +- src/utils/imagePaste.ts | 11 +- src/utils/jetbrains.ts | 3 +- src/utils/log.ts | 2 +- src/utils/mcp/dateTimeParser.ts | 4 +- src/utils/messages.ts | 286 +- src/utils/messages/mappers.ts | 33 +- src/utils/model/__tests__/aliases.test.ts | 82 +- src/utils/model/__tests__/model.test.ts | 156 +- src/utils/model/model.ts | 5 +- src/utils/model/validateModel.ts | 1 - src/utils/nativeInstaller/installer.ts | 5 +- src/utils/path.ts | 10 +- .../__tests__/PermissionMode.test.ts | 268 +- .../__tests__/dangerousPatterns.test.ts | 132 +- .../__tests__/getNextPermissionMode.test.ts | 8 +- .../__tests__/permissionRuleParser.test.ts | 228 +- .../__tests__/permissionSetup.test.ts | 15 +- .../permissions/__tests__/permissions.test.ts | 13 +- .../__tests__/shellRuleMatching.test.ts | 204 +- .../bypassPermissionsKillswitch.ts | 26 +- src/utils/permissions/filesystem.ts | 6 +- src/utils/permissions/pathValidation.ts | 14 +- src/utils/permissions/permissionExplainer.ts | 4 +- src/utils/permissions/permissions.ts | 1 - src/utils/permissions/yoloClassifier.ts | 7 +- src/utils/pipeMuteState.ts | 4 +- src/utils/pipePermissionRelay.ts | 4 +- src/utils/plans.ts | 2 +- src/utils/plugins/loadPluginHooks.ts | 2 +- src/utils/plugins/mcpbHandler.ts | 4 +- src/utils/plugins/performStartupChecks.tsx | 49 +- .../plugins/pluginInstallationHelpers.ts | 6 +- src/utils/plugins/pluginLoader.ts | 8 +- src/utils/plugins/refresh.ts | 12 +- src/utils/postCommitAttribution.ts | 7 +- src/utils/preflightChecks.tsx | 44 +- .../processUserInput/processBashCommand.tsx | 113 +- .../processUserInput/processUserInput.ts | 4 +- src/utils/protectedNamespace.ts | 4 +- src/utils/queryHelpers.ts | 38 +- src/utils/queueProcessor.ts | 5 +- src/utils/sdkHeapDumpMonitor.ts | 4 +- src/utils/secureStorage/types.ts | 4 +- src/utils/sentry.ts | 15 +- src/utils/sessionDataUploader.ts | 4 +- src/utils/sessionFileAccessHooks.ts | 5 +- src/utils/sessionState.ts | 8 +- src/utils/sessionStorage.ts | 20 +- src/utils/sessionStoragePortable.ts | 3 +- src/utils/sessionTitle.ts | 7 +- src/utils/settings/__tests__/config.test.ts | 661 +- src/utils/settings/types.ts | 5 +- .../shell/__tests__/outputLimits.test.ts | 76 +- src/utils/sideQuery.ts | 57 +- src/utils/sideQuestion.ts | 7 +- src/utils/sinks.ts | 1 - src/utils/sliceAnsi.ts | 8 +- src/utils/slowOperations.ts | 7 +- src/utils/staticRender.tsx | 72 +- src/utils/status.tsx | 324 +- src/utils/statusNoticeDefinitions.tsx | 183 +- src/utils/streamlinedTransform.ts | 8 +- .../__tests__/commandSuggestions.test.ts | 22 +- src/utils/swarm/It2SetupPrompt.tsx | 215 +- src/utils/swarm/inProcessRunner.ts | 9 +- src/utils/systemThemeWatcher.ts | 9 +- src/utils/task/__tests__/framework.test.ts | 262 +- src/utils/taskStateMessage.ts | 5 +- src/utils/teleport.tsx | 1009 +- src/utils/teleport/gitBundle.ts | 11 +- src/utils/tokens.ts | 28 +- src/utils/toolSearch.ts | 4 +- src/utils/transcriptSearch.ts | 10 +- src/utils/udsMessaging.ts | 9 +- src/utils/ultraplan/prompt.ts | 33 +- tests/integration/context-build.test.ts | 160 +- tests/integration/message-pipeline.test.ts | 86 +- tests/integration/tool-chain.test.ts | 192 +- tests/mocks/api-responses.ts | 34 +- tests/mocks/debug.ts | 4 +- tests/mocks/fixtures/sample-messages.json | 4 +- tests/mocks/log.ts | 4 +- tsconfig.json | 61 +- vendor/audio-capture-src/index.ts | 6 +- vite.config.ts | 111 +- 1333 files changed, 68255 insertions(+), 77882 deletions(-) create mode 100644 .husky/pre-commit diff --git a/.editorconfig b/.editorconfig index b34154ea8..215191533 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,8 +1,8 @@ root = true [*] -indent_style = tab -indent_size = 4 +indent_style = space +indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46e80411..cb415c0a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1" run: bun install --frozen-lockfile + - name: Lint and format check + run: bunx biome ci . + - name: Type check run: bun run typecheck diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..ea5a55b6f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +bunx lint-staged diff --git a/CLAUDE.md b/CLAUDE.md index 5781152f2..d12de3545 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,9 +48,11 @@ bun test src/utils/__tests__/hash.test.ts # run single file bun test --coverage # with coverage report # Lint & Format (Biome) -bun run lint # check only -bun run lint:fix # auto-fix -bun run format # format all src/ +bun run lint # lint check (全项目) +bun run lint:fix # auto-fix lint issues +bun run format # format all (全项目) +bun run check # lint + format check (全项目) +bun run check:fix # lint + format auto-fix # Health check bun run health @@ -82,9 +84,11 @@ bun run docs:dev - **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。 - **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. - **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。 -- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。 +- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(`packages/@ant/` 除外,为 forked 代码)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。 +- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。 +- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。 - **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。 -- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。 +- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。 ### Entry & Bootstrap @@ -328,7 +332,7 @@ bun run typecheck - **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid. - **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。 - **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。 -- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 +- **Biome 配置** — 42 条 lint 规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/`、`scripts/`、`packages/`),`packages/@ant/` 除外(forked 代码)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐(2-space 缩进)。修改任何代码后应运行 `bun run check` 确认无 lint/格式问题,pre-commit hook 会自动拦截不合格提交。 - **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。 - **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。 diff --git a/biome.json b/biome.json index debb5e3e4..237d5e8de 100644 --- a/biome.json +++ b/biome.json @@ -1,114 +1,113 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "includes": ["**", "!!**/dist", "!!**/packages/@ant"] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 80 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "off", - "noAssignInExpressions": "off", - "noDoubleEquals": "off", - "noRedeclare": "off", - "noImplicitAnyLet": "off", - "noGlobalIsNan": "off", - "noFallthroughSwitchClause": "off", - "noShadowRestrictedNames": "off", - "noArrayIndexKey": "off", - "noConsole": "off", - "noConfusingLabels": "off", - "useIterableCallbackReturn": "off" - }, - "style": { - "useConst": "off", - "noNonNullAssertion": "off", - "noParameterAssign": "off", - "useDefaultParameterLast": "off", - "noUnusedTemplateLiteral": "off", - "useTemplate": "off", - "useNumberNamespace": "off", - "useNodejsImportProtocol": "off", - "useImportType": "off" - }, - "complexity": { - "noForEach": "off", - "noBannedTypes": "off", - "noUselessConstructor": "off", - "noStaticOnlyClass": "off", - "useOptionalChain": "off", - "noUselessSwitchCase": "off", - "noUselessFragments": "off", - "noUselessTernary": "off", - "noUselessLoneBlockStatements": "off", - "noUselessEmptyExport": "off", - "useArrowFunction": "off", - "useLiteralKeys": "off" - }, - "correctness": { - "noUnusedVariables": "off", - "noUnusedImports": "off", - "useExhaustiveDependencies": "off", - "noSwitchDeclarations": "off", - "noUnreachable": "off", - "useHookAtTopLevel": "off", - "noVoidTypeReturn": "off", - "noConstantCondition": "off", - "noUnusedFunctionParameters": "off" - }, - "a11y": { - "recommended": false - }, - "nursery": { - "recommended": false - } - } - }, - "json": { - "formatter": { - "enabled": false - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "semicolons": "asNeeded", - "arrowParentheses": "asNeeded", - "trailingCommas": "all" - } - }, - "overrides": [ - { - "includes": ["**/*.tsx"], - "javascript": { - "formatter": { - "semicolons": "always" - } - }, - "formatter": { - "lineWidth": 120 - } - }, - { - "includes": ["scripts/**", "packages/**", "**/*.js", "**/*.mjs", "**/*.jsx"], - "formatter": { - "enabled": false - } - } - ], - "assist": { - "enabled": false - } + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist", "!!**/packages/@ant"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noAssignInExpressions": "off", + "noDoubleEquals": "off", + "noRedeclare": "off", + "noImplicitAnyLet": "off", + "noGlobalIsNan": "off", + "noFallthroughSwitchClause": "off", + "noShadowRestrictedNames": "off", + "noArrayIndexKey": "off", + "noConsole": "off", + "noConfusingLabels": "off", + "useIterableCallbackReturn": "off" + }, + "style": { + "useConst": "off", + "noNonNullAssertion": "off", + "noParameterAssign": "off", + "useDefaultParameterLast": "off", + "noUnusedTemplateLiteral": "off", + "useTemplate": "off", + "useNumberNamespace": "off", + "useNodejsImportProtocol": "off", + "useImportType": "off" + }, + "complexity": { + "noForEach": "off", + "noBannedTypes": "off", + "noUselessConstructor": "off", + "noStaticOnlyClass": "off", + "useOptionalChain": "off", + "noUselessSwitchCase": "off", + "noUselessFragments": "off", + "noUselessTernary": "off", + "noUselessLoneBlockStatements": "off", + "noUselessEmptyExport": "off", + "useArrowFunction": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "off", + "noUnusedImports": "off", + "useExhaustiveDependencies": "off", + "noSwitchDeclarations": "off", + "noUnreachable": "off", + "useHookAtTopLevel": "off", + "noVoidTypeReturn": "off", + "noConstantCondition": "off", + "noUnusedFunctionParameters": "off" + }, + "a11y": { + "recommended": false + }, + "nursery": { + "recommended": false + } + } + }, + "json": { + "formatter": { + "enabled": true + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded", + "arrowParentheses": "asNeeded", + "trailingCommas": "all" + } + }, + "overrides": [ + { + "includes": ["**/*.tsx"], + "javascript": { + "formatter": { + "semicolons": "always" + } + }, + "formatter": { + "lineWidth": 120 + } + } + ], + "assist": { + "enabled": false + } } diff --git a/build.ts b/build.ts index c54d09260..4854fd51c 100644 --- a/build.ts +++ b/build.ts @@ -56,7 +56,8 @@ for (const file of files) { // (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time. let bunPatched = 0 const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g -const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};' +const BUN_DESTRUCTURE_SAFE = + 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};' for (const file of files) { if (!file.endsWith('.js')) continue const filePath = join(outdir, file) diff --git a/bun.lock b/bun.lock index f52841366..ec8994605 100644 --- a/bun.lock +++ b/bun.lock @@ -103,11 +103,13 @@ "google-auth-library": "^10.6.2", "he": "^1.2.0", "https-proxy-agent": "^8.0.0", + "husky": "^9.1.7", "ignore": "^7.0.5", "image-processor-napi": "workspace:*", "indent-string": "^5.0.0", "jsonc-parser": "^3.3.1", "knip": "^6.4.1", + "lint-staged": "^16.4.0", "lodash-es": "^4.18.1", "lru-cache": "^11.3.5", "marked": "^17.0.6", @@ -1538,6 +1540,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -1632,8 +1636,12 @@ "cli-boxes": ["cli-boxes@4.0.1", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-highlight": ["cli-highlight@2.1.11", "https://registry.npmmirror.com/cli-highlight/-/cli-highlight-2.1.11.tgz", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + "cli-width": ["cli-width@4.1.0", "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "cliui": ["cliui@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], @@ -1820,6 +1828,8 @@ "env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1840,6 +1850,8 @@ "etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -2014,6 +2026,8 @@ "human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -2140,6 +2154,10 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], + + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + "locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], @@ -2162,6 +2180,8 @@ "lodash.once": ["lodash.once@4.1.1", "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -2292,6 +2312,8 @@ "mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2540,10 +2562,14 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], @@ -2604,6 +2630,8 @@ "simple-swizzle": ["simple-swizzle@0.2.4", "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], "sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -2622,6 +2650,8 @@ "streamdown": ["streamdown@1.6.11", "https://registry.npmmirror.com/streamdown/-/streamdown-1.6.11.tgz", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string-width": ["string-width@8.2.0", "https://registry.npmmirror.com/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -3280,6 +3310,14 @@ "katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "lint-staged/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "listr2/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], @@ -3310,6 +3348,8 @@ "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], @@ -3564,6 +3604,10 @@ "is-admin/execa/strip-final-newline": ["strip-final-newline@2.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "listr2/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "qrcode/yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], diff --git a/docs.json b/docs.json index ec22e680b..9350d9105 100644 --- a/docs.json +++ b/docs.json @@ -185,4 +185,4 @@ "destination": "/docs/introduction/what-is-claude-code" } ] -} \ No newline at end of file +} diff --git a/knip.json b/knip.json index bc66542d3..10d1457d5 100644 --- a/knip.json +++ b/knip.json @@ -1,22 +1,22 @@ { - "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/entrypoints/cli.tsx"], - "project": ["src/**/*.{ts,tsx}"], - "ignore": ["src/types/**", "src/**/*.d.ts"], - "ignoreDependencies": [ - "@ant/*", - "react-compiler-runtime", - "@anthropic-ai/mcpb", - "@anthropic-ai/sandbox-runtime" - ], - "ignoreBinaries": ["bun"], - "workspaces": { - "packages/*": { - "entry": ["src/index.ts"], - "project": ["src/**/*.ts"] - }, - "packages/@ant/*": { - "ignore": ["**"] - } - } + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": ["src/entrypoints/cli.tsx"], + "project": ["src/**/*.{ts,tsx}"], + "ignore": ["src/types/**", "src/**/*.d.ts"], + "ignoreDependencies": [ + "@ant/*", + "react-compiler-runtime", + "@anthropic-ai/mcpb", + "@anthropic-ai/sandbox-runtime" + ], + "ignoreBinaries": ["bun"], + "workspaces": { + "packages/*": { + "entry": ["src/index.ts"], + "project": ["src/**/*.ts"] + }, + "packages/@ant/*": { + "ignore": ["**"] + } + } } diff --git a/package.json b/package.json index 87f28fbf1..2b3c83487 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,12 @@ "dev": "bun run scripts/dev.ts", "dev:inspect": "bun run scripts/dev-debug.ts", "prepublishOnly": "bun run build:vite", - "lint": "biome lint src/", - "lint:fix": "biome lint --fix src/", - "format": "biome format --write src/", + "lint": "biome lint .", + "lint:fix": "biome lint --fix .", + "format": "biome format --write .", + "check": "biome check .", + "check:fix": "biome check --fix .", + "prepare": "husky", "test": "bun test", "test:production": "bun run scripts/production-test.ts", "test:production:offline": "bun run scripts/production-test.ts --offline", @@ -73,11 +76,11 @@ }, "devDependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", - "@ant/model-provider": "workspace:*", "@ant/claude-for-chrome-mcp": "workspace:*", "@ant/computer-use-input": "workspace:*", "@ant/computer-use-mcp": "workspace:*", "@ant/computer-use-swift": "workspace:*", + "@ant/model-provider": "workspace:*", "@anthropic-ai/bedrock-sdk": "^0.29.0", "@anthropic-ai/claude-agent-sdk": "^0.2.114", "@anthropic-ai/foundry-sdk": "^0.2.3", @@ -164,11 +167,13 @@ "google-auth-library": "^10.6.2", "he": "^1.2.0", "https-proxy-agent": "^8.0.0", + "husky": "^9.1.7", "ignore": "^7.0.5", "image-processor-napi": "workspace:*", "indent-string": "^5.0.0", "jsonc-parser": "^3.3.1", "knip": "^6.4.1", + "lint-staged": "^16.4.0", "lodash-es": "^4.18.1", "lru-cache": "^11.3.5", "marked": "^17.0.6", @@ -216,5 +221,13 @@ "hono": "4.12.15", "postcss": "8.5.10", "uuid": "14.0.0" + }, + "lint-staged": { + "*.{ts,tsx,js,mjs,jsx}": [ + "biome check --fix --no-errors-on-unmatched" + ], + "*.{json,jsonc}": [ + "biome format --write --no-errors-on-unmatched" + ] } } diff --git a/packages/acp-link/src/__tests__/cert.test.ts b/packages/acp-link/src/__tests__/cert.test.ts index 13ceb9cb6..94b58e4fd 100644 --- a/packages/acp-link/src/__tests__/cert.test.ts +++ b/packages/acp-link/src/__tests__/cert.test.ts @@ -1,28 +1,28 @@ -import { describe, test, expect } from "bun:test"; -import { getLanIPs } from "../cert.js"; +import { describe, test, expect } from 'bun:test' +import { getLanIPs } from '../cert.js' -describe("getLanIPs", () => { - test("returns an array", () => { - const ips = getLanIPs(); - expect(Array.isArray(ips)).toBe(true); - }); +describe('getLanIPs', () => { + test('returns an array', () => { + const ips = getLanIPs() + expect(Array.isArray(ips)).toBe(true) + }) - test("returns only IPv4 addresses", () => { - const ips = getLanIPs(); + test('returns only IPv4 addresses', () => { + const ips = getLanIPs() for (const ip of ips) { // IPv4 format: x.x.x.x - expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/); + expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/) } - }); + }) - test("does not include loopback addresses", () => { - const ips = getLanIPs(); - expect(ips).not.toContain("127.0.0.1"); - }); + test('does not include loopback addresses', () => { + const ips = getLanIPs() + expect(ips).not.toContain('127.0.0.1') + }) - test("may be empty in isolated environments", () => { + test('may be empty in isolated environments', () => { // This test just ensures it doesn't throw - const ips = getLanIPs(); - expect(ips.length).toBeGreaterThanOrEqual(0); - }); -}); + const ips = getLanIPs() + expect(ips.length).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/packages/acp-link/src/__tests__/server.test.ts b/packages/acp-link/src/__tests__/server.test.ts index 5e28c1bba..249856552 100644 --- a/packages/acp-link/src/__tests__/server.test.ts +++ b/packages/acp-link/src/__tests__/server.test.ts @@ -1,287 +1,306 @@ -import { describe, test, expect, mock } from "bun:test"; +import { describe, test, expect, mock } from 'bun:test' import { __testing, decodeClientWsMessage, MAX_CLIENT_WS_PAYLOAD_BYTES, resolveNewSessionPermissionMode, type ServerConfig, -} from "../server.js"; +} from '../server.js' import { authTokensEqual, decodeWebSocketAuthProtocol, encodeWebSocketAuthProtocol, extractWebSocketAuthToken, -} from "../ws-auth.js"; -import { buildRcsWsUrl } from "../rcs-upstream.js"; +} from '../ws-auth.js' +import { buildRcsWsUrl } from '../rcs-upstream.js' function makeTestWs(sent: unknown[]) { - type TestWs = Parameters[0]; + type TestWs = Parameters[0] return { readyState: 1, send: mock((message: string) => { - sent.push(JSON.parse(message)); + sent.push(JSON.parse(message)) }), close: mock(() => {}), raw: null, isInner: false, - url: "", - origin: "", - protocol: "", - } as unknown as TestWs; + url: '', + origin: '', + protocol: '', + } as unknown as TestWs } -describe("Server HTTP endpoints", () => { - test("package.json has correct bin and main entries", async () => { - const pkg = await import("../../package.json", { with: { type: "json" } }); - expect(pkg.default.name).toBe("acp-link"); - expect(pkg.default.main).toBe("./dist/server.js"); - expect(pkg.default.bin).toBeDefined(); - expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js"); - }); +describe('Server HTTP endpoints', () => { + test('package.json has correct bin and main entries', async () => { + const pkg = await import('../../package.json', { with: { type: 'json' } }) + expect(pkg.default.name).toBe('acp-link') + expect(pkg.default.main).toBe('./dist/server.js') + expect(pkg.default.bin).toBeDefined() + expect(pkg.default.bin['acp-link']).toBe('dist/cli/bin.js') + }) - test("ServerConfig interface accepts all expected fields", () => { + test('ServerConfig interface accepts all expected fields', () => { const config: ServerConfig = { port: 9315, - host: "localhost", - command: "echo", + host: 'localhost', + command: 'echo', args: [], - cwd: "/tmp", + cwd: '/tmp', debug: false, - token: "test-token", + token: 'test-token', https: false, - }; - expect(config.port).toBe(9315); - expect(config.token).toBe("test-token"); - }); + } + expect(config.port).toBe(9315) + expect(config.token).toBe('test-token') + }) - test("ServerConfig allows optional fields to be omitted", () => { + test('ServerConfig allows optional fields to be omitted', () => { const config: ServerConfig = { port: 9315, - host: "localhost", - command: "echo", + host: 'localhost', + command: 'echo', args: [], - cwd: "/tmp", - }; - expect(config.debug).toBeUndefined(); - expect(config.token).toBeUndefined(); - expect(config.https).toBeUndefined(); - }); -}); + cwd: '/tmp', + } + expect(config.debug).toBeUndefined() + expect(config.token).toBeUndefined() + expect(config.https).toBeUndefined() + }) +}) -describe("WebSocket message types", () => { +describe('WebSocket message types', () => { const clientMessageTypes = [ - "connect", - "disconnect", - "new_session", - "prompt", - "permission_response", - "cancel", - "set_session_model", - "list_sessions", - "load_session", - "resume_session", - "ping", - ]; + 'connect', + 'disconnect', + 'new_session', + 'prompt', + 'permission_response', + 'cancel', + 'set_session_model', + 'list_sessions', + 'load_session', + 'resume_session', + 'ping', + ] - test("all client message types are recognized", () => { - expect(clientMessageTypes.length).toBe(11); - expect(clientMessageTypes).toContain("ping"); - expect(clientMessageTypes).toContain("connect"); - expect(clientMessageTypes).toContain("cancel"); - }); + test('all client message types are recognized', () => { + expect(clientMessageTypes.length).toBe(11) + expect(clientMessageTypes).toContain('ping') + expect(clientMessageTypes).toContain('connect') + expect(clientMessageTypes).toContain('cancel') + }) - test("decodes supported client message payloads", () => { - expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" }); + test('decodes supported client message payloads', () => { + expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: 'ping' }) expect( - decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')), - ).toEqual({ type: "prompt", payload: { content: [] } }); + decodeClientWsMessage( + Buffer.from('{"type":"prompt","payload":{"content":[]}}'), + ), + ).toEqual({ type: 'prompt', payload: { content: [] } }) expect( - decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer), - ).toEqual({ type: "cancel" }); + decodeClientWsMessage( + new TextEncoder().encode('{"type":"cancel"}').buffer, + ), + ).toEqual({ type: 'cancel' }) expect( decodeClientWsMessage([ Buffer.from('{"type":"list_sessions","payload":{"cursor":"'), Buffer.from('next"}}'), ]), - ).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } }); - }); + ).toEqual({ + type: 'list_sessions', + payload: { cwd: undefined, cursor: 'next' }, + }) + }) - test("rejects malformed typed client payloads", () => { + test('rejects malformed typed client payloads', () => { expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow( - "Invalid prompt payload", - ); + 'Invalid prompt payload', + ) expect(() => decodeClientWsMessage('{"type":"load_session","payload":{}}'), - ).toThrow("Invalid load_session payload"); + ).toThrow('Invalid load_session payload') expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow( - "Unknown message type", - ); + 'Unknown message type', + ) expect(() => decodeClientWsMessage( '{"type":"new_session","payload":{"permissionMode":123}}', ), - ).toThrow("Invalid new_session.permissionMode"); + ).toThrow('Invalid new_session.permissionMode') expect(() => decodeClientWsMessage( '{"type":"new_session","payload":{"permissionMode":{}}}', ), - ).toThrow("Invalid new_session.permissionMode"); + ).toThrow('Invalid new_session.permissionMode') expect(() => decodeClientWsMessage( '{"type":"new_session","payload":{"permissionMode":null}}', ), - ).toThrow("Invalid new_session.permissionMode"); - }); + ).toThrow('Invalid new_session.permissionMode') + }) - test("rejects oversized client message payloads before decoding", () => { - const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1); - expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large"); - }); -}); + test('rejects oversized client message payloads before decoding', () => { + const payload = 'x'.repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1) + expect(() => decodeClientWsMessage(payload)).toThrow( + 'WebSocket message too large', + ) + }) +}) -describe("WebSocket auth protocol", () => { - test("round-trips tokens through a WebSocket subprotocol token", () => { - const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols"); - expect(protocol).toStartWith("rcs.auth."); - expect(protocol).not.toContain("secret/token"); - expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols"); - }); +describe('WebSocket auth protocol', () => { + test('round-trips tokens through a WebSocket subprotocol token', () => { + const protocol = encodeWebSocketAuthProtocol('secret/token+with=symbols') + expect(protocol).toStartWith('rcs.auth.') + expect(protocol).not.toContain('secret/token') + expect(decodeWebSocketAuthProtocol(protocol)).toBe( + 'secret/token+with=symbols', + ) + }) - test("ignores query-token style inputs", () => { - expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined(); - expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined(); - expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined(); - }); + test('ignores query-token style inputs', () => { + expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined() + expect(decodeWebSocketAuthProtocol('token=secret')).toBeUndefined() + expect(decodeWebSocketAuthProtocol('other, rcs.auth.')).toBeUndefined() + }) - test("prefers Authorization headers and supports protocol auth", () => { + test('prefers Authorization headers and supports protocol auth', () => { expect( extractWebSocketAuthToken({ - authorization: "Bearer header-token", - protocol: encodeWebSocketAuthProtocol("protocol-token"), + authorization: 'Bearer header-token', + protocol: encodeWebSocketAuthProtocol('protocol-token'), }), - ).toBe("header-token"); + ).toBe('header-token') expect( extractWebSocketAuthToken({ - protocol: encodeWebSocketAuthProtocol("protocol-token"), + protocol: encodeWebSocketAuthProtocol('protocol-token'), }), - ).toBe("protocol-token"); - }); + ).toBe('protocol-token') + }) - test("compares auth tokens through the shared constant-time path", () => { - expect(authTokensEqual("secret-token", "secret-token")).toBe(true); - expect(authTokensEqual("secret-token", "wrong-token")).toBe(false); - expect(authTokensEqual(undefined, "secret-token")).toBe(false); - }); -}); + test('compares auth tokens through the shared constant-time path', () => { + expect(authTokensEqual('secret-token', 'secret-token')).toBe(true) + expect(authTokensEqual('secret-token', 'wrong-token')).toBe(false) + expect(authTokensEqual(undefined, 'secret-token')).toBe(false) + }) +}) -describe("RCS upstream URL normalization", () => { - test("removes legacy token query params from WebSocket URLs", () => { +describe('RCS upstream URL normalization', () => { + test('removes legacy token query params from WebSocket URLs', () => { expect( - buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"), - ).toBe("ws://example.test/acp/ws?x=1"); - }); + buildRcsWsUrl('http://example.test/acp/ws?token=old-secret&x=1'), + ).toBe('ws://example.test/acp/ws?x=1') + }) - test("adds /acp/ws for base URLs", () => { - expect(buildRcsWsUrl("https://example.test/")).toBe( - "wss://example.test/acp/ws", - ); - }); -}); + test('adds /acp/ws for base URLs', () => { + expect(buildRcsWsUrl('https://example.test/')).toBe( + 'wss://example.test/acp/ws', + ) + }) +}) -describe("permission mode resolution", () => { - test("uses client requested non-bypass modes", () => { - expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan"); - }); +describe('permission mode resolution', () => { + test('uses client requested non-bypass modes', () => { + expect(resolveNewSessionPermissionMode('plan', 'acceptEdits')).toBe('plan') + }) - test("uses local default when client does not request a mode", () => { - expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits"); - }); + test('uses local default when client does not request a mode', () => { + expect(resolveNewSessionPermissionMode(undefined, 'acceptEdits')).toBe( + 'acceptEdits', + ) + }) - test("rejects client requested bypassPermissions without local default", () => { + test('rejects client requested bypassPermissions without local default', () => { expect(() => - resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"), - ).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE"); + resolveNewSessionPermissionMode('bypassPermissions', 'acceptEdits'), + ).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE') expect(() => - resolveNewSessionPermissionMode("bypass", "acceptEdits"), - ).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE"); + resolveNewSessionPermissionMode('bypass', 'acceptEdits'), + ).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE') expect(() => - resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"), - ).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE"); + resolveNewSessionPermissionMode('bypasspermissions', 'acceptEdits'), + ).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE') expect(() => - resolveNewSessionPermissionMode("bypassPermissions", undefined), - ).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE"); - }); + resolveNewSessionPermissionMode('bypassPermissions', undefined), + ).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE') + }) - test("rejects unknown client permission modes before forwarding", () => { + test('rejects unknown client permission modes before forwarding', () => { expect(() => - resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"), - ).toThrow("Invalid permissionMode: unknown-mode"); - }); + resolveNewSessionPermissionMode('unknown-mode', 'acceptEdits'), + ).toThrow('Invalid permissionMode: unknown-mode') + }) - test("allows bypassPermissions when local default already enables it", () => { - expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions"); - expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions"); - expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions"); - }); + test('allows bypassPermissions when local default already enables it', () => { + expect( + resolveNewSessionPermissionMode('bypassPermissions', 'bypassPermissions'), + ).toBe('bypassPermissions') + expect(resolveNewSessionPermissionMode('bypass', 'bypassPermissions')).toBe( + 'bypassPermissions', + ) + expect(resolveNewSessionPermissionMode('bypassPermissions', 'bypass')).toBe( + 'bypassPermissions', + ) + }) - test("new_session rejects client bypass before forwarding to the agent", async () => { - const sent: unknown[] = []; - const ws = makeTestWs(sent); - const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS; - process.env.ACP_LINK_TEST_INTERNALS = "1"; - let unregisterClient = () => {}; - let restoreMode = () => {}; + test('new_session rejects client bypass before forwarding to the agent', async () => { + const sent: unknown[] = [] + const ws = makeTestWs(sent) + const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS + process.env.ACP_LINK_TEST_INTERNALS = '1' + let unregisterClient = () => {} + let restoreMode = () => {} try { const newSession = mock(async () => ({ - sessionId: "should-not-be-created", - })); + sessionId: 'should-not-be-created', + })) unregisterClient = __testing.registerClient(ws, { connection: { newSession }, - }); - restoreMode = __testing.setDefaultPermissionMode("acceptEdits"); + }) + restoreMode = __testing.setDefaultPermissionMode('acceptEdits') await __testing.dispatchClientMessage(ws, { - type: "new_session", + type: 'new_session', payload: { - cwd: "/tmp", - permissionMode: "bypass", + cwd: '/tmp', + permissionMode: 'bypass', }, - }); + }) - expect(newSession).not.toHaveBeenCalled(); - expect(__testing.getClientSessionId(ws)).toBeNull(); + expect(newSession).not.toHaveBeenCalled() + expect(__testing.getClientSessionId(ws)).toBeNull() expect(sent).toEqual([ { - type: "error", + type: 'error', payload: { message: expect.stringContaining( - "bypassPermissions requires local ACP_PERMISSION_MODE", + 'bypassPermissions requires local ACP_PERMISSION_MODE', ), }, }, - ]); + ]) } finally { - restoreMode(); - unregisterClient(); + restoreMode() + unregisterClient() if (originalTestInternals === undefined) { - delete process.env.ACP_LINK_TEST_INTERNALS; + delete process.env.ACP_LINK_TEST_INTERNALS } else { - process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals; + process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals } } - }); -}); + }) +}) -describe("Heartbeat constants", () => { - test("PERMISSION_TIMEOUT_MS is 5 minutes", () => { - const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; - expect(PERMISSION_TIMEOUT_MS).toBe(300_000); - }); +describe('Heartbeat constants', () => { + test('PERMISSION_TIMEOUT_MS is 5 minutes', () => { + const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 + expect(PERMISSION_TIMEOUT_MS).toBe(300_000) + }) - test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => { - const HEARTBEAT_INTERVAL_MS = 30_000; - expect(HEARTBEAT_INTERVAL_MS).toBe(30_000); - }); -}); + test('HEARTBEAT_INTERVAL_MS is 30 seconds', () => { + const HEARTBEAT_INTERVAL_MS = 30_000 + expect(HEARTBEAT_INTERVAL_MS).toBe(30_000) + }) +}) diff --git a/packages/acp-link/src/__tests__/types.test.ts b/packages/acp-link/src/__tests__/types.test.ts index 8d04b087a..0af4e84a5 100644 --- a/packages/acp-link/src/__tests__/types.test.ts +++ b/packages/acp-link/src/__tests__/types.test.ts @@ -1,69 +1,86 @@ -import { describe, test, expect } from "bun:test"; -import { isRequest, isResponse, isNotification } from "../types.js"; -import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js"; +import { describe, test, expect } from 'bun:test' +import { isRequest, isResponse, isNotification } from '../types.js' +import type { + JsonRpcRequest, + JsonRpcResponse, + JsonRpcNotification, +} from '../types.js' -describe("isRequest", () => { - test("returns true for a valid JSON-RPC request", () => { - const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" }; - expect(isRequest(msg)).toBe(true); - }); +describe('isRequest', () => { + test('returns true for a valid JSON-RPC request', () => { + const msg: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'test' } + expect(isRequest(msg)).toBe(true) + }) - test("returns true for request with params", () => { - const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } }; - expect(isRequest(msg)).toBe(true); - }); + test('returns true for request with params', () => { + const msg = { + jsonrpc: '2.0' as const, + id: 'abc', + method: 'test', + params: { x: 1 }, + } + expect(isRequest(msg)).toBe(true) + }) - test("returns false for response (no method)", () => { - const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} }; - expect(isRequest(msg)).toBe(false); - }); + test('returns false for response (no method)', () => { + const msg: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: {} } + expect(isRequest(msg)).toBe(false) + }) - test("returns false for notification (no id)", () => { - const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" }; - expect(isRequest(msg)).toBe(false); - }); -}); + test('returns false for notification (no id)', () => { + const msg: JsonRpcNotification = { jsonrpc: '2.0', method: 'notify' } + expect(isRequest(msg)).toBe(false) + }) +}) -describe("isResponse", () => { - test("returns true for a valid JSON-RPC response with result", () => { - const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" }; - expect(isResponse(msg)).toBe(true); - }); +describe('isResponse', () => { + test('returns true for a valid JSON-RPC response with result', () => { + const msg: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: 'ok' } + expect(isResponse(msg)).toBe(true) + }) - test("returns true for a valid JSON-RPC error response", () => { - const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } }; - expect(isResponse(msg)).toBe(true); - }); + test('returns true for a valid JSON-RPC error response', () => { + const msg: JsonRpcResponse = { + jsonrpc: '2.0', + id: 2, + error: { code: -32600, message: 'bad' }, + } + expect(isResponse(msg)).toBe(true) + }) - test("returns false for request (has method)", () => { - const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" }; - expect(isResponse(msg)).toBe(false); - }); + test('returns false for request (has method)', () => { + const msg: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'test' } + expect(isResponse(msg)).toBe(false) + }) - test("returns false for notification", () => { - const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" }; - expect(isResponse(msg)).toBe(false); - }); -}); + test('returns false for notification', () => { + const msg: JsonRpcNotification = { jsonrpc: '2.0', method: 'notify' } + expect(isResponse(msg)).toBe(false) + }) +}) -describe("isNotification", () => { - test("returns true for a valid JSON-RPC notification", () => { - const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" }; - expect(isNotification(msg)).toBe(true); - }); +describe('isNotification', () => { + test('returns true for a valid JSON-RPC notification', () => { + const msg: JsonRpcNotification = { jsonrpc: '2.0', method: 'update' } + expect(isNotification(msg)).toBe(true) + }) - test("returns true for notification with params", () => { - const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } }; - expect(isNotification(msg)).toBe(true); - }); + test('returns true for notification with params', () => { + const msg = { + jsonrpc: '2.0' as const, + method: 'progress', + params: { pct: 50 }, + } + expect(isNotification(msg)).toBe(true) + }) - test("returns false for request (has id)", () => { - const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" }; - expect(isNotification(msg)).toBe(false); - }); + test('returns false for request (has id)', () => { + const msg: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'test' } + expect(isNotification(msg)).toBe(false) + }) - test("returns false for response (no method)", () => { - const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null }; - expect(isNotification(msg)).toBe(false); - }); -}); + test('returns false for response (no method)', () => { + const msg: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: null } + expect(isNotification(msg)).toBe(false) + }) +}) diff --git a/packages/acp-link/src/cert.ts b/packages/acp-link/src/cert.ts index 0a9f10ee4..046a6bc34 100644 --- a/packages/acp-link/src/cert.ts +++ b/packages/acp-link/src/cert.ts @@ -2,27 +2,27 @@ * Self-signed certificate generation for HTTPS support */ -import { X509Certificate } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir, networkInterfaces } from "node:os"; -import { join } from "node:path"; -import { generate } from "selfsigned"; +import { X509Certificate } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir, networkInterfaces } from 'node:os' +import { join } from 'node:path' +import { generate } from 'selfsigned' /** * Get all LAN IPv4 addresses */ export function getLanIPs(): string[] { - const ips: string[] = []; - const nets = networkInterfaces(); + const ips: string[] = [] + const nets = networkInterfaces() for (const name of Object.keys(nets)) { for (const net of nets[name] || []) { // Skip internal (loopback) and non-IPv4 addresses - if (!net.internal && net.family === "IPv4") { - ips.push(net.address); + if (!net.internal && net.family === 'IPv4') { + ips.push(net.address) } } } - return ips; + return ips } /** @@ -30,31 +30,31 @@ export function getLanIPs(): string[] { * SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost" */ function extractSanIPs(x509: X509Certificate): string[] { - const san = x509.subjectAltName; - if (!san) return []; + const san = x509.subjectAltName + if (!san) return [] - const ips: string[] = []; + const ips: string[] = [] // Parse "IP Address:x.x.x.x" entries from SAN string - const parts = san.split(", "); + const parts = san.split(', ') for (const part of parts) { - const match = part.match(/^IP Address:(.+)$/); + const match = part.match(/^IP Address:(.+)$/) if (match && match[1]) { - ips.push(match[1]); + ips.push(match[1]) } } - return ips; + return ips } -const CERT_DIR = join(homedir(), ".acp-proxy"); -const KEY_PATH = join(CERT_DIR, "key.pem"); -const CERT_PATH = join(CERT_DIR, "cert.pem"); +const CERT_DIR = join(homedir(), '.acp-proxy') +const KEY_PATH = join(CERT_DIR, 'key.pem') +const CERT_PATH = join(CERT_DIR, 'cert.pem') // Certificate validity in days -const CERT_VALIDITY_DAYS = 365; +const CERT_VALIDITY_DAYS = 365 export interface TlsOptions { - key: string; - cert: string; + key: string + cert: string } /** @@ -64,111 +64,119 @@ export interface TlsOptions { export async function getOrCreateCertificate(): Promise { // Ensure directory exists if (!existsSync(CERT_DIR)) { - mkdirSync(CERT_DIR, { recursive: true }); + mkdirSync(CERT_DIR, { recursive: true }) } // Check if certificates already exist and are still valid if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) { - const certPem = readFileSync(CERT_PATH, "utf-8"); - const keyPem = readFileSync(KEY_PATH, "utf-8"); + const certPem = readFileSync(CERT_PATH, 'utf-8') + const keyPem = readFileSync(KEY_PATH, 'utf-8') try { - const x509 = new X509Certificate(certPem); - const validTo = new Date(x509.validTo); - const now = new Date(); + const x509 = new X509Certificate(certPem) + const validTo = new Date(x509.validTo) + const now = new Date() // Check if cert is expired or will expire within 7 days - const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const daysUntilExpiry = Math.floor( + (validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + ) if (daysUntilExpiry <= 7) { // Certificate expired or expiring soon - console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`); + console.log( + `⚠️ Certificate ${daysUntilExpiry <= 0 ? 'expired' : `expires in ${daysUntilExpiry} days`}, regenerating...`, + ) } else { // Check if current LAN IPs are in the certificate's SAN - const currentLanIPs = getLanIPs(); - const certSanIPs = extractSanIPs(x509); + const currentLanIPs = getLanIPs() + const certSanIPs = extractSanIPs(x509) // Check if all current LAN IPs are covered by the certificate - const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip)); + const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip)) if (missingIPs.length === 0) { - console.log(`🔐 Using existing certificate from ${CERT_DIR}`); - console.log(` Valid for ${daysUntilExpiry} more days`); - return { key: keyPem, cert: certPem }; + console.log(`🔐 Using existing certificate from ${CERT_DIR}`) + console.log(` Valid for ${daysUntilExpiry} more days`) + return { key: keyPem, cert: certPem } } // LAN IP changed, regenerate - console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`); + console.log( + `⚠️ LAN IP changed (missing: ${missingIPs.join(', ')}), regenerating certificate...`, + ) } } catch { // Failed to parse certificate, regenerate - console.log(`⚠️ Invalid certificate, regenerating...`); + console.log(`⚠️ Invalid certificate, regenerating...`) } } // Generate new self-signed certificate - console.log(`🔐 Generating self-signed certificate...`); + console.log(`🔐 Generating self-signed certificate...`) - const attrs = [{ name: "commonName", value: "ACP Proxy Server" }]; + const attrs = [{ name: 'commonName', value: 'ACP Proxy Server' }] // Calculate expiry date - const notAfterDate = new Date(); - notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS); + const notAfterDate = new Date() + notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS) // Build altNames: localhost + loopback + all LAN IPs - const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [ - { type: 2, value: "localhost" }, - { type: 7, ip: "127.0.0.1" }, - { type: 7, ip: "::1" }, - ]; + const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = + [ + { type: 2, value: 'localhost' }, + { type: 7, ip: '127.0.0.1' }, + { type: 7, ip: '::1' }, + ] // Add all current LAN IPs - const lanIPs = getLanIPs(); + const lanIPs = getLanIPs() for (const ip of lanIPs) { - altNames.push({ type: 7, ip }); + altNames.push({ type: 7, ip }) } if (lanIPs.length > 0) { - console.log(` Including LAN IPs: ${lanIPs.join(", ")}`); + console.log(` Including LAN IPs: ${lanIPs.join(', ')}`) } const pems = await generate(attrs, { keySize: 2048, notAfterDate, - algorithm: "sha256", + algorithm: 'sha256', extensions: [ { - name: "basicConstraints", + name: 'basicConstraints', cA: true, }, { - name: "keyUsage", + name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true, }, { - name: "extKeyUsage", + name: 'extKeyUsage', serverAuth: true, }, { - name: "subjectAltName", + name: 'subjectAltName', altNames, }, ], - }); + }) // Save certificates - writeFileSync(KEY_PATH, pems.private); - writeFileSync(CERT_PATH, pems.cert); + writeFileSync(KEY_PATH, pems.private) + writeFileSync(CERT_PATH, pems.cert) - console.log(`✅ Certificate saved to ${CERT_DIR}`); - console.log(` Valid for ${CERT_VALIDITY_DAYS} days`); - console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`); + console.log(`✅ Certificate saved to ${CERT_DIR}`) + console.log(` Valid for ${CERT_VALIDITY_DAYS} days`) + console.log( + ` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`, + ) return { key: pems.private, cert: pems.cert, - }; + } } - diff --git a/packages/acp-link/src/cli/app.ts b/packages/acp-link/src/cli/app.ts index 4da5bd910..e8f06c9d4 100644 --- a/packages/acp-link/src/cli/app.ts +++ b/packages/acp-link/src/cli/app.ts @@ -1,18 +1,17 @@ -import { buildApplication } from "@stricli/core"; -import { createRequire } from "node:module"; -import { command } from "./command.js"; +import { buildApplication } from '@stricli/core' +import { createRequire } from 'node:module' +import { command } from './command.js' -const require = createRequire(import.meta.url); -const pkg = require("../../package.json") as { version: string }; +const require = createRequire(import.meta.url) +const pkg = require('../../package.json') as { version: string } export const app = buildApplication(command, { - name: "acp-link", + name: 'acp-link', versionInfo: { currentVersion: pkg.version, }, scanner: { - caseStyle: "allow-kebab-for-camel", + caseStyle: 'allow-kebab-for-camel', allowArgumentEscapeSequence: true, }, -}); - +}) diff --git a/packages/acp-link/src/cli/bin.ts b/packages/acp-link/src/cli/bin.ts index 9ff5b0765..ad4f8e696 100644 --- a/packages/acp-link/src/cli/bin.ts +++ b/packages/acp-link/src/cli/bin.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node -import { run } from "@stricli/core"; -import { app } from "./app.js"; -import { buildContext } from "./context.js"; - -await run(app, process.argv.slice(2), buildContext()); +import { run } from '@stricli/core' +import { app } from './app.js' +import { buildContext } from './context.js' +await run(app, process.argv.slice(2), buildContext()) diff --git a/packages/acp-link/src/cli/command.ts b/packages/acp-link/src/cli/command.ts index 18db7fa9b..a4dd8962d 100644 --- a/packages/acp-link/src/cli/command.ts +++ b/packages/acp-link/src/cli/command.ts @@ -1,123 +1,145 @@ -import { buildCommand, numberParser } from "@stricli/core"; -import type { LocalContext } from "./context.js"; +import { buildCommand, numberParser } from '@stricli/core' +import type { LocalContext } from './context.js' export const command = buildCommand({ docs: { - brief: "Start the ACP proxy server", + brief: 'Start the ACP proxy server', fullDescription: - "Starts a WebSocket proxy server that bridges clients to ACP agents. " + - "The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" + - "Use -- to pass arguments to the agent:\n" + - " acp-link /path/to/agent -- --verbose --model gpt-4\n\n" + - "Use --manager to start the Manager Web UI instead:\n" + - " acp-link --manager\n\n" + - "For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.", + 'Starts a WebSocket proxy server that bridges clients to ACP agents. ' + + 'The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n' + + 'Use -- to pass arguments to the agent:\n' + + ' acp-link /path/to/agent -- --verbose --model gpt-4\n\n' + + 'Use --manager to start the Manager Web UI instead:\n' + + ' acp-link --manager\n\n' + + 'For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.', }, parameters: { flags: { port: { - kind: "parsed", + kind: 'parsed', parse: numberParser, - brief: "Port to listen on", - default: "9315", + brief: 'Port to listen on', + default: '9315', }, host: { - kind: "parsed", + kind: 'parsed', parse: String, - brief: "Host to bind to (use 0.0.0.0 for remote access)", - default: "localhost", + brief: 'Host to bind to (use 0.0.0.0 for remote access)', + default: 'localhost', }, debug: { - kind: "boolean", - brief: "Enable debug logging to file", + kind: 'boolean', + brief: 'Enable debug logging to file', default: false, }, - "no-auth": { - kind: "boolean", - brief: "DANGEROUS: Disable authentication (not recommended)", + 'no-auth': { + kind: 'boolean', + brief: 'DANGEROUS: Disable authentication (not recommended)', default: false, }, https: { - kind: "boolean", - brief: "Enable HTTPS with auto-generated self-signed certificate", + kind: 'boolean', + brief: 'Enable HTTPS with auto-generated self-signed certificate', default: false, }, manager: { - kind: "boolean", - brief: "Start Manager Web UI (no proxy)", + kind: 'boolean', + brief: 'Start Manager Web UI (no proxy)', default: false, }, group: { - kind: "parsed", + kind: 'parsed', parse: (value: string) => { if (!/^[a-zA-Z0-9_-]+$/.test(value)) { - throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`); + throw new Error( + `Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`, + ) } - return value; + return value }, - brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)", + brief: 'Channel group ID for RCS registration (env: ACP_RCS_GROUP)', optional: true, }, }, positional: { - kind: "array", + kind: 'array', parameter: { - brief: "Agent command and arguments (use -- before agent flags)", + brief: 'Agent command and arguments (use -- before agent flags)', parse: String, - placeholder: "command", + placeholder: 'command', }, minimum: 0, }, }, func: async function ( this: LocalContext, - flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined }, + flags: { + port: number + host: string + debug: boolean + 'no-auth': boolean + https: boolean + manager: boolean + group: string | undefined + }, ...args: readonly string[] ) { - const port = flags.port; - const host = flags.host; - const debug = flags.debug; - const noAuth = flags["no-auth"]; - const https = flags.https; - const manager = flags.manager; - const group = flags.group; + const port = flags.port + const host = flags.host + const debug = flags.debug + const noAuth = flags['no-auth'] + const https = flags.https + const manager = flags.manager + const group = flags.group // Manager mode: start web UI only, no proxy if (manager) { - const { startManager } = await import("../manager/index.js"); - await startManager(port); - return; + const { startManager } = await import('../manager/index.js') + await startManager(port) + return } // Proxy mode: agent command is required if (args.length === 0) { - console.error("Error: agent command is required (or use --manager)"); - process.exit(1); + console.error('Error: agent command is required (or use --manager)') + process.exit(1) } - const [command, ...agentArgs] = args; - const cwd = process.cwd(); + const [command, ...agentArgs] = args + const cwd = process.cwd() // Determine auth token // Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth) - let token: string | undefined; + let token: string | undefined if (noAuth) { - console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!"); - token = undefined; + console.warn( + '⚠️ WARNING: Authentication disabled. This is dangerous for remote access!', + ) + token = undefined } else { - token = process.env.ACP_AUTH_TOKEN; + token = process.env.ACP_AUTH_TOKEN if (!token) { // Auto-generate random token - const { randomBytes } = await import("node:crypto"); - token = randomBytes(32).toString("hex"); + const { randomBytes } = await import('node:crypto') + token = randomBytes(32).toString('hex') } } // Initialize logger - const { initLogger } = await import("../logger.js"); - initLogger({ debug }); + const { initLogger } = await import('../logger.js') + initLogger({ debug }) // Import and run the server - const { startServer } = await import("../server.js"); - await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group }); + const { startServer } = await import('../server.js') + await startServer({ + port, + host, + command: command!, + args: [...agentArgs], + cwd, + debug, + token, + https, + group, + }) }, -}); +}) diff --git a/packages/acp-link/src/cli/context.ts b/packages/acp-link/src/cli/context.ts index 2d9ab16a5..f9d2b5a3b 100644 --- a/packages/acp-link/src/cli/context.ts +++ b/packages/acp-link/src/cli/context.ts @@ -1,10 +1,9 @@ -import type { CommandContext } from "@stricli/core"; +import type { CommandContext } from '@stricli/core' export interface LocalContext extends CommandContext {} export function buildContext(): LocalContext { return { process, - }; + } } - diff --git a/packages/acp-link/src/logger.ts b/packages/acp-link/src/logger.ts index 48fa81c7f..bfb514446 100644 --- a/packages/acp-link/src/logger.ts +++ b/packages/acp-link/src/logger.ts @@ -1,77 +1,81 @@ -import pino from "pino"; -import { join } from "node:path"; -import { mkdirSync, existsSync } from "node:fs"; +import pino from 'pino' +import { join } from 'node:path' +import { mkdirSync, existsSync } from 'node:fs' -let rootLogger: pino.Logger; +let rootLogger: pino.Logger export interface LoggerConfig { - debug: boolean; - logDir?: string; + debug: boolean + logDir?: string } /** Pretty-print config for console output */ const PRETTY_CONFIG = { colorize: true, - translateTime: "SYS:HH:MM:ss.l", - ignore: "pid,hostname", -} as const; + translateTime: 'SYS:HH:MM:ss.l', + ignore: 'pid,hostname', +} as const export function initLogger(config: LoggerConfig): pino.Logger { - const { debug, logDir } = config; + const { debug, logDir } = config if (debug) { - const dir = logDir || join(process.cwd(), ".acp-proxy"); + const dir = logDir || join(process.cwd(), '.acp-proxy') if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true }) } - const now = new Date(); - const timestamp = now.toISOString() - .replace(/T/, "_") - .replace(/:/g, "-") - .replace(/\..+/, ""); - const logFile = join(dir, `acp-proxy-${timestamp}.log`); + const now = new Date() + const timestamp = now + .toISOString() + .replace(/T/, '_') + .replace(/:/g, '-') + .replace(/\..+/, '') + const logFile = join(dir, `acp-proxy-${timestamp}.log`) // Debug mode: JSON to file + pretty to console (multistream) rootLogger = pino( { - level: "trace", + level: 'trace', timestamp: pino.stdTimeFunctions.isoTime, }, pino.transport({ targets: [ - { target: "pino/file", options: { destination: logFile } }, - { target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } }, + { target: 'pino/file', options: { destination: logFile } }, + { + target: 'pino-pretty', + options: { ...PRETTY_CONFIG, destination: 1 }, + }, ], }), - ); + ) - console.log(`📝 Debug logging enabled: ${logFile}`); + console.log(`📝 Debug logging enabled: ${logFile}`) } else { rootLogger = pino( - { level: "info", timestamp: pino.stdTimeFunctions.isoTime }, + { level: 'info', timestamp: pino.stdTimeFunctions.isoTime }, pino.transport({ - target: "pino-pretty", + target: 'pino-pretty', options: { ...PRETTY_CONFIG, destination: 1 }, }), - ); + ) } - return rootLogger; + return rootLogger } /** Get the root logger (auto-creates a default one if not initialized). */ export function getLogger(): pino.Logger { if (!rootLogger) { rootLogger = pino( - { level: "info" }, + { level: 'info' }, pino.transport({ - target: "pino-pretty", + target: 'pino-pretty', options: { ...PRETTY_CONFIG, destination: 1 }, }), - ); + ) } - return rootLogger; + return rootLogger } /** @@ -79,5 +83,5 @@ export function getLogger(): pino.Logger { * Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")` */ export function createLogger(module: string): pino.Logger { - return getLogger().child({ module }); + return getLogger().child({ module }) } diff --git a/packages/acp-link/src/manager/html.ts b/packages/acp-link/src/manager/html.ts index 70f0a6b56..ade2b7f78 100644 --- a/packages/acp-link/src/manager/html.ts +++ b/packages/acp-link/src/manager/html.ts @@ -342,4 +342,4 @@ fetchInstances(); setInterval(fetchInstances, 3000); -`; +` diff --git a/packages/acp-link/src/manager/index.ts b/packages/acp-link/src/manager/index.ts index e49334d6a..3b1438dda 100644 --- a/packages/acp-link/src/manager/index.ts +++ b/packages/acp-link/src/manager/index.ts @@ -1,44 +1,46 @@ -import { Hono } from "hono"; -import { serve } from "@hono/node-server"; -import { ProcessManager } from "./manager.js"; -import { createApp } from "./routes.js"; +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { ProcessManager } from './manager.js' +import { createApp } from './routes.js' export async function startManager(port: number): Promise { - const manager = new ProcessManager(); - const app = createApp(manager); + const manager = new ProcessManager() + const app = createApp(manager) // Health check - app.get("/health", (c) => c.json({ status: "ok" })); + app.get('/health', c => c.json({ status: 'ok' })) - let shuttingDown = false; + let shuttingDown = false const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - console.log("Shutting down..."); - await manager.shutdownAll(); - process.exit(0); - }; - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); + if (shuttingDown) return + shuttingDown = true + console.log('Shutting down...') + await manager.shutdownAll() + process.exit(0) + } + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) - const server = serve({ fetch: app.fetch, port }); - server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`); + const server = serve({ fetch: app.fetch, port }) + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error( + `\n Error: port ${port} is already in use. Use --port to specify a different port.\n`, + ) } else { - console.error(`\n Error: ${err.message}\n`); + console.error(`\n Error: ${err.message}\n`) } - process.exit(1); - }); + process.exit(1) + }) - console.log(); - console.log(` 🖥️ ACP Manager`); - console.log(); - console.log(` URL: http://localhost:${port}`); - console.log(); - console.log(` Press Ctrl+C to stop`); - console.log(); + console.log() + console.log(` 🖥️ ACP Manager`) + console.log() + console.log(` URL: http://localhost:${port}`) + console.log() + console.log(` Press Ctrl+C to stop`) + console.log() // Keep running - await new Promise(() => {}); + await new Promise(() => {}) } diff --git a/packages/acp-link/src/manager/manager.ts b/packages/acp-link/src/manager/manager.ts index 6b2a6ab59..2d459b5f3 100644 --- a/packages/acp-link/src/manager/manager.ts +++ b/packages/acp-link/src/manager/manager.ts @@ -1,205 +1,217 @@ -import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js"; +import type { AcpInstance, InstanceSummary, LogEntry } from './types.js' function log(tag: string, msg: string) { - const ts = new Date().toISOString(); - console.log(`[${ts}] [${tag}] ${msg}`); + const ts = new Date().toISOString() + console.log(`[${ts}] [${tag}] ${msg}`) } -const MAX_LOG_LINES = 2000; -const SHUTDOWN_TIMEOUT_MS = 5000; +const MAX_LOG_LINES = 2000 +const SHUTDOWN_TIMEOUT_MS = 5000 export class ProcessManager { - private instances = new Map(); + private instances = new Map() // eslint-disable-next-line @typescript-eslint/no-explicit-any - private processes = new Map(); + private processes = new Map() create(group: string, command: string): AcpInstance { - const id = crypto.randomUUID(); + const id = crypto.randomUUID() const instance: AcpInstance = { id, group, command, - status: "running", + status: 'running', pid: undefined, startTime: Date.now(), exitCode: null, logs: [], subscribers: new Set(), - }; + } - const args = this.parseCommand(command); - const fullArgs = ["--group", group, ...args]; + const args = this.parseCommand(command) + const fullArgs = ['--group', group, ...args] - const proc = Bun.spawn(["acp-link", ...fullArgs], { - stdout: "pipe", - stderr: "pipe", - env: { ...Bun.env, ACP_CHILD: "1" }, - }); + const proc = Bun.spawn(['acp-link', ...fullArgs], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...Bun.env, ACP_CHILD: '1' }, + }) - instance.pid = proc.pid; - this.instances.set(id, instance); - this.processes.set(id, proc); - log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`); + instance.pid = proc.pid + this.instances.set(id, instance) + this.processes.set(id, proc) + log( + 'manager', + `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(' ')}"`, + ) - this.pipeStream(proc.stdout, id, "stdout"); - this.pipeStream(proc.stderr, id, "stderr"); + this.pipeStream(proc.stdout, id, 'stdout') + this.pipeStream(proc.stderr, id, 'stderr') - proc.exited.then((code) => { - instance.status = code === 0 ? "stopped" : "failed"; - instance.exitCode = code; - instance.pid = undefined; - this.processes.delete(id); - log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`); - this.notifyStatus(instance); - }); + proc.exited.then(code => { + instance.status = code === 0 ? 'stopped' : 'failed' + instance.exitCode = code + instance.pid = undefined + this.processes.delete(id) + log( + 'manager', + `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`, + ) + this.notifyStatus(instance) + }) - return instance; + return instance } stop(id: string): boolean { - const proc = this.processes.get(id); - if (!proc) return false; - const inst = this.instances.get(id); - log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`); - proc.kill("SIGTERM"); + const proc = this.processes.get(id) + if (!proc) return false + const inst = this.instances.get(id) + log('manager', `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`) + proc.kill('SIGTERM') // Immediately mark as stopped to prevent stale state if (inst) { - inst.status = "stopped"; + inst.status = 'stopped' } - return true; + return true } remove(id: string): boolean { - const instance = this.instances.get(id); - if (!instance) return false; - if (instance.status === "running") return false; - instance.subscribers.clear(); - this.instances.delete(id); - log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`); - return true; + const instance = this.instances.get(id) + if (!instance) return false + if (instance.status === 'running') return false + instance.subscribers.clear() + this.instances.delete(id) + log('manager', `removed instance ${id.slice(0, 8)} group=${instance.group}`) + return true } list(): InstanceSummary[] { - return Array.from(this.instances.values()).map(this.toSummary); + return Array.from(this.instances.values()).map(this.toSummary) } get(id: string): AcpInstance | undefined { - return this.instances.get(id); + return this.instances.get(id) } subscribe(id: string, callback: (entry: LogEntry) => void): () => void { - const instance = this.instances.get(id); - if (!instance) return () => {}; - instance.subscribers.add(callback); - return () => instance.subscribers.delete(callback); + const instance = this.instances.get(id) + if (!instance) return () => {} + instance.subscribers.add(callback) + return () => instance.subscribers.delete(callback) } async shutdownAll(): Promise { - const running = Array.from(this.processes.entries()); - if (running.length === 0) return; + const running = Array.from(this.processes.entries()) + if (running.length === 0) return - log("manager", `shutting down ${running.length} running instance(s)...`); + log('manager', `shutting down ${running.length} running instance(s)...`) for (const [id, proc] of running) { try { - proc.kill("SIGTERM"); - log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`); + proc.kill('SIGTERM') + log('manager', `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`) } catch { // already dead } } - const timeout = new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)); + const timeout = new Promise(resolve => + setTimeout(resolve, SHUTDOWN_TIMEOUT_MS), + ) await Promise.race([ Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))), timeout, - ]); + ]) for (const [id, proc] of running) { try { - proc.kill("SIGKILL"); - log("manager", `sent SIGKILL to ${id.slice(0, 8)}`); + proc.kill('SIGKILL') + log('manager', `sent SIGKILL to ${id.slice(0, 8)}`) } catch { // already dead } } - log("manager", "all instances shut down"); + log('manager', 'all instances shut down') } private parseCommand(command: string): string[] { - const args: string[] = []; - let current = ""; - let inQuote: string | null = null; + const args: string[] = [] + let current = '' + let inQuote: string | null = null for (const ch of command) { if (inQuote) { if (ch === inQuote) { - inQuote = null; + inQuote = null } else { - current += ch; + current += ch } } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === " " || ch === "\t") { + inQuote = ch + } else if (ch === ' ' || ch === '\t') { if (current) { - args.push(current); - current = ""; + args.push(current) + current = '' } } else { - current += ch; + current += ch } } - if (current) args.push(current); - return args; + if (current) args.push(current) + return args } private pipeStream( readable: ReadableStream, instanceId: string, - stream: "stdout" | "stderr", + stream: 'stdout' | 'stderr', ) { - const reader = readable.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + const reader = readable.getReader() + const decoder = new TextDecoder() + let buffer = '' const processChunk = () => { reader .read() .then(({ done, value }) => { if (done) { - if (buffer) this.appendLog(instanceId, buffer, stream); - return; + if (buffer) this.appendLog(instanceId, buffer, stream) + return } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' for (const line of lines) { - if (line) this.appendLog(instanceId, line, stream); + if (line) this.appendLog(instanceId, line, stream) } - processChunk(); + processChunk() }) .catch(() => { // stream ended or error - }); - }; - processChunk(); + }) + } + processChunk() } - private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") { - const instance = this.instances.get(instanceId); - if (!instance) return; + private appendLog( + instanceId: string, + text: string, + stream: 'stdout' | 'stderr', + ) { + const instance = this.instances.get(instanceId) + if (!instance) return - const entry: LogEntry = { timestamp: Date.now(), stream, text }; - instance.logs.push(entry); + const entry: LogEntry = { timestamp: Date.now(), stream, text } + instance.logs.push(entry) if (instance.logs.length > MAX_LOG_LINES) { - instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES); + instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES) } for (const sub of instance.subscribers) { try { - sub(entry); + sub(entry) } catch { // subscriber error, remove it - instance.subscribers.delete(sub); + instance.subscribers.delete(sub) } } } @@ -207,14 +219,14 @@ export class ProcessManager { private notifyStatus(instance: AcpInstance) { const statusEntry: LogEntry = { timestamp: Date.now(), - stream: "stderr", + stream: 'stderr', text: `[${instance.status}] exit code: ${instance.exitCode}`, - }; + } for (const sub of instance.subscribers) { try { - sub(statusEntry); + sub(statusEntry) } catch { - instance.subscribers.delete(sub); + instance.subscribers.delete(sub) } } } @@ -228,6 +240,6 @@ export class ProcessManager { pid: inst.pid, startTime: inst.startTime, exitCode: inst.exitCode, - }; + } } } diff --git a/packages/acp-link/src/manager/routes.ts b/packages/acp-link/src/manager/routes.ts index 3b7d752e0..1d3bfe6fa 100644 --- a/packages/acp-link/src/manager/routes.ts +++ b/packages/acp-link/src/manager/routes.ts @@ -1,41 +1,41 @@ -import { Hono } from "hono"; -import type { ProcessManager } from "./manager.js"; -import { MANAGER_HTML } from "./html.js"; +import { Hono } from 'hono' +import type { ProcessManager } from './manager.js' +import { MANAGER_HTML } from './html.js' function logReq(method: string, path: string, status?: number) { - const ts = new Date().toISOString(); - const suffix = status != null ? ` -> ${status}` : ""; - console.log(`[${ts}] [http] ${method} ${path}${suffix}`); + const ts = new Date().toISOString() + const suffix = status != null ? ` -> ${status}` : '' + console.log(`[${ts}] [http] ${method} ${path}${suffix}`) } export function createApp(manager: ProcessManager): Hono { - const app = new Hono(); + const app = new Hono() - app.get("/", (c) => { - logReq("GET", "/", 200); - return c.html(MANAGER_HTML); - }); + app.get('/', c => { + logReq('GET', '/', 200) + return c.html(MANAGER_HTML) + }) - app.get("/api/instances", (c) => { - const list = manager.list(); - logReq("GET", "/api/instances", 200); - return c.json(list); - }); + app.get('/api/instances', c => { + const list = manager.list() + logReq('GET', '/api/instances', 200) + return c.json(list) + }) - app.post("/api/instances", async (c) => { - let body: { group?: string; command?: string }; + app.post('/api/instances', async c => { + let body: { group?: string; command?: string } try { - body = await c.req.json<{ group?: string; command?: string }>(); + body = await c.req.json<{ group?: string; command?: string }>() } catch { - logReq("POST", "/api/instances", 400); - return c.json({ error: "invalid JSON body" }, 400); + logReq('POST', '/api/instances', 400) + return c.json({ error: 'invalid JSON body' }, 400) } if (!body.group?.trim() || !body.command?.trim()) { - logReq("POST", "/api/instances", 400); - return c.json({ error: "group and command are required" }, 400); + logReq('POST', '/api/instances', 400) + return c.json({ error: 'group and command are required' }, 400) } - const instance = manager.create(body.group.trim(), body.command.trim()); - logReq("POST", `/api/instances group=${body.group}`, 201); + const instance = manager.create(body.group.trim(), body.command.trim()) + logReq('POST', `/api/instances group=${body.group}`, 201) return c.json( { id: instance.id, @@ -47,107 +47,107 @@ export function createApp(manager: ProcessManager): Hono { exitCode: instance.exitCode, }, 201, - ); - }); + ) + }) - app.post("/api/instances/:id/stop", (c) => { - const id = c.req.param("id"); - const inst = manager.get(id); + app.post('/api/instances/:id/stop', c => { + const id = c.req.param('id') + const inst = manager.get(id) if (!inst) { - logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404); - return c.json({ error: "not found" }, 404); + logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 404) + return c.json({ error: 'not found' }, 404) } - if (inst.status !== "running") { - logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400); - return c.json({ error: "not running" }, 400); + if (inst.status !== 'running') { + logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 400) + return c.json({ error: 'not running' }, 400) } - manager.stop(inst.id); - logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200); - return c.json({ ok: true }); - }); + manager.stop(inst.id) + logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 200) + return c.json({ ok: true }) + }) - app.delete("/api/instances/:id", (c) => { - const id = c.req.param("id"); - const inst = manager.get(id); + app.delete('/api/instances/:id', c => { + const id = c.req.param('id') + const inst = manager.get(id) if (!inst) { - logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404); - return c.json({ error: "not found" }, 404); + logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 404) + return c.json({ error: 'not found' }, 404) } - if (inst.status === "running") { - logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400); - return c.json({ error: "still running" }, 400); + if (inst.status === 'running') { + logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 400) + return c.json({ error: 'still running' }, 400) } - manager.remove(inst.id); - logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200); - return c.json({ ok: true }); - }); + manager.remove(inst.id) + logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 200) + return c.json({ ok: true }) + }) - app.get("/api/instances/:id/logs", (c) => { - const id = c.req.param("id"); - const inst = manager.get(id); + app.get('/api/instances/:id/logs', c => { + const id = c.req.param('id') + const inst = manager.get(id) if (!inst) { - logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404); - return c.json({ error: "not found" }, 404); + logReq('GET', `/api/instances/${id.slice(0, 8)}/logs`, 404) + return c.json({ error: 'not found' }, 404) } - logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`); + logReq('GET', `/api/instances/${id.slice(0, 8)}/logs SSE`) const stream = new ReadableStream({ start(controller) { - const encoder = new TextEncoder(); + const encoder = new TextEncoder() const send = (data: string) => { try { - controller.enqueue(encoder.encode(data)); + controller.enqueue(encoder.encode(data)) } catch { // stream closed } - }; + } // send historical logs for (const log of inst.logs) { - send(`data: ${JSON.stringify(log)}\n\n`); + send(`data: ${JSON.stringify(log)}\n\n`) } // subscribe to new logs - const unsub = manager.subscribe(inst.id, (entry) => { - send(`data: ${JSON.stringify(entry)}\n\n`); - }); + const unsub = manager.subscribe(inst.id, entry => { + send(`data: ${JSON.stringify(entry)}\n\n`) + }) // keepalive every 15s const keepalive = setInterval(() => { - send(": keepalive\n\n"); - }, 15000); + send(': keepalive\n\n') + }, 15000) const cleanup = () => { - unsub(); - clearInterval(keepalive); - logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`); + unsub() + clearInterval(keepalive) + logReq('SSE', `/api/instances/${id.slice(0, 8)}/logs closed`) try { - controller.close(); + controller.close() } catch { // already closed } - }; + } - c.req.raw.signal.addEventListener("abort", cleanup, { once: true }); + c.req.raw.signal.addEventListener('abort', cleanup, { once: true }) }, - }); + }) return new Response(stream, { headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "X-Accel-Buffering": "no", + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', }, - }); - }); + }) + }) // Catch-all: log unmatched routes for debugging - app.all("*", (c) => { - logReq(c.req.method, c.req.path, 404); - return c.json({ error: "not found", path: c.req.path }, 404); - }); + app.all('*', c => { + logReq(c.req.method, c.req.path, 404) + return c.json({ error: 'not found', path: c.req.path }, 404) + }) - return app; + return app } diff --git a/packages/acp-link/src/manager/types.ts b/packages/acp-link/src/manager/types.ts index b1984460c..d84b346c4 100644 --- a/packages/acp-link/src/manager/types.ts +++ b/packages/acp-link/src/manager/types.ts @@ -1,34 +1,34 @@ -export type InstanceStatus = "running" | "stopped" | "failed"; +export type InstanceStatus = 'running' | 'stopped' | 'failed' export interface AcpInstance { - id: string; - group: string; - command: string; - status: InstanceStatus; - pid: number | undefined; - startTime: number; - exitCode: number | null; - logs: LogEntry[]; - subscribers: Set<(entry: LogEntry) => void>; + id: string + group: string + command: string + status: InstanceStatus + pid: number | undefined + startTime: number + exitCode: number | null + logs: LogEntry[] + subscribers: Set<(entry: LogEntry) => void> } export interface LogEntry { - timestamp: number; - stream: "stdout" | "stderr"; - text: string; + timestamp: number + stream: 'stdout' | 'stderr' + text: string } export interface CreateInstanceRequest { - group: string; - command: string; + group: string + command: string } export interface InstanceSummary { - id: string; - group: string; - command: string; - status: InstanceStatus; - pid: number | undefined; - startTime: number; - exitCode: number | null; + id: string + group: string + command: string + status: InstanceStatus + pid: number | undefined + startTime: number + exitCode: number | null } diff --git a/packages/acp-link/src/rcs-upstream.ts b/packages/acp-link/src/rcs-upstream.ts index 113fc9c51..594f92b87 100644 --- a/packages/acp-link/src/rcs-upstream.ts +++ b/packages/acp-link/src/rcs-upstream.ts @@ -1,26 +1,26 @@ -import { createLogger } from "./logger.js"; -import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js"; -import { encodeWebSocketAuthProtocol } from "./ws-auth.js"; +import { createLogger } from './logger.js' +import { decodeJsonWsMessage, WsPayloadTooLargeError } from './ws-message.js' +import { encodeWebSocketAuthProtocol } from './ws-auth.js' export interface RcsUpstreamConfig { - rcsUrl: string; // e.g. "http://localhost:3000" - apiToken: string; - agentName: string; - channelGroupId?: string; - capabilities?: Record; - maxSessions?: number; + rcsUrl: string // e.g. "http://localhost:3000" + apiToken: string + agentName: string + channelGroupId?: string + capabilities?: Record + maxSessions?: number } export function buildRcsWsUrl(rcsUrl: string): string { - let raw = rcsUrl; - raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://"); - const url = new URL(raw); - const path = url.pathname.replace(/\/+$/, ""); - if (!path || path === "/") { - url.pathname = "/acp/ws"; + let raw = rcsUrl + raw = raw.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://') + const url = new URL(raw) + const path = url.pathname.replace(/\/+$/, '') + if (!path || path === '/') { + url.pathname = '/acp/ws' } - url.searchParams.delete("token"); - return url.toString(); + url.searchParams.delete('token') + return url.toString() } /** @@ -34,232 +34,272 @@ export function buildRcsWsUrl(rcsUrl: string): string { * 5. Reconnects with exponential backoff on failure */ export class RcsUpstreamClient { - private static log = createLogger("rcs-upstream"); - private ws: WebSocket | null = null; - private registered = false; - private reconnectAttempts = 0; - private closed = false; - private readonly maxReconnectDelay = 30_000; - private readonly baseReconnectDelay = 1_000; + private static log = createLogger('rcs-upstream') + private ws: WebSocket | null = null + private registered = false + private reconnectAttempts = 0 + private closed = false + private readonly maxReconnectDelay = 30_000 + private readonly baseReconnectDelay = 1_000 /** Agent ID obtained from REST registration */ - private agentId: string | null = null; + private agentId: string | null = null /** Session ID from REST registration (ACP agents auto-create a session) */ - private sessionId: string | undefined; + private sessionId: string | undefined /** Handler for incoming ACP messages from RCS relay */ - private messageHandler: ((message: Record) => void) | null = null; + private messageHandler: ((message: Record) => void) | null = + null constructor(private config: RcsUpstreamConfig) {} /** Get the agent ID from REST registration */ getAgentId(): string | null { - return this.agentId; + return this.agentId } /** Set handler for incoming ACP messages from RCS relay */ setMessageHandler(handler: (message: Record) => void): void { - this.messageHandler = handler; + this.messageHandler = handler } /** Register via REST API before establishing WS connection */ private async registerViaRest(): Promise { const baseUrl = this.config.rcsUrl - .replace(/^ws:\/\//, "http://") - .replace(/^wss:\/\//, "https://") - .replace(/\/acp\/ws.*$/, "") - .replace(/\/$/, ""); + .replace(/^ws:\/\//, 'http://') + .replace(/^wss:\/\//, 'https://') + .replace(/\/acp\/ws.*$/, '') + .replace(/\/$/, '') - const url = `${baseUrl}/v1/environments/bridge`; - RcsUpstreamClient.log.info({ url }, "REST register"); + const url = `${baseUrl}/v1/environments/bridge` + RcsUpstreamClient.log.info({ url }, 'REST register') const resp = await fetch(url, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${this.config.apiToken}`, + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiToken}`, }, body: JSON.stringify({ machine_name: this.config.agentName, - worker_type: "acp", + worker_type: 'acp', bridge_id: this.config.channelGroupId || undefined, max_sessions: this.config.maxSessions, capabilities: this.config.capabilities, }), - }); + }) if (!resp.ok) { - const text = await resp.text(); - throw new Error(`REST register failed (${resp.status}): ${text}`); + const text = await resp.text() + throw new Error(`REST register failed (${resp.status}): ${text}`) } - const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string }; - this.agentId = data.environment_id; - this.sessionId = data.session_id; - RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success"); - return data.environment_id; + const data = (await resp.json()) as { + environment_id: string + environment_secret: string + status: string + session_id?: string + } + this.agentId = data.environment_id + this.sessionId = data.session_id + RcsUpstreamClient.log.info( + { agentId: this.agentId, sessionId: this.sessionId }, + 'REST register success', + ) + return data.environment_id } /** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */ private buildWsUrl(): string { - return buildRcsWsUrl(this.config.rcsUrl); + return buildRcsWsUrl(this.config.rcsUrl) } /** Open connection to RCS: REST register → WS identify */ async connect(): Promise { - if (this.closed) return; + if (this.closed) return // Step 1: REST registration try { - await this.registerViaRest(); + await this.registerViaRest() } catch (err) { - RcsUpstreamClient.log.error({ err }, "REST registration failed"); + RcsUpstreamClient.log.error({ err }, 'REST registration failed') if (!this.closed) { - this.scheduleReconnect(); + this.scheduleReconnect() } - return; + return } // Step 2: WebSocket connection with identify - const wsUrl = this.buildWsUrl(); - RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS"); + const wsUrl = this.buildWsUrl() + RcsUpstreamClient.log.info({ url: wsUrl }, 'connecting WS') return new Promise((resolve, reject) => { try { this.ws = new WebSocket(wsUrl, [ encodeWebSocketAuthProtocol(this.config.apiToken), - ]); + ]) this.ws.onopen = () => { - RcsUpstreamClient.log.debug("ws open — sending identify"); + RcsUpstreamClient.log.debug('ws open — sending identify') this.ws!.send( JSON.stringify({ - type: "identify", + type: 'identify', agent_id: this.agentId, }), - ); - }; + ) + } - this.ws.onmessage = (event) => { - let data: Record; + this.ws.onmessage = event => { + let data: Record try { - data = decodeJsonWsMessage(event.data); + data = decodeJsonWsMessage(event.data) } catch (err) { if (err instanceof WsPayloadTooLargeError) { - RcsUpstreamClient.log.warn({ error: err.message }, "server message too large"); - this.ws?.close(1009, "message too large"); - return; + RcsUpstreamClient.log.warn( + { error: err.message }, + 'server message too large', + ) + this.ws?.close(1009, 'message too large') + return } - RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server"); - return; + RcsUpstreamClient.log.warn( + { raw: String(event.data).slice(0, 200) }, + 'invalid JSON from server', + ) + return } - if (data.type === "identified") { - RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified"); - this.registered = true; - this.reconnectAttempts = 0; + if (data.type === 'identified') { + RcsUpstreamClient.log.info( + { + agent_id: data.agent_id, + channel_group_id: data.channel_group_id, + }, + 'identified', + ) + this.registered = true + this.reconnectAttempts = 0 const webBase = this.config.rcsUrl - .replace(/^ws:\/\//, "http://") - .replace(/^wss:\/\//, "https://") - .replace(/\/acp\/ws.*$/, "") - .replace(/\/$/, ""); - console.log(); - console.log(` 🔗 Dashboard: ${webBase}/code/`); + .replace(/^ws:\/\//, 'http://') + .replace(/^wss:\/\//, 'https://') + .replace(/\/acp\/ws.*$/, '') + .replace(/\/$/, '') + console.log() + console.log(` 🔗 Dashboard: ${webBase}/code/`) if (this.agentId) { - console.log(` Agent ID: ${this.agentId}`); + console.log(` Agent ID: ${this.agentId}`) } - console.log(); - resolve(); - } else if (data.type === "registered") { + console.log() + resolve() + } else if (data.type === 'registered') { // Legacy fallback: server still uses old register flow - RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)"); - this.agentId = (data.agent_id as string) || this.agentId; - this.registered = true; - this.reconnectAttempts = 0; - resolve(); - } else if (data.type === "error") { - RcsUpstreamClient.log.error({ message: data.message }, "server error"); + RcsUpstreamClient.log.info( + { agent_id: data.agent_id }, + 'registered (legacy)', + ) + this.agentId = (data.agent_id as string) || this.agentId + this.registered = true + this.reconnectAttempts = 0 + resolve() + } else if (data.type === 'error') { + RcsUpstreamClient.log.error( + { message: data.message }, + 'server error', + ) if (!this.registered) { - reject(new Error(data.message as string)); + reject(new Error(data.message as string)) } - } else if (data.type === "keep_alive") { + } else if (data.type === 'keep_alive') { // ignore keepalive } else { // Forward ACP protocol messages to handler (for RCS relay support) - RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler"); - this.messageHandler?.(data); + RcsUpstreamClient.log.debug( + { type: data.type }, + 'forwarding to relay handler', + ) + this.messageHandler?.(data) } - }; + } this.ws.onerror = () => { // onclose fires after onerror with the actual close code, so we log there if (!this.registered) { - reject(new Error("WebSocket connection failed")); + reject(new Error('WebSocket connection failed')) } - }; + } - this.ws.onclose = (event) => { - RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed"); - this.registered = false; - this.ws = null; + this.ws.onclose = event => { + RcsUpstreamClient.log.info( + { code: event.code, reason: event.reason || undefined }, + 'ws closed', + ) + this.registered = false + this.ws = null if (!this.closed) { - this.scheduleReconnect(); + this.scheduleReconnect() } - }; + } } catch (err) { - RcsUpstreamClient.log.error({ err }, "connect threw"); - reject(err); + RcsUpstreamClient.log.error({ err }, 'connect threw') + reject(err) } - }); + }) } /** Send an ACP message to RCS for broadcast */ send(message: object): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) { - return; + return } try { - this.ws.send(JSON.stringify(message)); + this.ws.send(JSON.stringify(message)) } catch (err) { - RcsUpstreamClient.log.error({ err }, "send failed"); + RcsUpstreamClient.log.error({ err }, 'send failed') } } /** Check if registered with RCS */ isRegistered(): boolean { - return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN; + return ( + this.registered && + this.ws !== null && + this.ws.readyState === WebSocket.OPEN + ) } /** Close the RCS connection permanently */ async close(): Promise { - this.closed = true; - this.registered = false; + this.closed = true + this.registered = false if (this.ws) { - this.ws.close(1000, "client shutdown"); - this.ws = null; + this.ws.close(1000, 'client shutdown') + this.ws = null } - RcsUpstreamClient.log.info("closed"); + RcsUpstreamClient.log.info('closed') } private scheduleReconnect(): void { - if (this.closed) return; + if (this.closed) return const delay = Math.min( this.baseReconnectDelay * 2 ** this.reconnectAttempts, this.maxReconnectDelay, - ); - const jitter = delay * Math.random() * 0.2; - const actualDelay = delay + jitter; - this.reconnectAttempts++; + ) + const jitter = delay * Math.random() * 0.2 + const actualDelay = delay + jitter + this.reconnectAttempts++ - RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting"); + RcsUpstreamClient.log.warn( + { attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, + 'reconnecting', + ) setTimeout(async () => { - if (this.closed) return; + if (this.closed) return try { - await this.connect(); + await this.connect() } catch { // connect() itself logs the error; nothing to add here } - }, actualDelay); + }, actualDelay) } } diff --git a/packages/acp-link/src/server.ts b/packages/acp-link/src/server.ts index 237798f4e..b5624a344 100644 --- a/packages/acp-link/src/server.ts +++ b/packages/acp-link/src/server.ts @@ -1,116 +1,117 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { createServer as createHttpsServer } from "node:https"; -import { Writable, Readable } from "node:stream"; -import * as acp from "@agentclientprotocol/sdk"; -import { Hono } from "hono"; -import { serve } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import type { WSContext } from "hono/ws"; -import type { WebSocket as RawWebSocket } from "ws"; -import { createLogger } from "./logger.js"; -import { getOrCreateCertificate, getLanIPs } from "./cert.js"; -import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js"; -import { - decodeJsonWsMessage, - WsPayloadTooLargeError, -} from "./ws-message.js"; -import { authTokensEqual, extractWebSocketAuthToken } from "./ws-auth.js"; +import { spawn, type ChildProcess } from 'node:child_process' +import { createServer as createHttpsServer } from 'node:https' +import { Writable, Readable } from 'node:stream' +import * as acp from '@agentclientprotocol/sdk' +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { createNodeWebSocket } from '@hono/node-ws' +import type { WSContext } from 'hono/ws' +import type { WebSocket as RawWebSocket } from 'ws' +import { createLogger } from './logger.js' +import { getOrCreateCertificate, getLanIPs } from './cert.js' +import { RcsUpstreamClient, type RcsUpstreamConfig } from './rcs-upstream.js' +import { decodeJsonWsMessage, WsPayloadTooLargeError } from './ws-message.js' +import { authTokensEqual, extractWebSocketAuthToken } from './ws-auth.js' -export { MAX_CLIENT_WS_PAYLOAD_BYTES } from "./ws-message.js"; +export { MAX_CLIENT_WS_PAYLOAD_BYTES } from './ws-message.js' export interface ServerConfig { - port: number; - host: string; - command: string; - args: string[]; - cwd: string; - debug?: boolean; - token?: string; - https?: boolean; + port: number + host: string + command: string + args: string[] + cwd: string + debug?: boolean + token?: string + https?: boolean /** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */ - permissionMode?: string; + permissionMode?: string /** Channel group ID for RCS registration */ - group?: string; + group?: string } // Pending permission request interface PendingPermission { - resolve: (outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }) => void; - timeout: ReturnType; + resolve: ( + outcome: + | { outcome: 'cancelled' } + | { outcome: 'selected'; optionId: string }, + ) => void + timeout: ReturnType } // PromptCapabilities from ACP protocol // Reference: Zed's prompt_capabilities to check image support interface PromptCapabilities { - audio?: boolean; - embeddedContext?: boolean; - image?: boolean; + audio?: boolean + embeddedContext?: boolean + image?: boolean } // SessionModelState from ACP protocol // Reference: Zed's AgentModelSelector reads from state.available_models interface SessionModelState { availableModels: Array<{ - modelId: string; - name: string; - description?: string | null; - }>; - currentModelId: string; + modelId: string + name: string + description?: string | null + }> + currentModelId: string } // AgentCapabilities from ACP protocol // Reference: Zed's AcpConnection.agent_capabilities // Matches SDK's AgentCapabilities exactly interface AgentCapabilities { - _meta?: Record | null; - loadSession?: boolean; + _meta?: Record | null + loadSession?: boolean mcpCapabilities?: { - _meta?: Record | null; - clientServers?: boolean; - }; - promptCapabilities?: PromptCapabilities; + _meta?: Record | null + clientServers?: boolean + } + promptCapabilities?: PromptCapabilities sessionCapabilities?: { - _meta?: Record | null; - fork?: Record | null; - list?: Record | null; - resume?: Record | null; - }; + _meta?: Record | null + fork?: Record | null + list?: Record | null + resume?: Record | null + } } // Track connected clients and their agent connections interface ClientState { - process: ChildProcess | null; - connection: acp.ClientSideConnection | null; - sessionId: string | null; - pendingPermissions: Map; - agentCapabilities: AgentCapabilities | null; - promptCapabilities: PromptCapabilities | null; - modelState: SessionModelState | null; - isAlive: boolean; + process: ChildProcess | null + connection: acp.ClientSideConnection | null + sessionId: string | null + pendingPermissions: Map + agentCapabilities: AgentCapabilities | null + promptCapabilities: PromptCapabilities | null + modelState: SessionModelState | null + isAlive: boolean } // Module-level state (set when server starts) -let AGENT_COMMAND: string; -let AGENT_ARGS: string[]; -let AGENT_CWD: string; -let SERVER_PORT: number; -let SERVER_HOST: string; -let AUTH_TOKEN: string | undefined; -let DEFAULT_PERMISSION_MODE: string | undefined; +let AGENT_COMMAND: string +let AGENT_ARGS: string[] +let AGENT_CWD: string +let SERVER_PORT: number +let SERVER_HOST: string +let AUTH_TOKEN: string | undefined +let DEFAULT_PERMISSION_MODE: string | undefined -const clients = new Map(); +const clients = new Map() // Module-scoped child loggers -const logWs = createLogger("ws"); -const logAgent = createLogger("agent"); -const logSession = createLogger("session"); -const logPrompt = createLogger("prompt"); -const logPerm = createLogger("perm"); -const logRelay = createLogger("relay"); -const logServer = createLogger("server"); +const logWs = createLogger('ws') +const logAgent = createLogger('agent') +const logSession = createLogger('session') +const logPrompt = createLogger('prompt') +const logPerm = createLogger('perm') +const logRelay = createLogger('relay') +const logServer = createLogger('server') // RCS upstream client (optional — enabled via ACP_RCS_URL env var) -let rcsUpstream: RcsUpstreamClient | null = null; +let rcsUpstream: RcsUpstreamClient | null = null /** * Create a virtual WSContext for RCS relay messages. @@ -118,37 +119,39 @@ let rcsUpstream: RcsUpstreamClient | null = null; */ function createRelayWs(): WSContext { return { - get readyState() { return 1; }, // always OPEN + get readyState() { + return 1 + }, // always OPEN send: () => {}, // no-op — responses go through rcsUpstream.send() close: () => {}, raw: null, isInner: false, - url: "", - origin: "", - protocol: "", - } as unknown as WSContext; + url: '', + origin: '', + protocol: '', + } as unknown as WSContext } // Permission request timeout (5 minutes) -const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; +const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // Heartbeat interval for WebSocket ping/pong (30 seconds) -const HEARTBEAT_INTERVAL_MS = 30_000; +const HEARTBEAT_INTERVAL_MS = 30_000 // Generate unique request ID function generateRequestId(): string { - return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` } // Send a message to the WebSocket client (and optionally forward to RCS upstream) function send(ws: WSContext, type: string, payload?: unknown): void { if (ws.readyState === 1) { // WebSocket.OPEN - ws.send(JSON.stringify({ type, payload })); + ws.send(JSON.stringify({ type, payload })) } // Forward to RCS upstream if connected if (rcsUpstream?.isRegistered()) { - rcsUpstream.send({ type, payload }); + rcsUpstream.send({ type, payload }) } } @@ -156,176 +159,205 @@ function send(ws: WSContext, type: string, payload?: unknown): void { function createClient(ws: WSContext, clientState: ClientState): acp.Client { return { async requestPermission(params) { - const requestId = generateRequestId(); - logPerm.debug({ requestId, title: params.toolCall.title }, "requested"); + const requestId = generateRequestId() + logPerm.debug({ requestId, title: params.toolCall.title }, 'requested') - const outcomePromise = new Promise<{ outcome: "cancelled" } | { outcome: "selected"; optionId: string }>((resolve) => { + const outcomePromise = new Promise< + { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } + >(resolve => { const timeout = setTimeout(() => { - logPerm.warn({ requestId }, "timed out"); - clientState.pendingPermissions.delete(requestId); - resolve({ outcome: "cancelled" }); - }, PERMISSION_TIMEOUT_MS); + logPerm.warn({ requestId }, 'timed out') + clientState.pendingPermissions.delete(requestId) + resolve({ outcome: 'cancelled' }) + }, PERMISSION_TIMEOUT_MS) - clientState.pendingPermissions.set(requestId, { resolve, timeout }); - }); + clientState.pendingPermissions.set(requestId, { resolve, timeout }) + }) - send(ws, "permission_request", { + send(ws, 'permission_request', { requestId, sessionId: params.sessionId, options: params.options, toolCall: params.toolCall, - }); + }) - const outcome = await outcomePromise; - logPerm.debug({ requestId, outcome: outcome.outcome }, "resolved"); + const outcome = await outcomePromise + logPerm.debug({ requestId, outcome: outcome.outcome }, 'resolved') - return { outcome }; + return { outcome } }, async sessionUpdate(params) { - send(ws, "session_update", params); + send(ws, 'session_update', params) }, async readTextFile(params) { - logWs.debug({ path: params.path }, "readTextFile"); - return { content: "" }; + logWs.debug({ path: params.path }, 'readTextFile') + return { content: '' } }, async writeTextFile(params) { - logWs.debug({ path: params.path }, "writeTextFile"); - return {}; + logWs.debug({ path: params.path }, 'writeTextFile') + return {} }, - }; + } } // Handle permission response from client -function handlePermissionResponse(ws: WSContext, payload: { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } }): void { - const state = clients.get(ws); +function handlePermissionResponse( + ws: WSContext, + payload: { + requestId: string + outcome: + | { outcome: 'cancelled' } + | { outcome: 'selected'; optionId: string } + }, +): void { + const state = clients.get(ws) if (!state) { - logPerm.warn("response from unknown client"); - return; + logPerm.warn('response from unknown client') + return } - const pending = state.pendingPermissions.get(payload.requestId); + const pending = state.pendingPermissions.get(payload.requestId) if (!pending) { - logPerm.warn({ requestId: payload.requestId }, "response for unknown request"); - return; + logPerm.warn( + { requestId: payload.requestId }, + 'response for unknown request', + ) + return } - clearTimeout(pending.timeout); - state.pendingPermissions.delete(payload.requestId); - pending.resolve(payload.outcome); + clearTimeout(pending.timeout) + state.pendingPermissions.delete(payload.requestId) + pending.resolve(payload.outcome) } // Cancel all pending permissions for a client (called on disconnect) function cancelPendingPermissions(clientState: ClientState): void { for (const [requestId, pending] of clientState.pendingPermissions) { - logPerm.debug({ requestId }, "cancelled on disconnect"); - clearTimeout(pending.timeout); - pending.resolve({ outcome: "cancelled" }); + logPerm.debug({ requestId }, 'cancelled on disconnect') + clearTimeout(pending.timeout) + pending.resolve({ outcome: 'cancelled' }) } - clientState.pendingPermissions.clear(); + clientState.pendingPermissions.clear() } async function handleConnect(ws: WSContext): Promise { - const state = clients.get(ws); - if (!state) return; + const state = clients.get(ws) + if (!state) return // If already connected to a running agent, just resend status // This handles frontend reconnections without restarting the agent process // Check both .killed and .exitCode to detect crashed processes - if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) { - logAgent.info("already connected, resending status"); - send(ws, "status", { + if ( + state.connection && + state.process && + !state.process.killed && + state.process.exitCode === null + ) { + logAgent.info('already connected, resending status') + send(ws, 'status', { connected: true, agentInfo: { name: AGENT_COMMAND }, capabilities: state.agentCapabilities, - }); - return; + }) + return } // Kill existing process if any (only if not healthy) if (state.process) { - cancelPendingPermissions(state); - state.process.kill(); - state.process = null; - state.connection = null; + cancelPendingPermissions(state) + state.process.kill() + state.process = null + state.connection = null } try { - logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, "spawning"); + logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, 'spawning') const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, { cwd: AGENT_CWD, - stdio: ["pipe", "pipe", "inherit"], + stdio: ['pipe', 'pipe', 'inherit'], env: buildAgentEnv(), - }); + }) - state.process = agentProcess; + state.process = agentProcess // Clean up state when agent process exits unexpectedly - agentProcess.on("exit", (code) => { - logAgent.info({ exitCode: code }, "agent process exited"); + agentProcess.on('exit', code => { + logAgent.info({ exitCode: code }, 'agent process exited') // Only clear if this is still the current process if (state.process === agentProcess) { - state.process = null; - state.connection = null; - state.sessionId = null; + state.process = null + state.connection = null + state.sessionId = null } - }); + }) - const input = Writable.toWeb(agentProcess.stdin!) as unknown as WritableStream; - const output = Readable.toWeb(agentProcess.stdout!) as unknown as ReadableStream; + const input = Writable.toWeb( + agentProcess.stdin!, + ) as unknown as WritableStream + const output = Readable.toWeb( + agentProcess.stdout!, + ) as unknown as ReadableStream - const stream = acp.ndJsonStream(input, output); + const stream = acp.ndJsonStream(input, output) const connection = new acp.ClientSideConnection( - (_agent) => createClient(ws, state), + _agent => createClient(ws, state), stream, - ); + ) - state.connection = connection; + state.connection = connection const initResult = await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, - clientInfo: { name: "zed", version: "1.0.0" }, + clientInfo: { name: 'zed', version: '1.0.0' }, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, }, - }); + }) - const agentCaps = initResult.agentCapabilities; - state.agentCapabilities = agentCaps ? { - _meta: agentCaps._meta, - loadSession: agentCaps.loadSession, - mcpCapabilities: agentCaps.mcpCapabilities, - promptCapabilities: agentCaps.promptCapabilities, - sessionCapabilities: agentCaps.sessionCapabilities, - } : null; - state.promptCapabilities = agentCaps?.promptCapabilities ?? null; + const agentCaps = initResult.agentCapabilities + state.agentCapabilities = agentCaps + ? { + _meta: agentCaps._meta, + loadSession: agentCaps.loadSession, + mcpCapabilities: agentCaps.mcpCapabilities, + promptCapabilities: agentCaps.promptCapabilities, + sessionCapabilities: agentCaps.sessionCapabilities, + } + : null + state.promptCapabilities = agentCaps?.promptCapabilities ?? null - logAgent.info({ - protocolVersion: initResult.protocolVersion, - loadSession: !!state.agentCapabilities?.loadSession, - sessionList: !!state.agentCapabilities?.sessionCapabilities?.list, - sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume, - hasMcp: !!state.agentCapabilities?.mcpCapabilities, - }, "initialized"); + logAgent.info( + { + protocolVersion: initResult.protocolVersion, + loadSession: !!state.agentCapabilities?.loadSession, + sessionList: !!state.agentCapabilities?.sessionCapabilities?.list, + sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume, + hasMcp: !!state.agentCapabilities?.mcpCapabilities, + }, + 'initialized', + ) - send(ws, "status", { + send(ws, 'status', { connected: true, agentInfo: initResult.agentInfo, capabilities: state.agentCapabilities, - }); + }) connection.closed.then(() => { - logAgent.info("connection closed"); - state.connection = null; - state.sessionId = null; - send(ws, "status", { connected: false }); - }); + logAgent.info('connection closed') + state.connection = null + state.sessionId = null + send(ws, 'status', { connected: false }) + }) } catch (error) { - logAgent.error({ error: (error as Error).message }, "connect failed"); - send(ws, "error", { message: `Failed to connect: ${(error as Error).message}` }); + logAgent.error({ error: (error as Error).message }, 'connect failed') + send(ws, 'error', { + message: `Failed to connect: ${(error as Error).message}`, + }) } } @@ -333,43 +365,60 @@ async function handleNewSession( ws: WSContext, params: { cwd?: string; permissionMode?: string }, ): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection) { - logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleNewSession: not connected to agent"); - send(ws, "error", { message: "Not connected to agent" }); - return; + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleNewSession: not connected to agent', + ) + send(ws, 'error', { message: 'Not connected to agent' }) + return } try { - const sessionCwd = params.cwd || AGENT_CWD; - let permissionMode: string | undefined; + const sessionCwd = params.cwd || AGENT_CWD + let permissionMode: string | undefined try { permissionMode = resolveNewSessionPermissionMode( params.permissionMode, DEFAULT_PERMISSION_MODE, - ); + ) } catch (error) { - send(ws, "error", { message: (error as Error).message }); - return; + send(ws, 'error', { message: (error as Error).message }) + return } const result = await state.connection.newSession({ cwd: sessionCwd, mcpServers: [], ...(permissionMode ? { _meta: { permissionMode } } : {}), - }); + }) - state.sessionId = result.sessionId; - state.modelState = result.models ?? null; - logSession.info({ sessionId: result.sessionId, cwd: sessionCwd, hasModels: !!result.models }, "created"); + state.sessionId = result.sessionId + state.modelState = result.models ?? null + logSession.info( + { + sessionId: result.sessionId, + cwd: sessionCwd, + hasModels: !!result.models, + }, + 'created', + ) - send(ws, "session_created", { + send(ws, 'session_created', { ...result, promptCapabilities: state.promptCapabilities, models: state.modelState, - }); + }) } catch (error) { - logSession.error({ error: (error as Error).message }, "create failed"); - send(ws, "error", { message: `Failed to create session: ${(error as Error).message}` }); + logSession.error({ error: (error as Error).message }, 'create failed') + send(ws, 'error', { + message: `Failed to create session: ${(error as Error).message}`, + }) } } @@ -382,29 +431,46 @@ async function handleListSessions( ws: WSContext, params: { cwd?: string; cursor?: string }, ): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection) { - logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleListSessions: not connected to agent"); - send(ws, "error", { message: "Not connected to agent" }); - return; + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleListSessions: not connected to agent', + ) + send(ws, 'error', { message: 'Not connected to agent' }) + return } if (!state.agentCapabilities?.sessionCapabilities?.list) { - send(ws, "error", { message: "Listing sessions is not supported by this agent" }); - return; + send(ws, 'error', { + message: 'Listing sessions is not supported by this agent', + }) + return } try { const result = await state.connection.listSessions({ cwd: params.cwd, cursor: params.cursor, - }); + }) - const MAX_SESSIONS = 20; - const sessions = result.sessions.slice(0, MAX_SESSIONS); - logSession.info({ total: result.sessions.length, returned: sessions.length, hasMore: !!result.nextCursor }, "listed"); + const MAX_SESSIONS = 20 + const sessions = result.sessions.slice(0, MAX_SESSIONS) + logSession.info( + { + total: result.sessions.length, + returned: sessions.length, + hasMore: !!result.nextCursor, + }, + 'listed', + ) - send(ws, "session_list", { + send(ws, 'session_list', { sessions: sessions.map((s: acp.SessionInfo) => ({ _meta: s._meta, cwd: s.cwd, @@ -414,10 +480,12 @@ async function handleListSessions( })), nextCursor: result.nextCursor, _meta: result._meta, - }); + }) } catch (error) { - logSession.error({ error: (error as Error).message }, "list failed"); - send(ws, "error", { message: `Failed to list sessions: ${(error as Error).message}` }); + logSession.error({ error: (error as Error).message }, 'list failed') + send(ws, 'error', { + message: `Failed to list sessions: ${(error as Error).message}`, + }) } } @@ -425,39 +493,51 @@ async function handleLoadSession( ws: WSContext, params: { sessionId: string; cwd?: string }, ): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection) { - logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleLoadSession: not connected to agent"); - send(ws, "error", { message: "Not connected to agent" }); - return; + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleLoadSession: not connected to agent', + ) + send(ws, 'error', { message: 'Not connected to agent' }) + return } if (!state.agentCapabilities?.loadSession) { - send(ws, "error", { message: "Loading sessions is not supported by this agent" }); - return; + send(ws, 'error', { + message: 'Loading sessions is not supported by this agent', + }) + return } try { - const sessionCwd = params.cwd || AGENT_CWD; - const sessionId = params.sessionId; + const sessionCwd = params.cwd || AGENT_CWD + const sessionId = params.sessionId const result = await state.connection.loadSession({ sessionId, cwd: sessionCwd, mcpServers: [], - }); + }) - state.sessionId = sessionId; - state.modelState = result.models ?? null; - logSession.info({ sessionId, cwd: sessionCwd }, "loaded"); + state.sessionId = sessionId + state.modelState = result.models ?? null + logSession.info({ sessionId, cwd: sessionCwd }, 'loaded') - send(ws, "session_loaded", { + send(ws, 'session_loaded', { sessionId, promptCapabilities: state.promptCapabilities, models: state.modelState, - }); + }) } catch (error) { - logSession.error({ error: (error as Error).message }, "load failed"); - send(ws, "error", { message: `Failed to load session: ${(error as Error).message}` }); + logSession.error({ error: (error as Error).message }, 'load failed') + send(ws, 'error', { + message: `Failed to load session: ${(error as Error).message}`, + }) } } @@ -465,38 +545,50 @@ async function handleResumeSession( ws: WSContext, params: { sessionId: string; cwd?: string }, ): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection) { - logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleResumeSession: not connected to agent"); - send(ws, "error", { message: "Not connected to agent" }); - return; + logAgent.warn( + { + hasState: !!state, + hasProcess: !!state?.process, + processKilled: state?.process?.killed, + exitCode: state?.process?.exitCode, + }, + 'handleResumeSession: not connected to agent', + ) + send(ws, 'error', { message: 'Not connected to agent' }) + return } if (!state.agentCapabilities?.sessionCapabilities?.resume) { - send(ws, "error", { message: "Resuming sessions is not supported by this agent" }); - return; + send(ws, 'error', { + message: 'Resuming sessions is not supported by this agent', + }) + return } try { - const sessionCwd = params.cwd || AGENT_CWD; - const sessionId = params.sessionId; + const sessionCwd = params.cwd || AGENT_CWD + const sessionId = params.sessionId const result = await state.connection.unstable_resumeSession({ sessionId, cwd: sessionCwd, - }); + }) - state.sessionId = sessionId; - state.modelState = result.models ?? null; - logSession.info({ sessionId, cwd: sessionCwd }, "resumed"); + state.sessionId = sessionId + state.modelState = result.models ?? null + logSession.info({ sessionId, cwd: sessionCwd }, 'resumed') - send(ws, "session_resumed", { + send(ws, 'session_resumed', { sessionId, promptCapabilities: state.promptCapabilities, models: state.modelState, - }); + }) } catch (error) { - logSession.error({ error: (error as Error).message }, "resume failed"); - send(ws, "error", { message: `Failed to resume session: ${(error as Error).message}` }); + logSession.error({ error: (error as Error).message }, 'resume failed') + send(ws, 'error', { + message: `Failed to resume session: ${(error as Error).message}`, + }) } } @@ -505,64 +597,67 @@ async function handlePrompt( ws: WSContext, params: { content: ContentBlock[] }, ): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection || !state.sessionId) { - send(ws, "error", { message: "No active session" }); - return; + send(ws, 'error', { message: 'No active session' }) + return } try { - const firstText = params.content.find(b => b.type === "text")?.text; - const images = params.content.filter(b => b.type === "image"); - logPrompt.debug({ - text: firstText?.slice(0, 100), - imageCount: images.length, - blockCount: params.content.length, - }, "sending"); + const firstText = params.content.find(b => b.type === 'text')?.text + const images = params.content.filter(b => b.type === 'image') + logPrompt.debug( + { + text: firstText?.slice(0, 100), + imageCount: images.length, + blockCount: params.content.length, + }, + 'sending', + ) const result = await state.connection.prompt({ sessionId: state.sessionId, prompt: params.content as acp.ContentBlock[], - }); + }) - logPrompt.info({ stopReason: result.stopReason }, "completed"); - send(ws, "prompt_complete", result); + logPrompt.info({ stopReason: result.stopReason }, 'completed') + send(ws, 'prompt_complete', result) } catch (error) { - logPrompt.error({ error: (error as Error).message }, "failed"); - send(ws, "error", { message: `Prompt failed: ${(error as Error).message}` }); + logPrompt.error({ error: (error as Error).message }, 'failed') + send(ws, 'error', { message: `Prompt failed: ${(error as Error).message}` }) } } function handleDisconnect(ws: WSContext): void { - const state = clients.get(ws); - if (!state) return; + const state = clients.get(ws) + if (!state) return if (state.process) { - state.process.kill(); - state.process = null; + state.process.kill() + state.process = null } - state.connection = null; - state.sessionId = null; + state.connection = null + state.sessionId = null - send(ws, "status", { connected: false }); + send(ws, 'status', { connected: false }) } // Handle cancel request from client async function handleCancel(ws: WSContext): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection || !state.sessionId) { - logWs.warn("cancel requested but no active session"); - return; + logWs.warn('cancel requested but no active session') + return } - logSession.info({ sessionId: state.sessionId }, "cancel requested"); - cancelPendingPermissions(state); + logSession.info({ sessionId: state.sessionId }, 'cancel requested') + cancelPendingPermissions(state) try { - await state.connection.cancel({ sessionId: state.sessionId }); - logSession.info({ sessionId: state.sessionId }, "cancel sent"); + await state.connection.cancel({ sessionId: state.sessionId }) + logSession.info({ sessionId: state.sessionId }, 'cancel sent') } catch (error) { - logSession.error({ error: (error as Error).message }, "cancel failed"); + logSession.error({ error: (error as Error).message }, 'cancel failed') } } @@ -571,66 +666,73 @@ async function handleSetSessionModel( ws: WSContext, params: { modelId: string }, ): Promise { - const state = clients.get(ws); + const state = clients.get(ws) if (!state?.connection || !state.sessionId) { - send(ws, "error", { message: "No active session" }); - return; + send(ws, 'error', { message: 'No active session' }) + return } if (!state.modelState) { - send(ws, "error", { message: "Model selection not supported by this agent" }); - return; + send(ws, 'error', { + message: 'Model selection not supported by this agent', + }) + return } try { - logSession.info({ sessionId: state.sessionId, modelId: params.modelId }, "setting model"); + logSession.info( + { sessionId: state.sessionId, modelId: params.modelId }, + 'setting model', + ) await state.connection.unstable_setSessionModel({ sessionId: state.sessionId, modelId: params.modelId, - }); - state.modelState = { ...state.modelState, currentModelId: params.modelId }; - send(ws, "model_changed", { modelId: params.modelId }); - logSession.info({ modelId: params.modelId }, "model changed"); + }) + state.modelState = { ...state.modelState, currentModelId: params.modelId } + send(ws, 'model_changed', { modelId: params.modelId }) + logSession.info({ modelId: params.modelId }, 'model changed') } catch (error) { - logSession.error({ error: (error as Error).message }, "set model failed"); - send(ws, "error", { message: `Failed to set model: ${(error as Error).message}` }); + logSession.error({ error: (error as Error).message }, 'set model failed') + send(ws, 'error', { + message: `Failed to set model: ${(error as Error).message}`, + }) } } // ContentBlock type matching @agentclientprotocol/sdk interface ContentBlock { - type: string; - text?: string; - data?: string; - mimeType?: string; - uri?: string; - name?: string; + type: string + text?: string + data?: string + mimeType?: string + uri?: string + name?: string } type PermissionResponsePayload = { - requestId: string; - outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }; -}; + requestId: string + outcome: { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } +} type ProxyMessage = - | { type: "connect" } - | { type: "disconnect" } - | { type: "new_session"; payload: { cwd?: string; permissionMode?: string } } - | { type: "prompt"; payload: { content: ContentBlock[] } } - | { type: "permission_response"; payload: PermissionResponsePayload } - | { type: "cancel" } - | { type: "set_session_model"; payload: { modelId: string } } - | { type: "list_sessions"; payload: { cwd?: string; cursor?: string } } - | { type: "load_session"; payload: { sessionId: string; cwd?: string } } - | { type: "resume_session"; payload: { sessionId: string; cwd?: string } } - | { type: "ping" }; + | { type: 'connect' } + | { type: 'disconnect' } + | { type: 'new_session'; payload: { cwd?: string; permissionMode?: string } } + | { type: 'prompt'; payload: { content: ContentBlock[] } } + | { type: 'permission_response'; payload: PermissionResponsePayload } + | { type: 'cancel' } + | { type: 'set_session_model'; payload: { modelId: string } } + | { type: 'list_sessions'; payload: { cwd?: string; cursor?: string } } + | { type: 'load_session'; payload: { sessionId: string; cwd?: string } } + | { type: 'resume_session'; payload: { sessionId: string; cwd?: string } } + | { type: 'ping' } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null && !Array.isArray(value) } function optionalString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; + return typeof value === 'string' ? value : undefined } function optionalStringField( @@ -638,117 +740,125 @@ function optionalStringField( key: string, source: string, ): string | undefined { - if (!Object.hasOwn(payload, key)) return undefined; - const value = payload[key]; - if (typeof value === "string") return value; - throw new Error(`Invalid ${source}: expected a string`); + if (!Object.hasOwn(payload, key)) return undefined + const value = payload[key] + if (typeof value === 'string') return value + throw new Error(`Invalid ${source}: expected a string`) } function payloadRecord(value: unknown, type: string): Record { if (!isRecord(value)) { - throw new Error(`Invalid ${type} payload`); + throw new Error(`Invalid ${type} payload`) } - return value; + return value } -function optionalPayloadRecord(value: unknown, type: string): Record { - if (value === undefined) return {}; - return payloadRecord(value, type); +function optionalPayloadRecord( + value: unknown, + type: string, +): Record { + if (value === undefined) return {} + return payloadRecord(value, type) } function optionalRecord(value: unknown): Record { - return isRecord(value) ? value : {}; + return isRecord(value) ? value : {} } function decodeContentBlocks(value: unknown): ContentBlock[] { if ( !Array.isArray(value) || - !value.every(block => isRecord(block) && typeof block.type === "string") + !value.every(block => isRecord(block) && typeof block.type === 'string') ) { - throw new Error("Invalid prompt payload"); + throw new Error('Invalid prompt payload') } - return value as ContentBlock[]; + return value as ContentBlock[] } -function decodePermissionResponsePayload(value: unknown): PermissionResponsePayload { - const payload = payloadRecord(value, "permission_response"); - if (typeof payload.requestId !== "string" || !isRecord(payload.outcome)) { - throw new Error("Invalid permission_response payload"); +function decodePermissionResponsePayload( + value: unknown, +): PermissionResponsePayload { + const payload = payloadRecord(value, 'permission_response') + if (typeof payload.requestId !== 'string' || !isRecord(payload.outcome)) { + throw new Error('Invalid permission_response payload') } - if (payload.outcome.outcome === "cancelled") { - return { requestId: payload.requestId, outcome: { outcome: "cancelled" } }; + if (payload.outcome.outcome === 'cancelled') { + return { requestId: payload.requestId, outcome: { outcome: 'cancelled' } } } if ( - payload.outcome.outcome === "selected" && - typeof payload.outcome.optionId === "string" + payload.outcome.outcome === 'selected' && + typeof payload.outcome.optionId === 'string' ) { return { requestId: payload.requestId, - outcome: { outcome: "selected", optionId: payload.outcome.optionId }, - }; + outcome: { outcome: 'selected', optionId: payload.outcome.optionId }, + } } - throw new Error("Invalid permission_response payload"); + throw new Error('Invalid permission_response payload') } function decodeClientMessage(message: Record): ProxyMessage { - if (typeof message.type !== "string") { - throw new Error("Invalid WebSocket message payload"); + if (typeof message.type !== 'string') { + throw new Error('Invalid WebSocket message payload') } switch (message.type) { - case "connect": - case "disconnect": - case "cancel": - case "ping": - return { type: message.type }; - case "new_session": { - const payload = optionalPayloadRecord(message.payload, "new_session"); + case 'connect': + case 'disconnect': + case 'cancel': + case 'ping': + return { type: message.type } + case 'new_session': { + const payload = optionalPayloadRecord(message.payload, 'new_session') return { - type: "new_session", + type: 'new_session', payload: { - cwd: optionalStringField(payload, "cwd", "new_session.cwd"), + cwd: optionalStringField(payload, 'cwd', 'new_session.cwd'), permissionMode: optionalStringField( payload, - "permissionMode", - "new_session.permissionMode", + 'permissionMode', + 'new_session.permissionMode', ), }, - }; - } - case "prompt": { - const payload = payloadRecord(message.payload, "prompt"); - return { - type: "prompt", - payload: { content: decodeContentBlocks(payload.content) }, - }; - } - case "permission_response": - return { - type: "permission_response", - payload: decodePermissionResponsePayload(message.payload), - }; - case "set_session_model": { - const payload = payloadRecord(message.payload, "set_session_model"); - if (typeof payload.modelId !== "string") { - throw new Error("Invalid set_session_model payload"); } - return { type: "set_session_model", payload: { modelId: payload.modelId } }; } - case "list_sessions": { - const payload = optionalRecord(message.payload); + case 'prompt': { + const payload = payloadRecord(message.payload, 'prompt') return { - type: "list_sessions", + type: 'prompt', + payload: { content: decodeContentBlocks(payload.content) }, + } + } + case 'permission_response': + return { + type: 'permission_response', + payload: decodePermissionResponsePayload(message.payload), + } + case 'set_session_model': { + const payload = payloadRecord(message.payload, 'set_session_model') + if (typeof payload.modelId !== 'string') { + throw new Error('Invalid set_session_model payload') + } + return { + type: 'set_session_model', + payload: { modelId: payload.modelId }, + } + } + case 'list_sessions': { + const payload = optionalRecord(message.payload) + return { + type: 'list_sessions', payload: { cwd: optionalString(payload.cwd), cursor: optionalString(payload.cursor), }, - }; + } } - case "load_session": - case "resume_session": { - const payload = payloadRecord(message.payload, message.type); - if (typeof payload.sessionId !== "string") { - throw new Error(`Invalid ${message.type} payload`); + case 'load_session': + case 'resume_session': { + const payload = payloadRecord(message.payload, message.type) + if (typeof payload.sessionId !== 'string') { + throw new Error(`Invalid ${message.type} payload`) } return { type: message.type, @@ -756,72 +866,72 @@ function decodeClientMessage(message: Record): ProxyMessage { sessionId: payload.sessionId, cwd: optionalString(payload.cwd), }, - }; + } } default: - throw new Error(`Unknown message type: ${message.type}`); + throw new Error(`Unknown message type: ${message.type}`) } } export function decodeClientWsMessage(data: unknown): ProxyMessage { - return decodeClientMessage(decodeJsonWsMessage(data)); + return decodeClientMessage(decodeJsonWsMessage(data)) } -async function dispatchClientMessage(ws: WSContext, data: ProxyMessage): Promise { +async function dispatchClientMessage( + ws: WSContext, + data: ProxyMessage, +): Promise { switch (data.type) { - case "connect": - await handleConnect(ws); - break; - case "disconnect": - handleDisconnect(ws); - break; - case "new_session": - await handleNewSession(ws, data.payload); - break; - case "prompt": - await handlePrompt(ws, data.payload); - break; - case "permission_response": - handlePermissionResponse(ws, data.payload); - break; - case "cancel": - await handleCancel(ws); - break; - case "set_session_model": - await handleSetSessionModel(ws, data.payload); - break; - case "list_sessions": - await handleListSessions(ws, data.payload); - break; - case "load_session": - await handleLoadSession(ws, data.payload); - break; - case "resume_session": - await handleResumeSession(ws, data.payload); - break; - case "ping": - send(ws, "pong"); - break; + case 'connect': + await handleConnect(ws) + break + case 'disconnect': + handleDisconnect(ws) + break + case 'new_session': + await handleNewSession(ws, data.payload) + break + case 'prompt': + await handlePrompt(ws, data.payload) + break + case 'permission_response': + handlePermissionResponse(ws, data.payload) + break + case 'cancel': + await handleCancel(ws) + break + case 'set_session_model': + await handleSetSessionModel(ws, data.payload) + break + case 'list_sessions': + await handleListSessions(ws, data.payload) + break + case 'load_session': + await handleLoadSession(ws, data.payload) + break + case 'resume_session': + await handleResumeSession(ws, data.payload) + break + case 'ping': + send(ws, 'pong') + break } } export const __testing = { - dispatchClientMessage( - ws: WSContext, - data: unknown, - ): Promise { - assertTestingInternalsEnabled(); - return dispatchClientMessage(ws, data as ProxyMessage); + dispatchClientMessage(ws: WSContext, data: unknown): Promise { + assertTestingInternalsEnabled() + return dispatchClientMessage(ws, data as ProxyMessage) }, registerClient( ws: WSContext, state: { - connection?: unknown; - process?: ChildProcess | null; - sessionId?: string | null; + connection?: unknown + process?: ChildProcess | null + sessionId?: string | null }, ): () => void { - assertTestingInternalsEnabled(); + assertTestingInternalsEnabled() clients.set(ws, { process: state.process ?? null, connection: (state.connection ?? null) as acp.ClientSideConnection | null, @@ -831,133 +941,136 @@ export const __testing = { promptCapabilities: null, modelState: null, isAlive: true, - }); + }) return () => { - clients.delete(ws); - }; + clients.delete(ws) + } }, getClientSessionId(ws: WSContext): string | null | undefined { - assertTestingInternalsEnabled(); - return clients.get(ws)?.sessionId; + assertTestingInternalsEnabled() + return clients.get(ws)?.sessionId }, setDefaultPermissionMode(mode: string | undefined): () => void { - assertTestingInternalsEnabled(); - const previous = DEFAULT_PERMISSION_MODE; - DEFAULT_PERMISSION_MODE = mode; + assertTestingInternalsEnabled() + const previous = DEFAULT_PERMISSION_MODE + DEFAULT_PERMISSION_MODE = mode return () => { - DEFAULT_PERMISSION_MODE = previous; - }; + DEFAULT_PERMISSION_MODE = previous + } }, -}; +} function assertTestingInternalsEnabled(): void { - if (process.env.ACP_LINK_TEST_INTERNALS === "1") { - return; + if (process.env.ACP_LINK_TEST_INTERNALS === '1') { + return } throw new Error( - "acp-link test internals are disabled outside test execution.", - ); + 'acp-link test internals are disabled outside test execution.', + ) } const ACP_LINK_PERMISSION_MODE_ALIASES = { - auto: "auto", - default: "default", - acceptedits: "acceptEdits", - dontask: "dontAsk", - plan: "plan", - bypasspermissions: "bypassPermissions", - bypass: "bypassPermissions", -} as const; + auto: 'auto', + default: 'default', + acceptedits: 'acceptEdits', + dontask: 'dontAsk', + plan: 'plan', + bypasspermissions: 'bypassPermissions', + bypass: 'bypassPermissions', +} as const type AcpLinkPermissionMode = - (typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES]; + (typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES] export function resolveNewSessionPermissionMode( requestedMode: string | undefined, defaultMode: string | undefined, ): string | undefined { - const requested = resolveAcpLinkPermissionMode(requestedMode); - const localDefault = resolveAcpLinkPermissionMode(defaultMode); + const requested = resolveAcpLinkPermissionMode(requestedMode) + const localDefault = resolveAcpLinkPermissionMode(defaultMode) if (!requested) { - return localDefault; + return localDefault } - if (requested !== "bypassPermissions") { - return requested; + if (requested !== 'bypassPermissions') { + return requested } - if (localDefault === "bypassPermissions") { - return "bypassPermissions"; + if (localDefault === 'bypassPermissions') { + return 'bypassPermissions' } throw new Error( - "bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.", - ); + 'bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.', + ) } function resolveAcpLinkPermissionMode( mode: string | undefined, ): AcpLinkPermissionMode | undefined { - if (mode === undefined) return undefined; + if (mode === undefined) return undefined - const normalized = mode?.trim().toLowerCase(); + const normalized = mode?.trim().toLowerCase() if (!normalized) { - throw new Error("Invalid permissionMode: expected a non-empty string."); + throw new Error('Invalid permissionMode: expected a non-empty string.') } const resolved = ACP_LINK_PERMISSION_MODE_ALIASES[ normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES - ]; + ] if (!resolved) { - throw new Error(`Invalid permissionMode: ${mode}.`); + throw new Error(`Invalid permissionMode: ${mode}.`) } - return resolved; + return resolved } function buildAgentEnv(): NodeJS.ProcessEnv { if (!DEFAULT_PERMISSION_MODE) { - return process.env; + return process.env } return { ...process.env, ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE, - }; + } } export async function startServer(config: ServerConfig): Promise { - const { port, host, command, args, cwd, token, https } = config; + const { port, host, command, args, cwd, token, https } = config // Set module-level config - AGENT_COMMAND = command; - AGENT_ARGS = args; - AGENT_CWD = cwd; - SERVER_PORT = port; - SERVER_HOST = host; - AUTH_TOKEN = token; - DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE; + AGENT_COMMAND = command + AGENT_ARGS = args + AGENT_CWD = cwd + SERVER_PORT = port + SERVER_HOST = host + AUTH_TOKEN = token + DEFAULT_PERMISSION_MODE = + config.permissionMode || process.env.ACP_PERMISSION_MODE // Initialize RCS upstream client if configured - const rcsUrl = process.env.ACP_RCS_URL; - const rcsToken = process.env.ACP_RCS_TOKEN; - const rcsGroup = config.group || process.env.ACP_RCS_GROUP; + const rcsUrl = process.env.ACP_RCS_URL + const rcsToken = process.env.ACP_RCS_TOKEN + const rcsGroup = config.group || process.env.ACP_RCS_GROUP if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) { - throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`); + throw new Error( + `Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`, + ) } if (rcsUrl) { rcsUpstream = new RcsUpstreamClient({ rcsUrl, - apiToken: rcsToken || "", + apiToken: rcsToken || '', agentName: command, channelGroupId: rcsGroup || undefined, maxSessions: 1, - }); + }) - const relayWs = createRelayWs(); + const relayWs = createRelayWs() const relayState: ClientState = { process: null, connection: null, @@ -967,57 +1080,60 @@ export async function startServer(config: ServerConfig): Promise { promptCapabilities: null, modelState: null, isAlive: true, - }; - clients.set(relayWs, relayState); + } + clients.set(relayWs, relayState) - rcsUpstream.setMessageHandler(async (msg) => { + rcsUpstream.setMessageHandler(async msg => { try { - const data = decodeClientMessage(msg); - logRelay.debug({ type: data.type }, "processing"); - await dispatchClientMessage(relayWs, data); + const data = decodeClientMessage(msg) + logRelay.debug({ type: data.type }, 'processing') + await dispatchClientMessage(relayWs, data) } catch (error) { - logRelay.error({ error: (error as Error).message }, "handler error"); + logRelay.error({ error: (error as Error).message }, 'handler error') } - }); + }) - rcsUpstream.connect().catch((err) => { - logRelay.warn({ error: (err as Error).message }, "initial connection failed"); - }); - logRelay.info({ url: rcsUrl }, "upstream enabled"); + rcsUpstream.connect().catch(err => { + logRelay.warn( + { error: (err as Error).message }, + 'initial connection failed', + ) + }) + logRelay.info({ url: rcsUrl }, 'upstream enabled') } - const app = new Hono(); - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + const app = new Hono() + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) // Health check endpoint - app.get("/health", (c) => { - return c.json({ status: "ok" }); - }); + app.get('/health', c => { + return c.json({ status: 'ok' }) + }) // WebSocket endpoint with token validation app.get( - "/ws", - upgradeWebSocket((c) => { + '/ws', + upgradeWebSocket(c => { if (AUTH_TOKEN) { const providedToken = extractWebSocketAuthToken({ - authorization: c.req.header("Authorization"), - protocol: c.req.header("Sec-WebSocket-Protocol"), - }); + authorization: c.req.header('Authorization'), + protocol: c.req.header('Sec-WebSocket-Protocol'), + }) if (!authTokensEqual(providedToken, AUTH_TOKEN)) { - logWs.warn("connection rejected: invalid token"); + logWs.warn('connection rejected: invalid token') return { onOpen(_event, ws) { - ws.close(4001, "Unauthorized: Invalid token"); + ws.close(4001, 'Unauthorized: Invalid token') }, onMessage() {}, onClose() {}, - }; + } } } return { onOpen(_event, ws) { - logWs.info("client connected"); + logWs.info('client connected') const state: ClientState = { process: null, connection: null, @@ -1027,141 +1143,145 @@ export async function startServer(config: ServerConfig): Promise { promptCapabilities: null, modelState: null, isAlive: true, - }; - clients.set(ws, state); + } + clients.set(ws, state) - const rawWs = ws.raw as RawWebSocket; - rawWs.on("pong", () => { - state.isAlive = true; - }); + const rawWs = ws.raw as RawWebSocket + rawWs.on('pong', () => { + state.isAlive = true + }) }, async onMessage(event, ws) { try { - const data = decodeClientWsMessage(event.data); - logWs.debug({ type: data.type }, "received"); - await dispatchClientMessage(ws, data); + const data = decodeClientWsMessage(event.data) + logWs.debug({ type: data.type }, 'received') + await dispatchClientMessage(ws, data) } catch (error) { if (error instanceof WsPayloadTooLargeError) { - logWs.warn({ error: error.message }, "message too large"); - ws.close(1009, "message too large"); - return; + logWs.warn({ error: error.message }, 'message too large') + ws.close(1009, 'message too large') + return } - logWs.error({ error: (error as Error).message }, "message error"); - send(ws, "error", { message: `Error: ${(error as Error).message}` }); + logWs.error({ error: (error as Error).message }, 'message error') + send(ws, 'error', { message: `Error: ${(error as Error).message}` }) } }, onClose(_event, ws) { - logWs.info("client disconnected"); - const state = clients.get(ws); + logWs.info('client disconnected') + const state = clients.get(ws) if (state) { - cancelPendingPermissions(state); + cancelPendingPermissions(state) } - handleDisconnect(ws); - clients.delete(ws); + handleDisconnect(ws) + clients.delete(ws) }, - }; + } }), - ); + ) // Create server with optional HTTPS - let server; + let server if (https) { - const tlsOptions = await getOrCreateCertificate(); + const tlsOptions = await getOrCreateCertificate() server = serve({ fetch: app.fetch, port, hostname: host, createServer: createHttpsServer, serverOptions: tlsOptions, - }); + }) } else { - server = serve({ fetch: app.fetch, port, hostname: host }); + server = serve({ fetch: app.fetch, port, hostname: host }) } - injectWebSocket(server); + injectWebSocket(server) // Heartbeat: periodically ping all connected clients setInterval(() => { for (const [ws, state] of clients) { // Skip virtual relay connections (no raw socket, always alive) - if (!ws.raw && state.isAlive) continue; + if (!ws.raw && state.isAlive) continue if (!ws.raw) { // Connection already closed, clean up - clients.delete(ws); - continue; + clients.delete(ws) + continue } if (!state.isAlive) { - logWs.info("heartbeat timeout, terminating"); - (ws.raw as RawWebSocket).terminate(); - continue; + logWs.info('heartbeat timeout, terminating') + ;(ws.raw as RawWebSocket).terminate() + continue } - state.isAlive = false; - (ws.raw as RawWebSocket).ping(); + state.isAlive = false + ;(ws.raw as RawWebSocket).ping() } - }, HEARTBEAT_INTERVAL_MS); + }, HEARTBEAT_INTERVAL_MS) // Protocol strings based on HTTPS mode - const wsProtocol = https ? "wss" : "ws"; + const wsProtocol = https ? 'wss' : 'ws' // Get actual LAN IP when binding to 0.0.0.0 - let displayHost = host; - if (host === "0.0.0.0") { - const lanIPs = getLanIPs(); - displayHost = lanIPs[0] || "localhost"; + let displayHost = host + if (host === '0.0.0.0') { + const lanIPs = getLanIPs() + displayHost = lanIPs[0] || 'localhost' } // Build URLs - const localWsUrl = `${wsProtocol}://localhost:${port}/ws`; - const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`; + const localWsUrl = `${wsProtocol}://localhost:${port}/ws` + const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws` // Print startup banner - console.log(); - console.log(` 🚀 ACP Proxy Server${https ? " (HTTPS)" : ""}`); - console.log(); - console.log(` Connection:`); - if (host === "0.0.0.0") { - console.log(` URL: ${networkWsUrl}`); + console.log() + console.log(` 🚀 ACP Proxy Server${https ? ' (HTTPS)' : ''}`) + console.log() + console.log(` Connection:`) + if (host === '0.0.0.0') { + console.log(` URL: ${networkWsUrl}`) } else { - console.log(` URL: ${localWsUrl}`); + console.log(` URL: ${localWsUrl}`) } if (AUTH_TOKEN) { - console.log(` Token: configured`); + console.log(` Token: configured`) } - console.log(); + console.log() if (!AUTH_TOKEN) { - console.log(` ⚠️ Authentication disabled (--no-auth)`); - console.log(); + console.log(` ⚠️ Authentication disabled (--no-auth)`) + console.log() } - const agentDisplay = AGENT_ARGS.length > 0 - ? `${AGENT_COMMAND} ${AGENT_ARGS.join(" ")}` - : AGENT_COMMAND; - console.log(` 📦 Agent: ${agentDisplay}`); - console.log(` CWD: ${AGENT_CWD}`); - console.log(); - console.log(` Press Ctrl+C to stop`); - console.log(); + const agentDisplay = + AGENT_ARGS.length > 0 + ? `${AGENT_COMMAND} ${AGENT_ARGS.join(' ')}` + : AGENT_COMMAND + console.log(` 📦 Agent: ${agentDisplay}`) + console.log(` CWD: ${AGENT_CWD}`) + console.log() + console.log(` Press Ctrl+C to stop`) + console.log() - logServer.info({ - port, - host, - https, - wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`, - agent: AGENT_COMMAND, - agentArgs: AGENT_ARGS, - cwd: AGENT_CWD, - authEnabled: !!AUTH_TOKEN, - }, "started"); + logServer.info( + { + port, + host, + https, + wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`, + agent: AGENT_COMMAND, + agentArgs: AGENT_ARGS, + cwd: AGENT_CWD, + authEnabled: !!AUTH_TOKEN, + }, + 'started', + ) // Graceful shutdown — close RCS upstream const shutdown = async () => { if (rcsUpstream) { - await rcsUpstream.close(); + await rcsUpstream.close() } - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) // Keep the server running - await new Promise(() => {}); + await new Promise(() => {}) } diff --git a/packages/acp-link/src/types.ts b/packages/acp-link/src/types.ts index 3d898c70b..4db5a4f10 100644 --- a/packages/acp-link/src/types.ts +++ b/packages/acp-link/src/types.ts @@ -1,150 +1,150 @@ // JSON-RPC 2.0 Types export interface JsonRpcRequest { - jsonrpc: "2.0"; - id: string | number; - method: string; - params?: unknown; + jsonrpc: '2.0' + id: string | number + method: string + params?: unknown } export interface JsonRpcResponse { - jsonrpc: "2.0"; - id: string | number; - result?: unknown; - error?: JsonRpcError; + jsonrpc: '2.0' + id: string | number + result?: unknown + error?: JsonRpcError } export interface JsonRpcNotification { - jsonrpc: "2.0"; - method: string; - params?: unknown; + jsonrpc: '2.0' + method: string + params?: unknown } export interface JsonRpcError { - code: number; - message: string; - data?: unknown; + code: number + message: string + data?: unknown } export type JsonRpcMessage = | JsonRpcRequest | JsonRpcResponse - | JsonRpcNotification; + | JsonRpcNotification // Helper to check message types export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest { - return "method" in msg && "id" in msg; + return 'method' in msg && 'id' in msg } export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { - return "id" in msg && !("method" in msg); + return 'id' in msg && !('method' in msg) } export function isNotification( msg: JsonRpcMessage, ): msg is JsonRpcNotification { - return "method" in msg && !("id" in msg); + return 'method' in msg && !('id' in msg) } // ACP Protocol Types // Client -> Server messages (from extension to proxy) export interface ProxyConnectParams { - command: string; // Command to launch the agent (e.g., "claude-agent") - args?: string[]; // Optional arguments - cwd?: string; // Working directory for the agent + command: string // Command to launch the agent (e.g., "claude-agent") + args?: string[] // Optional arguments + cwd?: string // Working directory for the agent } export interface ProxyMessage { - type: "connect" | "disconnect" | "message"; - payload?: ProxyConnectParams | JsonRpcMessage; + type: 'connect' | 'disconnect' | 'message' + payload?: ProxyConnectParams | JsonRpcMessage } // Server -> Client messages (from proxy to extension) export interface ProxyStatus { - type: "status"; - connected: boolean; + type: 'status' + connected: boolean agentInfo?: { - name?: string; - version?: string; - }; - error?: string; + name?: string + version?: string + } + error?: string } export interface ProxyAgentMessage { - type: "agent_message"; - payload: JsonRpcMessage; + type: 'agent_message' + payload: JsonRpcMessage } export interface ProxyError { - type: "error"; - message: string; - code?: string; + type: 'error' + message: string + code?: string } -export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError; +export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError // ACP Initialization export interface InitializeParams { - protocolVersion: string; + protocolVersion: string clientInfo: { - name: string; - version: string; - }; - capabilities?: ClientCapabilities; + name: string + version: string + } + capabilities?: ClientCapabilities } export interface ClientCapabilities { - streaming?: boolean; - toolApproval?: boolean; + streaming?: boolean + toolApproval?: boolean } export interface InitializeResult { - protocolVersion: string; + protocolVersion: string serverInfo: { - name: string; - version: string; - }; - capabilities?: ServerCapabilities; + name: string + version: string + } + capabilities?: ServerCapabilities } export interface ServerCapabilities { - streaming?: boolean; - tools?: boolean; + streaming?: boolean + tools?: boolean } // ACP Session export interface SessionSetupParams { - sessionId?: string; - context?: SessionContext; + sessionId?: string + context?: SessionContext } export interface SessionContext { - workingDirectory?: string; - files?: string[]; + workingDirectory?: string + files?: string[] } // ACP Prompt export interface PromptParams { - sessionId: string; - messages: PromptMessage[]; + sessionId: string + messages: PromptMessage[] } export interface PromptMessage { - role: "user" | "assistant"; - content: string | ContentPart[]; + role: 'user' | 'assistant' + content: string | ContentPart[] } export interface ContentPart { - type: "text" | "image" | "file"; - text?: string; - data?: string; - mimeType?: string; - path?: string; + type: 'text' | 'image' | 'file' + text?: string + data?: string + mimeType?: string + path?: string } // Content streaming notification export interface ContentNotification { - sessionId: string; - content: string; - done?: boolean; + sessionId: string + content: string + done?: boolean } diff --git a/packages/acp-link/src/ws-auth.ts b/packages/acp-link/src/ws-auth.ts index 37e2d0dcd..1f670e867 100644 --- a/packages/acp-link/src/ws-auth.ts +++ b/packages/acp-link/src/ws-auth.ts @@ -1,54 +1,60 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { createHash, timingSafeEqual } from 'node:crypto' -const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth."; +const WS_AUTH_PROTOCOL_PREFIX = 'rcs.auth.' function sha256(value: string): Buffer { - return createHash("sha256").update(value).digest(); + return createHash('sha256').update(value).digest() } export function encodeWebSocketAuthProtocol(token: string): string { - return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`; + return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, 'utf8').toString('base64url')}` } -export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined { +export function decodeWebSocketAuthProtocol( + protocolHeader: string | undefined, +): string | undefined { if (!protocolHeader) { - return undefined; + return undefined } - for (const protocol of protocolHeader.split(",")) { - const trimmed = protocol.trim(); + for (const protocol of protocolHeader.split(',')) { + const trimmed = protocol.trim() if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) { - continue; + continue } - const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length); + const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length) if (!encoded) { - return undefined; + return undefined } try { - const token = Buffer.from(encoded, "base64url").toString("utf8"); - return token.length > 0 ? token : undefined; + const token = Buffer.from(encoded, 'base64url').toString('utf8') + return token.length > 0 ? token : undefined } catch { - return undefined; + return undefined } } - return undefined; + return undefined } -export function extractBearerToken(authorizationHeader: string | undefined): string | undefined { - return authorizationHeader?.startsWith("Bearer ") - ? authorizationHeader.slice("Bearer ".length) - : undefined; +export function extractBearerToken( + authorizationHeader: string | undefined, +): string | undefined { + return authorizationHeader?.startsWith('Bearer ') + ? authorizationHeader.slice('Bearer '.length) + : undefined } export function extractWebSocketAuthToken(headers: { - authorization?: string; - protocol?: string; + authorization?: string + protocol?: string }): string | undefined { - return extractBearerToken(headers.authorization) ?? - decodeWebSocketAuthProtocol(headers.protocol); + return ( + extractBearerToken(headers.authorization) ?? + decodeWebSocketAuthProtocol(headers.protocol) + ) } export function authTokensEqual( @@ -56,7 +62,7 @@ export function authTokensEqual( expectedToken: string | undefined, ): boolean { if (!providedToken || !expectedToken) { - return false; + return false } - return timingSafeEqual(sha256(providedToken), sha256(expectedToken)); + return timingSafeEqual(sha256(providedToken), sha256(expectedToken)) } diff --git a/packages/acp-link/src/ws-message.ts b/packages/acp-link/src/ws-message.ts index 5b65c57f3..4b506f10c 100644 --- a/packages/acp-link/src/ws-message.ts +++ b/packages/acp-link/src/ws-message.ts @@ -1,60 +1,63 @@ -export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024; +export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024 export class WsPayloadTooLargeError extends Error { constructor(byteLength: number) { - super(`WebSocket message too large: ${byteLength} bytes`); - this.name = "WsPayloadTooLargeError"; + super(`WebSocket message too large: ${byteLength} bytes`) + this.name = 'WsPayloadTooLargeError' } } export interface JsonWsMessage { - type: string; - payload?: unknown; - [key: string]: unknown; + type: string + payload?: unknown + [key: string]: unknown } function assertPayloadSize(byteLength: number): void { if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) { - throw new WsPayloadTooLargeError(byteLength); + throw new WsPayloadTooLargeError(byteLength) } } function decodeWsText(data: unknown): string { - if (typeof data === "string") { - assertPayloadSize(Buffer.byteLength(data, "utf8")); - return data; + if (typeof data === 'string') { + assertPayloadSize(Buffer.byteLength(data, 'utf8')) + return data } if (data instanceof ArrayBuffer) { - assertPayloadSize(data.byteLength); - return new TextDecoder().decode(new Uint8Array(data)); + assertPayloadSize(data.byteLength) + return new TextDecoder().decode(new Uint8Array(data)) } if (ArrayBuffer.isView(data)) { - assertPayloadSize(data.byteLength); + assertPayloadSize(data.byteLength) return new TextDecoder().decode( new Uint8Array(data.buffer, data.byteOffset, data.byteLength), - ); + ) } if (Array.isArray(data) && data.every(Buffer.isBuffer)) { - const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0); - assertPayloadSize(byteLength); - return Buffer.concat(data, byteLength).toString("utf8"); + const byteLength = data.reduce( + (total, chunk) => total + chunk.byteLength, + 0, + ) + assertPayloadSize(byteLength) + return Buffer.concat(data, byteLength).toString('utf8') } - throw new Error("Unsupported WebSocket message payload"); + throw new Error('Unsupported WebSocket message payload') } export function decodeJsonWsMessage(data: unknown): JsonWsMessage { - const parsed = JSON.parse(decodeWsText(data)) as unknown; + const parsed = JSON.parse(decodeWsText(data)) as unknown if ( - typeof parsed !== "object" || + typeof parsed !== 'object' || parsed === null || - !("type" in parsed) || - typeof parsed.type !== "string" + !('type' in parsed) || + typeof parsed.type !== 'string' ) { - throw new Error("Invalid WebSocket message payload"); + throw new Error('Invalid WebSocket message payload') } - return parsed as JsonWsMessage; + return parsed as JsonWsMessage } diff --git a/packages/acp-link/tsconfig.json b/packages/acp-link/tsconfig.json index cff6d9cb6..2646912f6 100644 --- a/packages/acp-link/tsconfig.json +++ b/packages/acp-link/tsconfig.json @@ -31,7 +31,7 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, - "types": ["bun"], + "types": ["bun"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/__tests__"] diff --git a/packages/agent-tools/src/__tests__/compat.test.ts b/packages/agent-tools/src/__tests__/compat.test.ts index 752043f0f..80f13e18f 100644 --- a/packages/agent-tools/src/__tests__/compat.test.ts +++ b/packages/agent-tools/src/__tests__/compat.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from 'bun:test' -import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools' +import type { + CoreTool, + Tool, + Tools, + AnyObject, + ToolResult, + ValidationResult, + PermissionResult, +} from '@claude-code-best/agent-tools' import type { Tool as HostTool } from '../../../../src/Tool.js' describe('agent-tools compatibility', () => { @@ -12,17 +20,29 @@ describe('agent-tools compatibility', () => { aliases: [], searchHint: 'test tool', inputSchema: {} as any, - async call() { return { data: 'ok' } as any }, - async description() { return 'test' }, - async prompt() { return 'test prompt' }, + async call() { + return { data: 'ok' } as any + }, + async description() { + return 'test' + }, + async prompt() { + return 'test prompt' + }, isConcurrencySafe: () => false, isEnabled: () => true, isReadOnly: () => false, - async checkPermissions() { return { behavior: 'allow' as const, updatedInput: {} } }, + async checkPermissions() { + return { behavior: 'allow' as const, updatedInput: {} } + }, toAutoClassifierInput: () => '', userFacingName: () => 'test', maxResultSizeChars: 100000, - mapToolResultToToolResultBlockParam: () => ({ type: 'tool_result', tool_use_id: '1', content: 'ok' }), + mapToolResultToToolResultBlockParam: () => ({ + type: 'tool_result', + tool_use_id: '1', + content: 'ok', + }), renderToolUseMessage: () => null, } diff --git a/packages/agent-tools/src/__tests__/registry.test.ts b/packages/agent-tools/src/__tests__/registry.test.ts index c35aa9d1e..a53602bb6 100644 --- a/packages/agent-tools/src/__tests__/registry.test.ts +++ b/packages/agent-tools/src/__tests__/registry.test.ts @@ -12,8 +12,12 @@ describe('toolMatchesName', () => { }) test('matches alias', () => { - expect(toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'shell')).toBe(true) - expect(toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'sh')).toBe(true) + expect( + toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'shell'), + ).toBe(true) + expect( + toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'sh'), + ).toBe(true) }) test('handles empty aliases', () => { diff --git a/packages/agent-tools/src/types.ts b/packages/agent-tools/src/types.ts index 611be167e..5058986ec 100644 --- a/packages/agent-tools/src/types.ts +++ b/packages/agent-tools/src/types.ts @@ -186,10 +186,7 @@ export interface CoreTool< // ── Output ── maxResultSizeChars: number userFacingName(input: Partial> | undefined): string - mapToolResultToToolResultBlockParam( - content: Output, - toolUseID: string, - ): any + mapToolResultToToolResultBlockParam(content: Output, toolUseID: string): any // ── Optional output helpers ── isResultTruncated?(output: Output): boolean diff --git a/packages/audio-capture-napi/package.json b/packages/audio-capture-napi/package.json index dbc43a288..29068cee5 100644 --- a/packages/audio-capture-napi/package.json +++ b/packages/audio-capture-napi/package.json @@ -1,8 +1,8 @@ { - "name": "audio-capture-napi", - "version": "1.0.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts" + "name": "audio-capture-napi", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts" } diff --git a/packages/audio-capture-napi/src/index.ts b/packages/audio-capture-napi/src/index.ts index b59199fec..6db81d7ad 100644 --- a/packages/audio-capture-napi/src/index.ts +++ b/packages/audio-capture-napi/src/index.ts @@ -29,10 +29,7 @@ function getVendorRoot(): string { } type AudioCaptureNapi = { - startRecording( - onData: (data: Buffer) => void, - onEnd: () => void, - ): boolean + startRecording(onData: (data: Buffer) => void, onEnd: () => void): boolean stopRecording(): void isRecording(): boolean startPlayback(sampleRate: number, channels: number): boolean diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index 8609978c6..e02824d0b 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -64,7 +64,13 @@ export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js' export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js' // Constants -export { SYNTHETIC_OUTPUT_TOOL_NAME, createSyntheticOutputTool } from './tools/SyntheticOutputTool/SyntheticOutputTool.js' +export { + SYNTHETIC_OUTPUT_TOOL_NAME, + createSyntheticOutputTool, +} from './tools/SyntheticOutputTool/SyntheticOutputTool.js' // Shared utilities -export { tagMessagesWithToolUseID, getToolUseIDFromParentMessage } from './tools/utils.js' +export { + tagMessagesWithToolUseID, + getToolUseIDFromParentMessage, +} from './tools/utils.js' diff --git a/packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx b/packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx index 82b729386..378a9078b 100644 --- a/packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx +++ b/packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx @@ -1,29 +1,19 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js' -import type { - AssistantMessage, - Message as MessageType, - NormalizedUserMessage, -} from 'src/types/message.js' -import { getQuerySourceForAgent } from 'src/utils/promptCategory.js' -import { z } from 'zod/v4' -import { - clearInvokedSkillsForAgent, - getSdkAgentProgressSummariesEnabled, -} from 'src/bootstrap/state.js' -import { - enhanceSystemPromptWithEnvDetails, - getSystemPrompt, -} from 'src/constants/prompts.js' -import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js' -import { startAgentSummarization } from 'src/services/AgentSummary/agentSummary.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'; +import type { AssistantMessage, Message as MessageType, NormalizedUserMessage } from 'src/types/message.js'; +import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'; +import { z } from 'zod/v4'; +import { clearInvokedSkillsForAgent, getSdkAgentProgressSummariesEnabled } from 'src/bootstrap/state.js'; +import { enhanceSystemPromptWithEnvDetails, getSystemPrompt } from 'src/constants/prompts.js'; +import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'; +import { startAgentSummarization } from 'src/services/AgentSummary/agentSummary.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { clearDumpState } from 'src/services/api/dumpPrompts.js' +} from 'src/services/analytics/index.js'; +import { clearDumpState } from 'src/services/api/dumpPrompts.js'; import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, @@ -39,58 +29,46 @@ import { unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage, -} from 'src/tasks/LocalAgentTask/LocalAgentTask.js' +} from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask, type BackgroundRemoteSessionPrecondition, -} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' -import { assembleToolPool } from 'src/tools.js' -import { asAgentId } from 'src/types/ids.js' -import { runWithAgentContext, type SubagentContext } from 'src/utils/agentContext.js' -import { isAgentSwarmsEnabled } from 'src/utils/agentSwarmsEnabled.js' -import { getCwd, runWithCwdOverride } from 'src/utils/cwd.js' -import { logForDebugging } from 'src/utils/debug.js' -import { isEnvTruthy } from 'src/utils/envUtils.js' -import { AbortError, errorMessage, toError } from 'src/utils/errors.js' -import type { CacheSafeParams } from 'src/utils/forkedAgent.js' -import { lazySchema } from 'src/utils/lazySchema.js' -import { - createUserMessage, - extractTextContent, - isSyntheticMessage, - normalizeMessages, -} from 'src/utils/messages.js' -import { getAgentModel } from 'src/utils/model/agent.js' -import { permissionModeSchema } from 'src/utils/permissions/PermissionMode.js' -import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js' -import { - filterDeniedAgents, - getDenyRuleForAgent, -} from 'src/utils/permissions/permissions.js' -import { enqueueSdkEvent } from 'src/utils/sdkEventQueue.js' -import { writeAgentMetadata } from 'src/utils/sessionStorage.js' -import { sleep } from 'src/utils/sleep.js' -import { buildEffectiveSystemPrompt } from 'src/utils/systemPrompt.js' -import { asSystemPrompt } from 'src/utils/systemPromptType.js' -import { getTaskOutputPath } from 'src/utils/task/diskOutput.js' -import { getParentSessionId, isTeammate } from 'src/utils/teammate.js' -import { isInProcessTeammate } from 'src/utils/teammateContext.js' -import { teleportToRemote } from 'src/utils/teleport.js' -import { getAssistantMessageContentLength } from 'src/utils/tokens.js' -import { createAgentId } from 'src/utils/uuid.js' -import { - createAgentWorktree, - hasWorktreeChanges, - removeAgentWorktree, -} from 'src/utils/worktree.js' -import { BASH_TOOL_NAME } from '../BashTool/toolName.js' -import { BackgroundHint } from '../BashTool/UI.js' -import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' -import { spawnTeammate } from '../shared/spawnMultiAgent.js' -import { setAgentColor } from './agentColorManager.js' +} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { assembleToolPool } from 'src/tools.js'; +import { asAgentId } from 'src/types/ids.js'; +import { runWithAgentContext, type SubagentContext } from 'src/utils/agentContext.js'; +import { isAgentSwarmsEnabled } from 'src/utils/agentSwarmsEnabled.js'; +import { getCwd, runWithCwdOverride } from 'src/utils/cwd.js'; +import { logForDebugging } from 'src/utils/debug.js'; +import { isEnvTruthy } from 'src/utils/envUtils.js'; +import { AbortError, errorMessage, toError } from 'src/utils/errors.js'; +import type { CacheSafeParams } from 'src/utils/forkedAgent.js'; +import { lazySchema } from 'src/utils/lazySchema.js'; +import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from 'src/utils/messages.js'; +import { getAgentModel } from 'src/utils/model/agent.js'; +import { permissionModeSchema } from 'src/utils/permissions/PermissionMode.js'; +import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'; +import { filterDeniedAgents, getDenyRuleForAgent } from 'src/utils/permissions/permissions.js'; +import { enqueueSdkEvent } from 'src/utils/sdkEventQueue.js'; +import { writeAgentMetadata } from 'src/utils/sessionStorage.js'; +import { sleep } from 'src/utils/sleep.js'; +import { buildEffectiveSystemPrompt } from 'src/utils/systemPrompt.js'; +import { asSystemPrompt } from 'src/utils/systemPromptType.js'; +import { getTaskOutputPath } from 'src/utils/task/diskOutput.js'; +import { getParentSessionId, isTeammate } from 'src/utils/teammate.js'; +import { isInProcessTeammate } from 'src/utils/teammateContext.js'; +import { teleportToRemote } from 'src/utils/teleport.js'; +import { getAssistantMessageContentLength } from 'src/utils/tokens.js'; +import { createAgentId } from 'src/utils/uuid.js'; +import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from 'src/utils/worktree.js'; +import { BASH_TOOL_NAME } from '../BashTool/toolName.js'; +import { BackgroundHint } from '../BashTool/UI.js'; +import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'; +import { spawnTeammate } from '../shared/spawnMultiAgent.js'; +import { setAgentColor } from './agentColorManager.js'; import { agentToolResultSchema, classifyHandoffIfNeeded, @@ -99,28 +77,20 @@ import { finalizeAgentTool, getLastToolUseName, runAsyncAgentLifecycle, -} from './agentToolUtils.js' -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' -import { - AGENT_TOOL_NAME, - LEGACY_AGENT_TOOL_NAME, - ONE_SHOT_BUILTIN_AGENT_TYPES, -} from './constants.js' +} from './agentToolUtils.js'; +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; +import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME, ONE_SHOT_BUILTIN_AGENT_TYPES } from './constants.js'; import { buildForkedMessages, buildWorktreeNotice, FORK_AGENT, isForkSubagentEnabled, isInForkChild, -} from './forkSubagent.js' -import type { AgentDefinition } from './loadAgentsDir.js' -import { - filterAgentsByMcpRequirements, - hasRequiredMcpServers, - isBuiltInAgent, -} from './loadAgentsDir.js' -import { getPrompt } from './prompt.js' -import { runAgent } from './runAgent.js' +} from './forkSubagent.js'; +import type { AgentDefinition } from './loadAgentsDir.js'; +import { filterAgentsByMcpRequirements, hasRequiredMcpServers, isBuiltInAgent } from './loadAgentsDir.js'; +import { getPrompt } from './prompt.js'; +import { runAgent } from './runAgent.js'; import { renderGroupedAgentToolUse, renderToolResultMessage, @@ -131,22 +101,22 @@ import { renderToolUseTag, userFacingName, userFacingNameBackgroundColor, -} from './UI.js' +} from './UI.js'; /* eslint-disable @typescript-eslint/no-require-imports */ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? (require('src/proactive/index.js') as typeof import('src/proactive/index.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ // Progress display constants (for showing background hint) -const PROGRESS_THRESHOLD_MS = 2000 // Show background hint after 2 seconds +const PROGRESS_THRESHOLD_MS = 2000; // Show background hint after 2 seconds // Check if background tasks are disabled at module load time const isBackgroundTasksDisabled = // eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load - isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) + isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); // Auto-background agent tasks after this many ms (0 = disabled) // Enabled by env var OR GrowthBook gate (checked lazily since GB may not be ready at module load) @@ -155,9 +125,9 @@ function getAutoBackgroundMs(): number { isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false) ) { - return 120_000 + return 120_000; } - return 0 + return 0; } // Multi-agent type constants are defined inline inside gated blocks to enable dead code elimination @@ -165,14 +135,9 @@ function getAutoBackgroundMs(): number { // Base input schema without multi-agent parameters const baseInputSchema = lazySchema(() => z.object({ - description: z - .string() - .describe('A short (3-5 word) description of the task'), + description: z.string().describe('A short (3-5 word) description of the task'), prompt: z.string().describe('The task for the agent to perform'), - subagent_type: z - .string() - .optional() - .describe('The type of specialized agent to use for this task'), + subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'), model: z .enum(['sonnet', 'opus', 'haiku']) .optional() @@ -182,11 +147,9 @@ const baseInputSchema = lazySchema(() => run_in_background: z .boolean() .optional() - .describe( - 'Set to true to run this agent in the background. You will be notified when it completes.', - ), + .describe('Set to true to run this agent in the background. You will be notified when it completes.'), }), -) +); // Full schema combining base + multi-agent params + isolation const fullInputSchema = lazySchema(() => { @@ -195,29 +158,17 @@ const fullInputSchema = lazySchema(() => { name: z .string() .optional() - .describe( - 'Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.', - ), - team_name: z - .string() - .optional() - .describe( - 'Team name for spawning. Uses current team context if omitted.', - ), + .describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), + team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), mode: permissionModeSchema() .optional() - .describe( - 'Permission mode for spawned teammate (e.g., "plan" to require plan approval).', - ), - }) + .describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).'), + }); return baseInputSchema() .merge(multiAgentInputSchema) .extend({ - isolation: (process.env.USER_TYPE === 'ant' - ? z.enum(['worktree', 'remote']) - : z.enum(['worktree']) - ) + isolation: (process.env.USER_TYPE === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])) .optional() .describe( process.env.USER_TYPE === 'ant' @@ -230,8 +181,8 @@ const fullInputSchema = lazySchema(() => { .describe( 'Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".', ), - }) -}) + }); +}); // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread @@ -240,9 +191,7 @@ const fullInputSchema = lazySchema(() => { // type, but call() destructures via the explicit AgentToolInput type below // which always includes all optional fields. export const inputSchema = lazySchema(() => { - const schema = feature('KAIROS') - ? fullInputSchema() - : fullInputSchema().omit({ cwd: true }) + const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true }); // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -251,70 +200,64 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() - ? schema.omit({ run_in_background: true }) - : schema -}) -type InputSchema = ReturnType + return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema; +}); +type InputSchema = ReturnType; // Explicit type widens the schema inference to always include all optional // fields even when .omit() strips them for gating (cwd, run_in_background). // subagent_type is optional; call() defaults it to general-purpose when the // fork gate is off, or routes to the fork path when the gate is on. type AgentToolInput = z.infer> & { - name?: string - team_name?: string - mode?: z.infer> - isolation?: 'worktree' | 'remote' - cwd?: string -} + name?: string; + team_name?: string; + mode?: z.infer>; + isolation?: 'worktree' | 'remote'; + cwd?: string; +}; // Output schema - multi-agent spawned schema added dynamically at runtime when enabled export const outputSchema = lazySchema(() => { const syncOutputSchema = agentToolResultSchema().extend({ status: z.literal('completed'), prompt: z.string(), - }) + }); const asyncOutputSchema = z.object({ status: z.literal('async_launched'), agentId: z.string().describe('The ID of the async agent'), description: z.string().describe('The description of the task'), prompt: z.string().describe('The prompt for the agent'), - outputFile: z - .string() - .describe('Path to the output file for checking agent progress'), + outputFile: z.string().describe('Path to the output file for checking agent progress'), canReadOutputFile: z .boolean() .optional() - .describe( - 'Whether the calling agent has Read/Bash tools to check progress', - ), - }) + .describe('Whether the calling agent has Read/Bash tools to check progress'), + }); - return z.union([syncOutputSchema, asyncOutputSchema]) -}) -type OutputSchema = ReturnType -type Output = z.input + return z.union([syncOutputSchema, asyncOutputSchema]); +}); +type OutputSchema = ReturnType; +type Output = z.input; // Private type for teammate spawn results - excluded from exported schema for dead code elimination // The 'teammate_spawned' status string is only included when ENABLE_AGENT_SWARMS is true type TeammateSpawnedOutput = { - status: 'teammate_spawned' - prompt: string - teammate_id: string - agent_id: string - agent_type?: string - model?: string - name: string - color?: string - tmux_session_name: string - tmux_window_name: string - tmux_pane_id: string - team_name?: string - is_splitpane?: boolean - plan_mode_required?: boolean -} + status: 'teammate_spawned'; + prompt: string; + teammate_id: string; + agent_id: string; + agent_type?: string; + model?: string; + name: string; + color?: string; + tmux_session_name: string; + tmux_window_name: string; + tmux_pane_id: string; + team_name?: string; + is_splitpane?: boolean; + plan_mode_required?: boolean; +}; // Combined output type including both public and internal types // Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time @@ -322,67 +265,58 @@ type TeammateSpawnedOutput = { // like TeammateSpawnedOutput for dead code elimination purposes. Exported // for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts. export type RemoteLaunchedOutput = { - status: 'remote_launched' - taskId: string - sessionUrl: string - description: string - prompt: string - outputFile: string -} + status: 'remote_launched'; + taskId: string; + sessionUrl: string; + description: string; + prompt: string; + outputFile: string; +}; -type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput +type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput; -import type { AgentToolProgress, ShellProgress } from 'src/types/tools.js' +import type { AgentToolProgress, ShellProgress } from 'src/types/tools.js'; // AgentTool forwards both its own progress events and shell progress // events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs. -export type Progress = AgentToolProgress | ShellProgress +export type Progress = AgentToolProgress | ShellProgress; export const AgentTool = buildTool({ async prompt({ agents, tools, getToolPermissionContext, allowedAgentTypes }) { - const toolPermissionContext = await getToolPermissionContext() + const toolPermissionContext = await getToolPermissionContext(); // Get MCP servers that have tools available - const mcpServersWithTools: string[] = [] + const mcpServersWithTools: string[] = []; for (const tool of tools) { if (tool.name?.startsWith('mcp__')) { - const parts = tool.name.split('__') - const serverName = parts[1] + const parts = tool.name.split('__'); + const serverName = parts[1]; if (serverName && !mcpServersWithTools.includes(serverName)) { - mcpServersWithTools.push(serverName) + mcpServersWithTools.push(serverName); } } } // Filter agents: first by MCP requirements, then by permission rules - const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements( - agents, - mcpServersWithTools, - ) - const filteredAgents = filterDeniedAgents( - agentsWithMcpRequirementsMet, - toolPermissionContext, - AGENT_TOOL_NAME, - ) + const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(agents, mcpServersWithTools); + const filteredAgents = filterDeniedAgents(agentsWithMcpRequirementsMet, toolPermissionContext, AGENT_TOOL_NAME); // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') - ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) - : false - return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes) + const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; + return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes); }, name: AGENT_TOOL_NAME, searchHint: 'delegate work to a subagent', aliases: [LEGACY_AGENT_TOOL_NAME], maxResultSizeChars: 100_000, async description() { - return 'Launch a new agent' + return 'Launch a new agent'; }, get inputSchema(): InputSchema { - return inputSchema() + return inputSchema(); }, get outputSchema(): OutputSchema { - return outputSchema() + return outputSchema(); }, async call( { @@ -402,30 +336,29 @@ export const AgentTool = buildTool({ assistantMessage, onProgress?, ) { - const startTime = Date.now() - const model = isCoordinatorMode() ? undefined : modelParam + const startTime = Date.now(); + const model = isCoordinatorMode() ? undefined : modelParam; // Get app state for permission mode and agent filtering - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode + const appState = toolUseContext.getAppState(); + const permissionMode = appState.toolPermissionContext.mode; // In-process teammates get a no-op setAppState; setAppStateForTasks // reaches the root store so task registration/progress/kill stay visible. - const rootSetAppState = - toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState + const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState; // Check if user is trying to use agent teams without access if (team_name && !isAgentSwarmsEnabled()) { - throw new Error('Agent Teams is not yet available on your plan.') + throw new Error('Agent Teams is not yet available on your plan.'); } // Teammates (in-process or tmux) passing `name` would trigger spawnTeammate() // below, but TeamFile.members is a flat array with one leadAgentId — nested // teammates land in the roster with no provenance and confuse the lead. - const teamName = resolveTeamName({ team_name }, appState) + const teamName = resolveTeamName({ team_name }, appState); if (isTeammate() && teamName && name) { throw new Error( 'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.', - ) + ); } // In-process teammates cannot spawn background agents (their lifecycle is // tied to the leader's process). Tmux teammates are separate processes and @@ -433,7 +366,7 @@ export const AgentTool = buildTool({ if (isInProcessTeammate() && teamName && run_in_background === true) { throw new Error( 'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.', - ) + ); } // Check if this is a multi-agent spawn request @@ -441,12 +374,10 @@ export const AgentTool = buildTool({ if (teamName && name) { // Set agent definition color for grouped UI display before spawning const agentDef = subagent_type - ? toolUseContext.options.agentDefinitions.activeAgents.find( - a => a.agentType === subagent_type, - ) - : undefined + ? toolUseContext.options.agentDefinitions.activeAgents.find(a => a.agentType === subagent_type) + : undefined; if (agentDef?.color) { - setAgentColor(subagent_type!, agentDef.color) + setAgentColor(subagent_type!, agentDef.color); } const result = await spawnTeammate( { @@ -461,7 +392,7 @@ export const AgentTool = buildTool({ invokingRequestId: assistantMessage?.requestId as string | undefined, }, toolUseContext, - ) + ); // Type assertion uses TeammateSpawnedOutput (defined above) instead of any. // This type is excluded from the exported outputSchema for dead code elimination. @@ -471,20 +402,18 @@ export const AgentTool = buildTool({ status: 'teammate_spawned' as const, prompt, ...result.data, - } - return { data: spawnResult } as unknown as { data: Output } + }; + return { data: spawnResult } as unknown as { data: Output }; } // Fork subagent experiment routing: // - subagent_type set: use it (explicit wins) // - subagent_type omitted, gate on: fork path (undefined) // - subagent_type omitted, gate off: default general-purpose - const effectiveType = - subagent_type ?? - (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType) - const isForkPath = effectiveType === undefined + const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType); + const isForkPath = effectiveType === undefined; - let selectedAgent: AgentDefinition + let selectedAgent: AgentDefinition; if (isForkPath) { // Recursive fork guard: fork children keep the Agent tool in their // pool for cache-identical tool defs, so reject fork attempts at call @@ -493,69 +422,52 @@ export const AgentTool = buildTool({ // rewrite). Message-scan fallback catches any path where querySource // wasn't threaded. if ( - toolUseContext.options.querySource === - `agent:builtin:${FORK_AGENT.agentType}` || + toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages) ) { - throw new Error( - 'Fork is not available inside a forked worker. Complete your task directly using your tools.', - ) + throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.'); } - selectedAgent = FORK_AGENT + selectedAgent = FORK_AGENT; } else { // Filter agents to exclude those denied via Agent(AgentName) syntax - const allAgents = toolUseContext.options.agentDefinitions.activeAgents - const { allowedAgentTypes } = toolUseContext.options.agentDefinitions + const allAgents = toolUseContext.options.agentDefinitions.activeAgents; + const { allowedAgentTypes } = toolUseContext.options.agentDefinitions; const agents = filterDeniedAgents( // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types - allowedAgentTypes - ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) - : allAgents, + allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents, appState.toolPermissionContext, AGENT_TOOL_NAME, - ) + ); - const found = agents.find(agent => agent.agentType === effectiveType) + const found = agents.find(agent => agent.agentType === effectiveType); if (!found) { // Check if the agent exists but is denied by permission rules - const agentExistsButDenied = allAgents.find( - agent => agent.agentType === effectiveType, - ) + const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType); if (agentExistsButDenied) { - const denyRule = getDenyRuleForAgent( - appState.toolPermissionContext, - AGENT_TOOL_NAME, - effectiveType, - ) + const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType); throw new Error( `Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`, - ) + ); } throw new Error( - `Agent type '${effectiveType}' not found. Available agents: ${agents - .map(a => a.agentType) - .join(', ')}`, - ) + `Agent type '${effectiveType}' not found. Available agents: ${agents.map(a => a.agentType).join(', ')}`, + ); } - selectedAgent = found + selectedAgent = found; } // Same lifecycle constraint as the run_in_background guard above, but for // agent definitions that force background via `background: true`. Checked // here because selectedAgent is only now resolved. - if ( - isInProcessTeammate() && - teamName && - selectedAgent.background === true - ) { + if (isInProcessTeammate() && teamName && selectedAgent.background === true) { throw new Error( `In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`, - ) + ); } // Capture for type narrowing — `let selectedAgent` prevents TS from // narrowing property types across the if-else assignment above. - const requiredMcpServers = selectedAgent.requiredMcpServers + const requiredMcpServers = selectedAgent.requiredMcpServers; // Check if required MCP servers have tools available // A server that's connected but not authenticated won't have any tools @@ -566,74 +478,65 @@ export const AgentTool = buildTool({ const hasPendingRequiredServers = appState.mcp.clients.some( c => c.type === 'pending' && - requiredMcpServers.some(pattern => - c.name.toLowerCase().includes(pattern.toLowerCase()), - ), - ) + requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase())), + ); - let currentAppState = appState + let currentAppState = appState; if (hasPendingRequiredServers) { - const MAX_WAIT_MS = 30_000 - const POLL_INTERVAL_MS = 500 - const deadline = Date.now() + MAX_WAIT_MS + const MAX_WAIT_MS = 30_000; + const POLL_INTERVAL_MS = 500; + const deadline = Date.now() + MAX_WAIT_MS; while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS) - currentAppState = toolUseContext.getAppState() + await sleep(POLL_INTERVAL_MS); + currentAppState = toolUseContext.getAppState(); // Early exit: if any required server has already failed, no point // waiting for other pending servers — the check will fail regardless. const hasFailedRequiredServer = currentAppState.mcp.clients.some( c => c.type === 'failed' && - requiredMcpServers.some(pattern => - c.name.toLowerCase().includes(pattern.toLowerCase()), - ), - ) - if (hasFailedRequiredServer) break + requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase())), + ); + if (hasFailedRequiredServer) break; const stillPending = currentAppState.mcp.clients.some( c => c.type === 'pending' && - requiredMcpServers.some(pattern => - c.name.toLowerCase().includes(pattern.toLowerCase()), - ), - ) - if (!stillPending) break + requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase())), + ); + if (!stillPending) break; } } // Get servers that actually have tools (meaning they're connected AND authenticated) - const serversWithTools: string[] = [] + const serversWithTools: string[] = []; for (const tool of currentAppState.mcp.tools) { if (tool.name?.startsWith('mcp__')) { // Extract server name from tool name (format: mcp__serverName__toolName) - const parts = tool.name.split('__') - const serverName = parts[1] + const parts = tool.name.split('__'); + const serverName = parts[1]; if (serverName && !serversWithTools.includes(serverName)) { - serversWithTools.push(serverName) + serversWithTools.push(serverName); } } } if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) { const missing = requiredMcpServers.filter( - pattern => - !serversWithTools.some(server => - server.toLowerCase().includes(pattern.toLowerCase()), - ), - ) + pattern => !serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase())), + ); throw new Error( `Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + `Use /mcp to configure and authenticate the required MCP servers.`, - ) + ); } } // Initialize the color for this agent if it has a predefined one if (selectedAgent.color) { - setAgentColor(selectedAgent.agentType, selectedAgent.color) + setAgentColor(selectedAgent.agentType, selectedAgent.color); } // Resolve agent params for logging (these are already resolved in runAgent) @@ -642,50 +545,44 @@ export const AgentTool = buildTool({ toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode, - ) + ); logEvent('tengu_agent_tool_selected', { - agent_type: - selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: - resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - color: - selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + color: selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_built_in_agent: isBuiltInAgent(selectedAgent), is_resume: false, - is_async: - (run_in_background === true || selectedAgent.background === true) && - !isBackgroundTasksDisabled, + is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, is_fork: isForkPath, - }) + }); // Resolve effective isolation mode (explicit param overrides agent def) - const effectiveIsolation = isolation ?? selectedAgent.isolation + const effectiveIsolation = isolation ?? selectedAgent.isolation; // Remote isolation: delegate to CCR. Gated ant-only — the guard enables // dead code elimination of the entire block for external builds. if (process.env.USER_TYPE === 'ant' && effectiveIsolation === 'remote') { - const eligibility = await checkRemoteAgentEligibility() + const eligibility = await checkRemoteAgentEligibility(); if (!eligibility.eligible) { const reasons = (eligibility as { eligible: false; errors: BackgroundRemoteSessionPrecondition[] }).errors .map(formatPreconditionError) - .join('\n') - throw new Error(`Cannot launch remote agent:\n${reasons}`) + .join('\n'); + throw new Error(`Cannot launch remote agent:\n${reasons}`); } - let bundleFailHint: string | undefined + let bundleFailHint: string | undefined; const session = await teleportToRemote({ initialMessage: prompt, description, signal: toolUseContext.abortController.signal, onBundleFail: msg => { - bundleFailHint = msg + bundleFailHint = msg; }, - }) + }); if (!session) { - throw new Error(bundleFailHint ?? 'Failed to create remote session') + throw new Error(bundleFailHint ?? 'Failed to create remote session'); } const { taskId, sessionId } = registerRemoteAgentTask({ @@ -694,12 +591,11 @@ export const AgentTool = buildTool({ command: prompt, context: toolUseContext, toolUseId: toolUseContext.toolUseId, - }) + }); logEvent('tengu_agent_tool_remote_launched', { - agent_type: - selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); const remoteResult: RemoteLaunchedOutput = { status: 'remote_launched', @@ -708,8 +604,8 @@ export const AgentTool = buildTool({ description, prompt, outputFile: getTaskOutputPath(taskId), - } - return { data: remoteResult } as unknown as { data: Output } + }; + return { data: remoteResult } as unknown as { data: Output }; } // System prompt + prompt messages: branch on fork path. // @@ -720,62 +616,55 @@ export const AgentTool = buildTool({ // // Normal path: build the selected agent's own system prompt with env // details, and use a simple user message for the prompt. - let enhancedSystemPrompt: string[] | undefined - let forkParentSystemPrompt: - | ReturnType - | undefined - let promptMessages: MessageType[] + let enhancedSystemPrompt: string[] | undefined; + let forkParentSystemPrompt: ReturnType | undefined; + let promptMessages: MessageType[]; if (isForkPath) { if (toolUseContext.renderedSystemPrompt) { - forkParentSystemPrompt = toolUseContext.renderedSystemPrompt + forkParentSystemPrompt = toolUseContext.renderedSystemPrompt; } else { // Fallback: recompute. May diverge from parent's cached bytes if // GrowthBook state changed between parent turn-start and fork spawn. const mainThreadAgentDefinition = appState.agent - ? appState.agentDefinitions.activeAgents.find( - a => a.agentType === appState.agent, - ) - : undefined + ? appState.agentDefinitions.activeAgents.find(a => a.agentType === appState.agent) + : undefined; const additionalWorkingDirectories = Array.from( appState.toolPermissionContext.additionalWorkingDirectories.keys(), - ) + ); const defaultSystemPrompt = await getSystemPrompt( toolUseContext.options.tools, toolUseContext.options.mainLoopModel, additionalWorkingDirectories, toolUseContext.options.mcpClients, - ) + ); forkParentSystemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt: toolUseContext.options.customSystemPrompt, defaultSystemPrompt, appendSystemPrompt: toolUseContext.options.appendSystemPrompt, - }) + }); } - promptMessages = buildForkedMessages(prompt, assistantMessage) + promptMessages = buildForkedMessages(prompt, assistantMessage); } else { try { const additionalWorkingDirectories = Array.from( appState.toolPermissionContext.additionalWorkingDirectories.keys(), - ) + ); // All agents have getSystemPrompt - pass toolUseContext to all - const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }) + const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }); // Log agent memory loaded event for subagents if (selectedAgent.memory) { logEvent('tengu_agent_memory_loaded', { ...(process.env.USER_TYPE === 'ant' && { - agent_type: - selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), - scope: - selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } // Apply environment details enhancement @@ -783,13 +672,11 @@ export const AgentTool = buildTool({ [agentPrompt], resolvedAgentModel, additionalWorkingDirectories, - ) + ); } catch (error) { - logForDebugging( - `Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`, - ) + logForDebugging(`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`); } - promptMessages = [createUserMessage({ content: prompt })] + promptMessages = [createUserMessage({ content: prompt })]; } const metadata = { @@ -798,20 +685,16 @@ export const AgentTool = buildTool({ isBuiltInAgent: isBuiltInAgent(selectedAgent), startTime, agentType: selectedAgent.agentType, - isAsync: - (run_in_background === true || selectedAgent.background === true) && - !isBackgroundTasksDisabled, - } + isAsync: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, + }; // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') - ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) - : false + const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; // Fork subagent experiment: force ALL spawns async for a unified // interaction model (not just fork spawns — all of them). - const forceAsync = isForkSubagentEnabled() + const forceAsync = isForkSubagentEnabled(); // Assistant mode: force all agents async. Synchronous subagents hold the // main loop's turn open until they complete — the daemon's inputQueue @@ -820,9 +703,7 @@ export const AgentTool = buildTool({ // executeForkedSlashCommand's fire-and-forget path; the // re-entry there is handled by the else branch // below (registerAsyncAgentTask + notifyOnCompletion). - const assistantForceAsync = feature('KAIROS') - ? appState.kairosEnabled - : false + const assistantForceAsync = feature('KAIROS') ? appState.kairosEnabled : false; const shouldRunAsync = (run_in_background === true || @@ -831,7 +712,7 @@ export const AgentTool = buildTool({ forceAsync || assistantForceAsync || (proactiveModule?.isProactiveActive() ?? false)) && - !isBackgroundTasksDisabled + !isBackgroundTasksDisabled; // Assemble the worker's tool pool independently of the parent's. // Workers always get their tools from assembleToolPool with their own // permission mode, so they aren't affected by the parent's tool @@ -840,27 +721,24 @@ export const AgentTool = buildTool({ const workerPermissionContext = { ...appState.toolPermissionContext, mode: selectedAgent.permissionMode ?? 'acceptEdits', - } - const workerTools = assembleToolPool( - workerPermissionContext, - appState.mcp.tools, - ) + }; + const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools); // Create a stable agent ID early so it can be used for worktree slug - const earlyAgentId = createAgentId() + const earlyAgentId = createAgentId(); // Set up worktree isolation if requested let worktreeInfo: { - worktreePath: string - worktreeBranch?: string - headCommit?: string - gitRoot?: string - hookBased?: boolean - } | null = null + worktreePath: string; + worktreeBranch?: string; + headCommit?: string; + gitRoot?: string; + hookBased?: boolean; + } | null = null; if (effectiveIsolation === 'worktree') { - const slug = `agent-${earlyAgentId.slice(0, 8)}` - worktreeInfo = await createAgentWorktree(slug) + const slug = `agent-${earlyAgentId.slice(0, 8)}`; + worktreeInfo = await createAgentWorktree(slug); } // Fork + worktree: inject a notice telling the child to translate paths @@ -871,7 +749,7 @@ export const AgentTool = buildTool({ createUserMessage({ content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath), }), - ) + ); } const runAgentParams: Parameters[0] = { @@ -882,10 +760,7 @@ export const AgentTool = buildTool({ isAsync: shouldRunAsync, querySource: toolUseContext.options.querySource ?? - getQuerySourceForAgent( - selectedAgent.agentType, - isBuiltInAgent(selectedAgent), - ), + getQuerySourceForAgent(selectedAgent.agentType, isBuiltInAgent(selectedAgent)), model: isForkPath ? undefined : model, // Fork path: pass parent's system prompt AND parent's exact tool // array (cache-identical prefix). workerTools is rebuilt under @@ -910,52 +785,48 @@ export const AgentTool = buildTool({ ...(isForkPath && { useExactTools: true }), worktreePath: worktreeInfo?.worktreePath, description, - } + }; // Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS) // takes precedence over worktree isolation path. - const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath - const wrapWithCwd = (fn: () => T): T => - cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn() + const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath; + const wrapWithCwd = (fn: () => T): T => (cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn()); // Helper to clean up worktree after agent completes const cleanupWorktreeIfNeeded = async (): Promise<{ - worktreePath?: string - worktreeBranch?: string + worktreePath?: string; + worktreeBranch?: string; }> => { - if (!worktreeInfo) return {} - const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = - worktreeInfo + if (!worktreeInfo) return {}; + const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = worktreeInfo; // Null out to make idempotent — guards against double-call if code // between cleanup and end of try throws into catch - worktreeInfo = null + worktreeInfo = null; if (hookBased) { // Hook-based worktrees are always kept since we can't detect VCS changes - logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`) - return { worktreePath } + logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`); + return { worktreePath }; } if (headCommit) { - const changed = await hasWorktreeChanges(worktreePath, headCommit) + const changed = await hasWorktreeChanges(worktreePath, headCommit); if (!changed) { - await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot) + await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot); // Clear worktreePath from metadata so resume doesn't try to use // a deleted directory. Fire-and-forget to match runAgent's // writeAgentMetadata handling. void writeAgentMetadata(asAgentId(earlyAgentId), { agentType: selectedAgent.agentType, description, - }).catch(_err => - logForDebugging(`Failed to clear worktree metadata: ${_err}`), - ) - return {} + }).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`)); + return {}; } } - logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`) - return { worktreePath, worktreeBranch } - } + logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`); + return { worktreePath, worktreeBranch }; + }; if (shouldRunAsync) { - const asyncAgentId = earlyAgentId + const asyncAgentId = earlyAgentId; const agentBackgroundTask = registerAsyncAgent({ agentId: asyncAgentId, description, @@ -966,17 +837,17 @@ export const AgentTool = buildTool({ // survive when the user presses ESC to cancel the main thread. // They are killed explicitly via chat:killAgents. toolUseId: toolUseContext.toolUseId, - }) + }); // Register name → agentId for SendMessage routing. Post-registerAsyncAgent // so we don't leave a stale entry if spawn fails. Sync agents skipped — // coordinator is blocked, so SendMessage routing doesn't apply. if (name) { rootSetAppState(prev => { - const next = new Map(prev.agentNameRegistry) - next.set(name, asAgentId(asyncAgentId)) - return { ...prev, agentNameRegistry: next } - }) + const next = new Map(prev.agentNameRegistry); + next.set(name, asAgentId(asyncAgentId)); + return { ...prev, agentNameRegistry: next }; + }); } // Wrap async agent execution in agent context for analytics attribution @@ -991,7 +862,7 @@ export const AgentTool = buildTool({ invokingRequestId: assistantMessage?.requestId as string | undefined, invocationKind: 'spawn' as const, invocationEmitted: false, - } + }; // Workload propagation: handlePromptSubmit wraps the entire turn in // runWithWorkload (AsyncLocalStorage). ALS context is captured at @@ -1018,20 +889,15 @@ export const AgentTool = buildTool({ toolUseContext, rootSetAppState, agentIdForCleanup: asyncAgentId, - enableSummarization: - isCoordinator || - isForkSubagentEnabled() || - getSdkAgentProgressSummariesEnabled(), + enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(), getWorktreeResult: cleanupWorktreeIfNeeded, }), ), - ) + ); const canReadOutputFile = toolUseContext.options.tools.some( - t => - toolMatchesName(t, FILE_READ_TOOL_NAME) || - toolMatchesName(t, BASH_TOOL_NAME), - ) + t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME), + ); return { data: { isAsync: true as const, @@ -1042,10 +908,10 @@ export const AgentTool = buildTool({ outputFile: getTaskOutputPath(agentBackgroundTask.agentId), canReadOutputFile, }, - } + }; } else { // Create an explicit agentId for sync agents - const syncAgentId = asAgentId(earlyAgentId) + const syncAgentId = asAgentId(earlyAgentId); // Set up agent context for sync execution (for analytics attribution) const syncAgentContext: SubagentContext = { @@ -1059,30 +925,24 @@ export const AgentTool = buildTool({ invokingRequestId: assistantMessage?.requestId as string | undefined, invocationKind: 'spawn' as const, invocationEmitted: false, - } + }; // Wrap entire sync agent execution in context for analytics attribution // and optionally in a worktree cwd override for filesystem isolation return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => { - const agentMessages: MessageType[] = [] - const agentStartTime = Date.now() - const syncTracker = createProgressTracker() - const syncResolveActivity = createActivityDescriptionResolver( - toolUseContext.options.tools, - ) + const agentMessages: MessageType[] = []; + const agentStartTime = Date.now(); + const syncTracker = createProgressTracker(); + const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools); // Yield initial progress message to carry metadata (prompt) if (promptMessages.length > 0) { - const normalizedPromptMessages = normalizeMessages(promptMessages) + const normalizedPromptMessages = normalizeMessages(promptMessages); const normalizedFirstMessage = normalizedPromptMessages.find( (m): m is NormalizedUserMessage => m.type === 'user', - ) - if ( - normalizedFirstMessage && - normalizedFirstMessage.type === 'user' && - onProgress - ) { + ); + if (normalizedFirstMessage && normalizedFirstMessage.type === 'user' && onProgress) { onProgress({ toolUseID: `agent_${assistantMessage.message.id}`, data: { @@ -1091,18 +951,18 @@ export const AgentTool = buildTool({ prompt, agentId: syncAgentId, }, - }) + }); } } // Register as foreground task immediately so it can be backgrounded at any time // Skip registration if background tasks are disabled - let foregroundTaskId: string | undefined + let foregroundTaskId: string | undefined; // Create the background race promise once outside the loop — otherwise // each iteration adds a new .then() reaction to the same pending // promise, accumulating callbacks for the lifetime of the agent. - let backgroundPromise: Promise<{ type: 'background' }> | undefined - let cancelAutoBackground: (() => void) | undefined + let backgroundPromise: Promise<{ type: 'background' }> | undefined; + let cancelAutoBackground: (() => void) | undefined; if (!isBackgroundTasksDisabled) { const registration = registerAgentForeground({ agentId: syncAgentId, @@ -1112,23 +972,23 @@ export const AgentTool = buildTool({ setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, autoBackgroundMs: getAutoBackgroundMs() || undefined, - }) - foregroundTaskId = registration.taskId + }); + foregroundTaskId = registration.taskId; backgroundPromise = registration.backgroundSignal.then(() => ({ type: 'background' as const, - })) - cancelAutoBackground = registration.cancelAutoBackground + })); + cancelAutoBackground = registration.cancelAutoBackground; } // Track if we've shown the background hint UI - let backgroundHintShown = false + let backgroundHintShown = false; // Track if the agent was backgrounded (cleanup handled by backgrounded finally) - let wasBackgrounded = false + let wasBackgrounded = false; // Per-scope stop function — NOT shared with the backgrounded closure. // idempotent: startAgentSummarization's stop() checks `stopped` flag. - let stopForegroundSummarization: (() => void) | undefined + let stopForegroundSummarization: (() => void) | undefined; // const capture for sound type narrowing inside the callback below - const summaryTaskId = foregroundTaskId + const summaryTaskId = foregroundTaskId; // Get async iterator for the agent const agentIterator = runAgent({ @@ -1140,28 +1000,23 @@ export const AgentTool = buildTool({ onCacheSafeParams: summaryTaskId && getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { stop } = startAgentSummarization( - summaryTaskId, - syncAgentId, - params, - rootSetAppState, - ) - stopForegroundSummarization = stop + const { stop } = startAgentSummarization(summaryTaskId, syncAgentId, params, rootSetAppState); + stopForegroundSummarization = stop; } : undefined, - })[Symbol.asyncIterator]() + })[Symbol.asyncIterator](); // Track if an error occurred during iteration - let syncAgentError: Error | undefined - let wasAborted = false + let syncAgentError: Error | undefined; + let wasAborted = false; let worktreeResult: { - worktreePath?: string - worktreeBranch?: string - } = {} + worktreePath?: string; + worktreeBranch?: string; + } = {}; try { while (true) { - const elapsed = Date.now() - agentStartTime + const elapsed = Date.now() - agentStartTime; // Show background hint after threshold (but task is already registered) // Skip if background tasks are disabled @@ -1171,18 +1026,18 @@ export const AgentTool = buildTool({ elapsed >= PROGRESS_THRESHOLD_MS && toolUseContext.setToolJSX ) { - backgroundHintShown = true + backgroundHintShown = true; toolUseContext.setToolJSX({ jsx: , shouldHidePromptInput: false, shouldContinueAnimation: true, showSpinner: true, - }) + }); } // Race between next message and background signal // If background tasks are disabled, just await the next message directly - const nextMessagePromise = agentIterator.next() + const nextMessagePromise = agentIterator.next(); const raceResult = backgroundPromise ? await Promise.race([ nextMessagePromise.then(r => ({ @@ -1194,49 +1049,38 @@ export const AgentTool = buildTool({ : { type: 'message' as const, result: await nextMessagePromise, - } + }; // Check if we were backgrounded via backgroundAll() // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' // because backgroundPromise is only defined when foregroundTaskId is defined if (raceResult.type === 'background' && foregroundTaskId) { - const appState = toolUseContext.getAppState() - const task = appState.tasks[foregroundTaskId] + const appState = toolUseContext.getAppState(); + const task = appState.tasks[foregroundTaskId]; if (isLocalAgentTask(task) && task.isBackgrounded) { // Capture the taskId for use in the async callback - const backgroundedTaskId = foregroundTaskId - wasBackgrounded = true + const backgroundedTaskId = foregroundTaskId; + wasBackgrounded = true; // Stop foreground summarization; the backgrounded closure // below owns its own independent stop function. - stopForegroundSummarization?.() + stopForegroundSummarization?.(); // Workload: inherited via ALS at `void` invocation time, // same as the async-from-start path above. // Continue agent in background and return async result void runWithAgentContext(syncAgentContext, async () => { - let stopBackgroundedSummarization: (() => void) | undefined + let stopBackgroundedSummarization: (() => void) | undefined; try { // Clean up the foreground iterator so its finally block runs // (releases MCP connections, session hooks, prompt cache tracking, etc.) // Timeout prevents blocking if MCP server cleanup hangs. // .catch() prevents unhandled rejection if timeout wins the race. - await Promise.race([ - agentIterator.return(undefined).catch(() => {}), - sleep(1000), - ]) + await Promise.race([agentIterator.return(undefined).catch(() => {}), sleep(1000)]); // Initialize progress tracking from existing messages - const tracker = createProgressTracker() - const resolveActivity2 = - createActivityDescriptionResolver( - toolUseContext.options.tools, - ) + const tracker = createProgressTracker(); + const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); for (const existingMsg of agentMessages) { - updateProgressFromMessage( - tracker, - existingMsg, - resolveActivity2, - toolUseContext.options.tools, - ) + updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); } for await (const msg of runAgent({ ...runAgentParams, @@ -1253,27 +1097,18 @@ export const AgentTool = buildTool({ asAgentId(backgroundedTaskId), params, rootSetAppState, - ) - stopBackgroundedSummarization = stop + ); + stopBackgroundedSummarization = stop; } : undefined, })) { - agentMessages.push(msg) + agentMessages.push(msg); // Track progress for backgrounded agents - updateProgressFromMessage( - tracker, - msg, - resolveActivity2, - toolUseContext.options.tools, - ) - updateAsyncAgentProgress( - backgroundedTaskId, - getProgressUpdate(tracker), - rootSetAppState, - ) + updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools); + updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState); - const lastToolName = getLastToolUseName(msg) + const lastToolName = getLastToolUseName(msg); if (lastToolName) { emitTaskProgress( tracker, @@ -1282,46 +1117,37 @@ export const AgentTool = buildTool({ description, startTime, lastToolName, - ) + ); } } - const agentResult = finalizeAgentTool( - agentMessages, - backgroundedTaskId, - metadata, - ) + const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); // Mark task completed FIRST so TaskOutput(block=true) // unblocks immediately. classifyHandoffIfNeeded and // cleanupWorktreeIfNeeded can hang — they must not gate // the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState) + completeAsyncAgent(agentResult, rootSetAppState); // Extract text from agent result content for the notification - let finalMessage = extractTextContent( - agentResult.content, - '\n', - ) + let finalMessage = extractTextContent(agentResult.content, '\n'); if (feature('TRANSCRIPT_CLASSIFIER')) { - const backgroundedAppState = - toolUseContext.getAppState() + const backgroundedAppState = toolUseContext.getAppState(); const handoffWarning = await classifyHandoffIfNeeded({ agentMessages, tools: toolUseContext.options.tools, - toolPermissionContext: - backgroundedAppState.toolPermissionContext, + toolPermissionContext: backgroundedAppState.toolPermissionContext, abortSignal: task.abortController!.signal, subagentType: selectedAgent.agentType, totalToolUseCount: agentResult.totalToolUseCount, - }) + }); if (handoffWarning) { - finalMessage = `${handoffWarning}\n\n${finalMessage}` + finalMessage = `${handoffWarning}\n\n${finalMessage}`; } } // Clean up worktree before notification so we can include it - const worktreeResult = await cleanupWorktreeIfNeeded() + const worktreeResult = await cleanupWorktreeIfNeeded(); enqueueAgentNotification({ taskId: backgroundedTaskId, @@ -1336,15 +1162,14 @@ export const AgentTool = buildTool({ }, toolUseId: toolUseContext.toolUseId, ...worktreeResult, - }) + }); } catch (error) { if (error instanceof AbortError) { // Transition status BEFORE worktree cleanup so // TaskOutput unblocks even if git hangs (gh-20236). - killAsyncAgent(backgroundedTaskId, rootSetAppState) + killAsyncAgent(backgroundedTaskId, rootSetAppState); logEvent('tengu_agent_tool_terminated', { - agent_type: - metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, @@ -1352,10 +1177,9 @@ export const AgentTool = buildTool({ is_built_in_agent: metadata.isBuiltInAgent, reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - const worktreeResult = await cleanupWorktreeIfNeeded() - const partialResult = - extractPartialResult(agentMessages) + }); + const worktreeResult = await cleanupWorktreeIfNeeded(); + const partialResult = extractPartialResult(agentMessages); enqueueAgentNotification({ taskId: backgroundedTaskId, description, @@ -1364,16 +1188,12 @@ export const AgentTool = buildTool({ toolUseId: toolUseContext.toolUseId, finalMessage: partialResult, ...worktreeResult, - }) - return + }); + return; } - const errMsg = errorMessage(error) - failAsyncAgent( - backgroundedTaskId, - errMsg, - rootSetAppState, - ) - const worktreeResult = await cleanupWorktreeIfNeeded() + const errMsg = errorMessage(error); + failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState); + const worktreeResult = await cleanupWorktreeIfNeeded(); enqueueAgentNotification({ taskId: backgroundedTaskId, description, @@ -1382,22 +1202,20 @@ export const AgentTool = buildTool({ setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, ...worktreeResult, - }) + }); } finally { - stopBackgroundedSummarization?.() - clearInvokedSkillsForAgent(syncAgentId) - clearDumpState(syncAgentId) + stopBackgroundedSummarization?.(); + clearInvokedSkillsForAgent(syncAgentId); + clearDumpState(syncAgentId); // Note: worktree cleanup is done before enqueueAgentNotification // in both try and catch paths so we can include worktree info } - }) + }); // Return async_launched result immediately const canReadOutputFile = toolUseContext.options.tools.some( - t => - toolMatchesName(t, FILE_READ_TOOL_NAME) || - toolMatchesName(t, BASH_TOOL_NAME), - ) + t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME), + ); return { data: { isAsync: true as const, @@ -1408,30 +1226,25 @@ export const AgentTool = buildTool({ outputFile: getTaskOutputPath(backgroundedTaskId), canReadOutputFile, }, - } + }; } } // Process the message from the race result if (raceResult.type !== 'message') { // This shouldn't happen - background case handled above - continue + continue; } - const { result } = raceResult - if (result.done) break - const message = result.value as MessageType + const { result } = raceResult; + if (result.done) break; + const message = result.value as MessageType; - agentMessages.push(message) + agentMessages.push(message); // Emit task_progress for the VS Code subagent panel - updateProgressFromMessage( - syncTracker, - message, - syncResolveActivity, - toolUseContext.options.tools, - ) + updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools); if (foregroundTaskId) { - const lastToolName = getLastToolUseName(message) + const lastToolName = getLastToolUseName(message); if (lastToolName) { emitTaskProgress( syncTracker, @@ -1440,16 +1253,12 @@ export const AgentTool = buildTool({ description, agentStartTime, lastToolName, - ) + ); // Keep AppState task.progress in sync when SDK summaries are // enabled, so updateAgentSummary reads correct token/tool counts // instead of zeros. if (getSdkAgentProgressSummariesEnabled()) { - updateAsyncAgentProgress( - foregroundTaskId, - getProgressUpdate(syncTracker), - rootSetAppState, - ) + updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); } } } @@ -1465,31 +1274,28 @@ export const AgentTool = buildTool({ onProgress({ toolUseID: message.toolUseID as string, data: message.data, - }) + }); } if (message.type !== 'assistant' && message.type !== 'user') { - continue + continue; } // Increment token count in spinner for assistant messages // Subagent streaming events are filtered out in runAgent.ts, so we // need to count tokens from completed messages here if (message.type === 'assistant') { - const contentLength = getAssistantMessageContentLength(message as AssistantMessage) + const contentLength = getAssistantMessageContentLength(message as AssistantMessage); if (contentLength > 0) { - toolUseContext.setResponseLength(len => len + contentLength) + toolUseContext.setResponseLength(len => len + contentLength); } } - const normalizedNew = normalizeMessages([message]) + const normalizedNew = normalizeMessages([message]); for (const m of normalizedNew) { for (const content of (m.message?.content ?? []) as readonly { readonly type: string }[]) { - if ( - content.type !== 'tool_use' && - content.type !== 'tool_result' - ) { - continue + if (content.type !== 'tool_use' && content.type !== 'tool_result') { + continue; } // Forward progress updates @@ -1504,7 +1310,7 @@ export const AgentTool = buildTool({ prompt: '', agentId: syncAgentId, }, - }) + }); } } } @@ -1513,57 +1319,50 @@ export const AgentTool = buildTool({ // Handle errors from the sync agent loop // AbortError should be re-thrown for proper interruption handling if (error instanceof AbortError) { - wasAborted = true + wasAborted = true; logEvent('tengu_agent_tool_terminated', { - agent_type: - metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: - metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, is_async: false, is_built_in_agent: metadata.isBuiltInAgent, - reason: - 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - throw error + reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + throw error; } // Log the error for debugging logForDebugging(`Sync agent error: ${errorMessage(error)}`, { level: 'error', - }) + }); // Store the error to handle after cleanup - syncAgentError = toError(error) + syncAgentError = toError(error); } finally { // Clear the background hint UI if (toolUseContext.setToolJSX) { - toolUseContext.setToolJSX(null) + toolUseContext.setToolJSX(null); } // Stop foreground summarization. Idempotent — if already stopped at // the backgrounding transition, this is a no-op. The backgrounded // closure owns a separate stop function (stopBackgroundedSummarization). - stopForegroundSummarization?.() + stopForegroundSummarization?.(); // Unregister foreground task if agent completed without being backgrounded if (foregroundTaskId) { - unregisterAgentForeground(foregroundTaskId, rootSetAppState) + unregisterAgentForeground(foregroundTaskId, rootSetAppState); // Notify SDK consumers (e.g. VS Code subagent panel) that this // foreground agent is done. Goes through drainSdkEvents() — does // NOT trigger the print.ts XML task_notification parser or the LLM loop. if (!wasBackgrounded) { - const progress = getProgressUpdate(syncTracker) + const progress = getProgressUpdate(syncTracker); enqueueSdkEvent({ type: 'system', subtype: 'task_notification', task_id: foregroundTaskId, tool_use_id: toolUseContext.toolUseId, - status: syncAgentError - ? 'failed' - : wasAborted - ? 'stopped' - : 'completed', + status: syncAgentError ? 'failed' : wasAborted ? 'stopped' : 'completed', output_file: '', summary: description, usage: { @@ -1571,47 +1370,42 @@ export const AgentTool = buildTool({ tool_uses: progress.toolUseCount, duration_ms: Date.now() - agentStartTime, }, - }) + }); } } // Clean up scoped skills so they don't accumulate in the global map - clearInvokedSkillsForAgent(syncAgentId) + clearInvokedSkillsForAgent(syncAgentId); // Clean up dumpState entry for this agent to prevent unbounded growth // Skip if backgrounded — the backgrounded agent's finally handles cleanup if (!wasBackgrounded) { - clearDumpState(syncAgentId) + clearDumpState(syncAgentId); } // Cancel auto-background timer if agent completed before it fired - cancelAutoBackground?.() + cancelAutoBackground?.(); // Clean up worktree if applicable (in finally to handle abort/error paths) // Skip if backgrounded — the background continuation is still running in it if (!wasBackgrounded) { - worktreeResult = await cleanupWorktreeIfNeeded() + worktreeResult = await cleanupWorktreeIfNeeded(); } } // Re-throw abort errors // TODO: Find a cleaner way to express this - const lastMessage = agentMessages.findLast( - _ => _.type !== 'system' && _.type !== 'progress', - ) + const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); if (lastMessage && isSyntheticMessage(lastMessage)) { logEvent('tengu_agent_tool_terminated', { - agent_type: - metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: - metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, is_async: false, is_built_in_agent: metadata.isBuiltInAgent, - reason: - 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - throw new AbortError() + reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + throw new AbortError(); } // If an error occurred during iteration, try to return a result with @@ -1619,30 +1413,22 @@ export const AgentTool = buildTool({ // re-throw the error so it's properly handled by the tool framework. if (syncAgentError) { // Check if we have any assistant messages to return - const hasAssistantMessages = agentMessages.some( - msg => msg.type === 'assistant', - ) + const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant'); if (!hasAssistantMessages) { // No messages collected, re-throw the error - throw syncAgentError + throw syncAgentError; } // We have some messages, try to finalize and return them // This allows the parent agent to see partial progress even after an error - logForDebugging( - `Sync agent recovering from error with ${agentMessages.length} messages`, - ) + logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); } - const agentResult = finalizeAgentTool( - agentMessages, - syncAgentId, - metadata, - ) + const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); if (feature('TRANSCRIPT_CLASSIFIER')) { - const currentAppState = toolUseContext.getAppState() + const currentAppState = toolUseContext.getAppState(); const handoffWarning = await classifyHandoffIfNeeded({ agentMessages, tools: toolUseContext.options.tools, @@ -1650,12 +1436,9 @@ export const AgentTool = buildTool({ abortSignal: toolUseContext.abortController.signal, subagentType: selectedAgent.agentType, totalToolUseCount: agentResult.totalToolUseCount, - }) + }); if (handoffWarning) { - agentResult.content = [ - { type: 'text' as const, text: handoffWarning }, - ...agentResult.content, - ] + agentResult.content = [{ type: 'text' as const, text: handoffWarning }, ...agentResult.content]; } } @@ -1666,59 +1449,53 @@ export const AgentTool = buildTool({ ...agentResult, ...worktreeResult, }, - } + }; }), - ) + ); } }, isReadOnly() { - return true // delegates permission checks to its underlying tools + return true; // delegates permission checks to its underlying tools }, toAutoClassifierInput(input) { - const i = input as AgentToolInput - const tags = [ - i.subagent_type, - i.mode ? `mode=${i.mode}` : undefined, - ].filter((t): t is string => t !== undefined) - const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': ' - return `${prefix}${i.prompt}` + const i = input as AgentToolInput; + const tags = [i.subagent_type, i.mode ? `mode=${i.mode}` : undefined].filter((t): t is string => t !== undefined); + const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '; + return `${prefix}${i.prompt}`; }, isConcurrencySafe() { - return true + return true; }, userFacingName, userFacingNameBackgroundColor, getActivityDescription(input) { - return input?.description ?? 'Running task' + return input?.description ?? 'Running task'; }, async checkPermissions(input, context): Promise { - const appState = context.getAppState() + const appState = context.getAppState(); // Only route through auto mode classifier when in auto mode // In all other modes, auto-approve sub-agent generation // Note: process.env.USER_TYPE === 'ant' guard enables dead code elimination for external builds - if ( - process.env.USER_TYPE === 'ant' && - appState.toolPermissionContext.mode === 'auto' - ) { + if (process.env.USER_TYPE === 'ant' && appState.toolPermissionContext.mode === 'auto') { return { behavior: 'passthrough', message: 'Agent tool requires permission to spawn sub-agents.', - } + }; } - return { behavior: 'allow', updatedInput: input } + return { behavior: 'allow', updatedInput: input }; }, mapToolResultToToolResultBlockParam(data, toolUseID) { // Multi-agent spawn result - const internalData = data as InternalOutput + const internalData = data as InternalOutput; if ( typeof internalData === 'object' && internalData !== null && 'status' in internalData && internalData.status === 'teammate_spawned' ) { - const spawnData = internalData as TeammateSpawnedOutput + const spawnData = internalData as TeammateSpawnedOutput; return { tool_use_id: toolUseID, type: 'tool_result', @@ -1732,10 +1509,10 @@ team_name: ${spawnData.team_name} The agent is now running and will receive instructions via mailbox.`, }, ], - } + }; } if ('status' in internalData && internalData.status === 'remote_launched') { - const r = internalData + const r = internalData; return { tool_use_id: toolUseID, type: 'tool_result', @@ -1745,14 +1522,14 @@ The agent is now running and will receive instructions via mailbox.`, text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`, }, ], - } + }; } if (data.status === 'async_launched') { - const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.` + const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`; const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` - : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.` - const text = `${prefix}\n${instructions}` + : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`; + const text = `${prefix}\n${instructions}`; return { tool_use_id: toolUseID, type: 'tool_result', @@ -1762,13 +1539,13 @@ The agent is now running and will receive instructions via mailbox.`, text, }, ], - } + }; } if (data.status === 'completed') { - const worktreeData = data as Record + const worktreeData = data as Record; const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` - : '' + : ''; // If the subagent completes with no content, the tool_result is just the // agentId/usage trailer below — a metadata-only block at the prompt tail. // Some models read that as "nothing to act on" and end their turn @@ -1781,22 +1558,18 @@ The agent is now running and will receive instructions via mailbox.`, type: 'text' as const, text: '(Subagent completed but returned no output.)', }, - ] + ]; // One-shot built-ins (Explore, Plan) are never continued via SendMessage // — the agentId hint and block are dead weight (~135 chars × // 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this // block (it uses logEvent in finalizeAgentTool), so dropping is safe. // agentType is optional for resume compat — missing means show trailer. - if ( - data.agentType && - ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && - !worktreeInfoText - ) { + if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) { return { tool_use_id: toolUseID, type: 'tool_result', content: contentOrMarker, - } + }; } return { tool_use_id: toolUseID, @@ -1811,12 +1584,10 @@ tool_uses: ${data.totalToolUseCount} duration_ms: ${data.totalDurationMs}`, }, ], - } + }; } - data satisfies never - throw new Error( - `Unexpected agent tool result status: ${(data as { status: string }).status}`, - ) + data satisfies never; + throw new Error(`Unexpected agent tool result status: ${(data as { status: string }).status}`); }, renderToolResultMessage, renderToolUseMessage, @@ -1825,12 +1596,12 @@ duration_ms: ${data.totalDurationMs}`, renderToolUseRejectedMessage, renderToolUseErrorMessage, renderGroupedToolUse: renderGroupedAgentToolUse, -} satisfies ToolDef) +} satisfies ToolDef); function resolveTeamName( input: { team_name?: string }, appState: { teamContext?: { teamName: string } }, ): string | undefined { - if (!isAgentSwarmsEnabled()) return undefined - return input.team_name || appState.teamContext?.teamName + if (!isAgentSwarmsEnabled()) return undefined; + return input.team_name || appState.teamContext?.teamName; } diff --git a/packages/builtin-tools/src/tools/AgentTool/UI.tsx b/packages/builtin-tools/src/tools/AgentTool/UI.tsx index 4ba99149a..0c571a218 100644 --- a/packages/builtin-tools/src/tools/AgentTool/UI.tsx +++ b/packages/builtin-tools/src/tools/AgentTool/UI.tsx @@ -1,59 +1,37 @@ -import type { - ContentBlock, - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/index.mjs' -type BetaContentBlock = ContentBlock | ToolResultBlockParam -import * as React from 'react' -import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js' -import { - CtrlOToExpand, - SubAgentProvider, -} from 'src/components/CtrlOToExpand.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import type { z } from 'zod/v4' -import { AgentProgressLine } from 'src/components/AgentProgressLine.js' -import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js' -import { FallbackToolUseRejectedMessage } from 'src/components/FallbackToolUseRejectedMessage.js' -import { Markdown } from 'src/components/Markdown.js' -import { Message as MessageComponent } from 'src/components/Message.js' -import { MessageResponse } from 'src/components/MessageResponse.js' -import { ToolUseLoader } from 'src/components/ToolUseLoader.js' -import { Box, Text } from '@anthropic/ink' -import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js' -import { findToolByName, type Tools } from 'src/Tool.js' -import type { Message, ProgressMessage } from 'src/types/message.js' -import type { AgentToolProgress } from 'src/types/tools.js' -import { count } from 'src/utils/array.js' -import { - getSearchOrReadFromContent, - getSearchReadSummaryText, -} from 'src/utils/collapseReadSearch.js' -import { getDisplayPath } from 'src/utils/file.js' -import { formatDuration, formatNumber } from 'src/utils/format.js' -import { - buildSubagentLookups, - createAssistantMessage, - EMPTY_LOOKUPS, -} from 'src/utils/messages.js' -import type { ModelAlias } from 'src/utils/model/aliases.js' -import { - getMainLoopModel, - parseUserSpecifiedModel, - renderModelName, -} from 'src/utils/model/model.js' -import type { Theme, ThemeName } from 'src/utils/theme.js' -import type { - outputSchema, - Progress, - RemoteLaunchedOutput, -} from './AgentTool.js' -import { inputSchema } from './AgentTool.js' -import { getAgentColor } from './agentColorManager.js' -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' -import { BetaUsage } from '@anthropic-ai/sdk/resources/beta.mjs' +import type { ContentBlock, ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +type BetaContentBlock = ContentBlock | ToolResultBlockParam; +import * as React from 'react'; +import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; +import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import type { z } from 'zod/v4'; +import { AgentProgressLine } from 'src/components/AgentProgressLine.js'; +import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'; +import { FallbackToolUseRejectedMessage } from 'src/components/FallbackToolUseRejectedMessage.js'; +import { Markdown } from 'src/components/Markdown.js'; +import { Message as MessageComponent } from 'src/components/Message.js'; +import { MessageResponse } from 'src/components/MessageResponse.js'; +import { ToolUseLoader } from 'src/components/ToolUseLoader.js'; +import { Box, Text } from '@anthropic/ink'; +import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'; +import { findToolByName, type Tools } from 'src/Tool.js'; +import type { Message, ProgressMessage } from 'src/types/message.js'; +import type { AgentToolProgress } from 'src/types/tools.js'; +import { count } from 'src/utils/array.js'; +import { getSearchOrReadFromContent, getSearchReadSummaryText } from 'src/utils/collapseReadSearch.js'; +import { getDisplayPath } from 'src/utils/file.js'; +import { formatDuration, formatNumber } from 'src/utils/format.js'; +import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from 'src/utils/messages.js'; +import type { ModelAlias } from 'src/utils/model/aliases.js'; +import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from 'src/utils/model/model.js'; +import type { Theme, ThemeName } from 'src/utils/theme.js'; +import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; +import { inputSchema } from './AgentTool.js'; +import { getAgentColor } from './agentColorManager.js'; +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; +import { BetaUsage } from '@anthropic-ai/sdk/resources/beta.mjs'; -const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 +const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; /** * Guard: checks if progress data has a `message` field (agent_progress or @@ -62,10 +40,10 @@ const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { - return false + return false; } - const msg = (data as AgentToolProgress).message - return msg != null && typeof msg === 'object' && 'type' in msg + const msg = (data as AgentToolProgress).message; + return msg != null && typeof msg === 'object' && 'type' in msg; } /** @@ -81,41 +59,39 @@ function getSearchOrReadInfo( toolUseByID: Map, ): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { - return null + return null; } - const message = progressMessage.data.message + const message = progressMessage.data.message; // Check tool_use (assistant message) if (message.type === 'assistant') { - return getSearchOrReadFromContent(message.message.content[0], tools) + return getSearchOrReadFromContent(message.message.content[0], tools); } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { - const content = message.message.content[0] + const content = message.message.content[0]; if (content?.type === 'tool_result') { - const toolUse = toolUseByID.get(content.tool_use_id) + const toolUse = toolUseByID.get(content.tool_use_id); if (toolUse) { - return getSearchOrReadFromContent(toolUse, tools) + return getSearchOrReadFromContent(toolUse, tools); } } } - return null + return null; } type SummaryMessage = { - type: 'summary' - searchCount: number - readCount: number - replCount: number - uuid: string - isActive: boolean // true if still in progress (last message was tool_use, not tool_result) -} + type: 'summary'; + searchCount: number; + readCount: number; + replCount: number; + uuid: string; + isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) +}; -type ProcessedMessage = - | { type: 'original'; message: ProgressMessage } - | SummaryMessage +type ProcessedMessage = { type: 'original'; message: ProgressMessage } | SummaryMessage; /** * Process progress messages to group consecutive search/read operations into summaries. @@ -131,27 +107,21 @@ function processProgressMessages( if (process.env.USER_TYPE !== 'ant') { return messages .filter( - (m): m is ProgressMessage => - hasProgressMessage(m.data) && m.data.message.type !== 'user', + (m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user', ) - .map(m => ({ type: 'original', message: m })) + .map(m => ({ type: 'original', message: m })); } - const result: ProcessedMessage[] = [] + const result: ProcessedMessage[] = []; let currentGroup: { - searchCount: number - readCount: number - replCount: number - startUuid: string - } | null = null + searchCount: number; + readCount: number; + replCount: number; + startUuid: string; + } | null = null; function flushGroup(isActive: boolean): void { - if ( - currentGroup && - (currentGroup.searchCount > 0 || - currentGroup.readCount > 0 || - currentGroup.replCount > 0) - ) { + if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, @@ -159,27 +129,25 @@ function processProgressMessages( replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, isActive, - }) + }); } - currentGroup = null + currentGroup = null; } - const agentMessages = messages.filter( - (m): m is ProgressMessage => hasProgressMessage(m.data), - ) + const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); // Build tool_use lookup incrementally as we iterate - const toolUseByID = new Map() + const toolUseByID = new Map(); for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam) + toolUseByID.set(c.id, c as ToolUseBlockParam); } } } - const info = getSearchOrReadInfo(msg, tools, toolUseByID) + const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group @@ -189,48 +157,48 @@ function processProgressMessages( readCount: 0, replCount: 0, startUuid: msg.uuid, - } + }; } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - currentGroup.searchCount++ + currentGroup.searchCount++; } else if (info.isREPL) { - currentGroup.replCount++ + currentGroup.replCount++; } else if (info.isRead) { - currentGroup.readCount++ + currentGroup.readCount++; } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message - flushGroup(false) + flushGroup(false); // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { - result.push({ type: 'original', message: msg }) + result.push({ type: 'original', message: msg }); } } } // Flush any remaining group - it's active if the agent is still running - flushGroup(isAgentRunning) + flushGroup(isAgentRunning); - return result + return result; } -const ESTIMATED_LINES_PER_TOOL = 9 -const TERMINAL_BUFFER_LINES = 7 +const ESTIMATED_LINES_PER_TOOL = 9; +const TERMINAL_BUFFER_LINES = 7; -type Output = z.input> +type Output = z.input>; export function AgentPromptDisplay({ prompt, dim: _dim = false, }: { - prompt: string - theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally - dim?: boolean // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) + prompt: string; + theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally + dim?: boolean; // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) }): React.ReactNode { return ( @@ -241,14 +209,14 @@ export function AgentPromptDisplay({ {prompt} - ) + ); } export function AgentResponseDisplay({ content, }: { - content: { type: string; text: string }[] - theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally + content: { type: string; text: string }[]; + theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally }): React.ReactNode { return ( @@ -261,44 +229,36 @@ export function AgentResponseDisplay({ ))} - ) + ); } type VerboseAgentTranscriptProps = { - progressMessages: ProgressMessage[] - tools: Tools - verbose: boolean -} + progressMessages: ProgressMessage[]; + tools: Tools; + verbose: boolean; +}; -function VerboseAgentTranscript({ - progressMessages, - tools, - verbose, -}: VerboseAgentTranscriptProps): React.ReactNode { +function VerboseAgentTranscript({ progressMessages, tools, verbose }: VerboseAgentTranscriptProps): React.ReactNode { const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( progressMessages - .filter((pm): pm is ProgressMessage => - hasProgressMessage(pm.data), - ) + .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), - ) + ); // Filter out user tool_result messages that lack toolUseResult. // Subagent progress messages don't carry the parsed tool output, // so UserToolSuccessMessage returns null and MessageResponse renders // a bare ⎿ with no content. - const filteredMessages = progressMessages.filter( - (pm): pm is ProgressMessage => { - if (!hasProgressMessage(pm.data)) { - return false - } - const msg = pm.data.message - if (msg.type === 'user' && msg.toolUseResult === undefined) { - return false - } - return true - }, - ) + const filteredMessages = progressMessages.filter((pm): pm is ProgressMessage => { + if (!hasProgressMessage(pm.data)) { + return false; + } + const msg = pm.data.message; + if (msg.type === 'user' && msg.toolUseResult === undefined) { + return false; + } + return true; + }); return ( <> @@ -321,7 +281,7 @@ function VerboseAgentTranscript({ ))} - ) + ); } export function renderToolResultMessage( @@ -333,15 +293,15 @@ export function renderToolResultMessage( theme, isTranscriptMode = false, }: { - tools: Tools - verbose: boolean - theme: ThemeName - isTranscriptMode?: boolean + tools: Tools; + verbose: boolean; + theme: ThemeName; + isTranscriptMode?: boolean; }, ): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. - const internal = data as Output | RemoteLaunchedOutput + const internal = data as Output | RemoteLaunchedOutput; if (internal.status === 'remote_launched') { return ( @@ -354,10 +314,10 @@ export function renderToolResultMessage( - ) + ); } if (data.status === 'async_launched') { - const { prompt } = data + const { prompt } = data; return ( @@ -388,42 +348,32 @@ export function renderToolResultMessage( )} - ) + ); } if (data.status !== 'completed') { - return null + return null; } - const { - agentId, - totalDurationMs, - totalToolUseCount, - totalTokens, - usage, - content, - prompt, - } = data + const { agentId, totalDurationMs, totalToolUseCount, totalTokens, usage, content, prompt } = data; const result = [ totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs), - ] + ]; - const completionMessage = `Done (${result.join(' · ')})` + const completionMessage = `Done (${result.join(' · ')})`; const finalAssistantMessage = createAssistantMessage({ content: completionMessage, usage: { ...usage, inference_geo: null, iterations: null, speed: null } as unknown as BetaUsage, - }) + }); return ( {process.env.USER_TYPE === 'ant' && ( - - [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {isTranscriptMode && prompt && ( @@ -433,11 +383,7 @@ export function renderToolResultMessage( )} {isTranscriptMode ? ( - + ) : null} {isTranscriptMode && content && content.length > 0 && ( @@ -468,52 +414,52 @@ export function renderToolResultMessage( )} - ) + ); } export function renderToolUseMessage({ description, prompt, }: Partial<{ - description: string - prompt: string + description: string; + prompt: string; }>): React.ReactNode { if (!description || !prompt) { - return null + return null; } - return description + return description; } export function renderToolUseTag( input: Partial<{ - description: string - prompt: string - subagent_type: string - model?: ModelAlias + description: string; + prompt: string; + subagent_type: string; + model?: ModelAlias; }>, ): React.ReactNode { - const tags: React.ReactNode[] = [] + const tags: React.ReactNode[] = []; if (input.model) { - const mainModel = getMainLoopModel() - const agentModel = parseUserSpecifiedModel(input.model) + const mainModel = getMainLoopModel(); + const agentModel = parseUserSpecifiedModel(input.model); if (agentModel !== mainModel) { tags.push( {renderModelName(agentModel)} , - ) + ); } } if (tags.length === 0) { - return null + return null; } - return <>{tags} + return <>{tags}; } -const INITIALIZING_TEXT = 'Initializing…' +const INITIALIZING_TEXT = 'Initializing…'; export function renderToolUseProgressMessage( progressMessages: ProgressMessage[], @@ -524,11 +470,11 @@ export function renderToolUseProgressMessage( inProgressToolCallCount, isTranscriptMode = false, }: { - tools: Tools - verbose: boolean - terminalSize?: { columns: number; rows: number } - inProgressToolCallCount?: number - isTranscriptMode?: boolean + tools: Tools; + verbose: boolean; + terminalSize?: { columns: number; rows: number }; + inProgressToolCallCount?: number; + isTranscriptMode?: boolean; }, ): React.ReactNode { if (!progressMessages.length) { @@ -536,57 +482,49 @@ export function renderToolUseProgressMessage( {INITIALIZING_TEXT} - ) + ); } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content - const toolToolRenderLinesEstimate = - (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + - TERMINAL_BUFFER_LINES + const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; const shouldUseCondensedMode = - !isTranscriptMode && - terminalSize && - terminalSize.rows && - terminalSize.rows < toolToolRenderLinesEstimate + !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; const getProgressStats = () => { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false + return false; } - const message = msg.data.message - return message.message.content.some( - (content: BetaContentBlock) => content.type === 'tool_use', - ) - }) + const message = msg.data.message; + return message.message.content.some((content: BetaContentBlock) => content.type === 'tool_use'); + }); const latestAssistant = progressMessages.findLast( (msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', - ) + ); - let tokens = null + let tokens = null; if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage + const usage = latestAssistant.data.message.message.usage; tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + - usage.output_tokens + usage.output_tokens; } - return { toolUseCount, tokens } - } + return { toolUseCount, tokens }; + }; if (shouldUseCondensedMode) { - const { toolUseCount, tokens } = getProgressStats() + const { toolUseCount, tokens } = getProgressStats(); return ( - In progress… · {toolUseCount} tool{' '} - {toolUseCount === 1 ? 'use' : 'uses'} + In progress… · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - ) + ); } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running - const processedMessages = processProgressMessages( - progressMessages, - tools, - true, - ) + const processedMessages = processProgressMessages(progressMessages, tools, true); // For display, take the last few processed messages const displayedMessages = isTranscriptMode ? processedMessages - : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW) + : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple @@ -619,26 +553,20 @@ export function renderToolUseProgressMessage( // hidden messages inflates the number shown to the user. const hiddenMessages = isTranscriptMode ? [] - : processedMessages.slice( - 0, - Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW), - ) + : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { - return m.searchCount + m.readCount + m.replCount > 0 + return m.searchCount + m.readCount + m.replCount > 0; } - const data = m.message.data + const data = m.message.data; if (!hasProgressMessage(data)) { - return false + return false; } - return data.message.message.content.some( - (content: BetaContentBlock) => content.type === 'tool_use', - ) - }) + return data.message.message.content.some((content: BetaContentBlock) => content.type === 'tool_use'); + }); - const firstData = progressMessages[0]?.data - const prompt = - firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined + const firstData = progressMessages[0]?.data; + const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet @@ -649,19 +577,14 @@ export function renderToolUseProgressMessage( {INITIALIZING_TEXT} - ) + ); } - const { - lookups: subagentLookups, - inProgressToolUseIDs: collapsedInProgressIDs, - } = buildSubagentLookups( + const { lookups: subagentLookups, inProgressToolUseIDs: collapsedInProgressIDs } = buildSubagentLookups( progressMessages - .filter((pm): pm is ProgressMessage => - hasProgressMessage(pm.data), - ) + .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), - ) + ); return ( @@ -680,12 +603,12 @@ export function renderToolUseProgressMessage( processed.readCount, processed.isActive, processed.replCount, - ) + ); return ( {summaryText} - ) + ); } // Render original message without height=1 wrapper so null // content (tool not found, renderToolUseMessage returns null) @@ -708,18 +631,17 @@ export function renderToolUseProgressMessage( isTranscriptMode={false} isStatic={true} /> - ) + ); })} {hiddenToolUseCount > 0 && ( - +{hiddenToolUseCount} more tool{' '} - {hiddenToolUseCount === 1 ? 'use' : 'uses'} + +{hiddenToolUseCount} more tool {hiddenToolUseCount === 1 ? 'use' : 'uses'} )} - ) + ); } export function renderToolUseRejectedMessage( @@ -730,28 +652,25 @@ export function renderToolUseRejectedMessage( verbose, isTranscriptMode, }: { - columns: number - messages: Message[] - style?: 'condensed' - theme: ThemeName - progressMessagesForMessage: ProgressMessage[] - tools: Tools - verbose: boolean - isTranscriptMode?: boolean + columns: number; + messages: Message[]; + style?: 'condensed'; + theme: ThemeName; + progressMessagesForMessage: ProgressMessage[]; + tools: Tools; + verbose: boolean; + isTranscriptMode?: boolean; }, ): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) - const firstData = progressMessagesForMessage[0]?.data - const agentId = - firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined + const firstData = progressMessagesForMessage[0]?.data; + const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; return ( <> {process.env.USER_TYPE === 'ant' && agentId && ( - - [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {renderToolUseProgressMessage(progressMessagesForMessage, { @@ -761,7 +680,7 @@ export function renderToolUseRejectedMessage( })} - ) + ); } export function renderToolUseErrorMessage( @@ -772,10 +691,10 @@ export function renderToolUseErrorMessage( verbose, isTranscriptMode, }: { - progressMessagesForMessage: ProgressMessage[] - tools: Tools - verbose: boolean - isTranscriptMode?: boolean + progressMessagesForMessage: ProgressMessage[]; + tools: Tools; + verbose: boolean; + isTranscriptMode?: boolean; }, ): React.ReactNode { return ( @@ -787,159 +706,132 @@ export function renderToolUseErrorMessage( })} - ) + ); } function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number - tokens: number | null + toolUseCount: number; + tokens: number | null; } { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false + return false; } - const message = msg.data.message + const message = msg.data.message; return ( message.type === 'user' && message.message.content.some((content: BetaContentBlock) => content.type === 'tool_result') - ) - }) + ); + }); const latestAssistant = progressMessages.findLast( (msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', - ) + ); - let tokens = null + let tokens = null; if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage + const usage = latestAssistant.data.message.message.usage; tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + - usage.output_tokens + usage.output_tokens; } - return { toolUseCount, tokens } + return { toolUseCount, tokens }; } export function renderGroupedAgentToolUse( toolUses: Array<{ - param: ToolUseBlockParam - isResolved: boolean - isError: boolean - isInProgress: boolean - progressMessages: ProgressMessage[] + param: ToolUseBlockParam; + isResolved: boolean; + isError: boolean; + isInProgress: boolean; + progressMessages: ProgressMessage[]; result?: { - param: ToolResultBlockParam - output: Output - } + param: ToolResultBlockParam; + output: Output; + }; }>, options: { - shouldAnimate: boolean - tools: Tools + shouldAnimate: boolean; + tools: Tools; }, ): React.ReactNode | null { - const { shouldAnimate, tools } = options + const { shouldAnimate, tools } = options; // Calculate stats for each agent - const agentStats = toolUses.map( - ({ param, isResolved, isError, progressMessages, result }) => { - const stats = calculateAgentStats(progressMessages) - const lastToolInfo = extractLastToolInfo(progressMessages, tools) - const parsedInput = inputSchema().safeParse(param.input) + const agentStats = toolUses.map(({ param, isResolved, isError, progressMessages, result }) => { + const stats = calculateAgentStats(progressMessages); + const lastToolInfo = extractLastToolInfo(progressMessages, tools); + const parsedInput = inputSchema().safeParse(param.input); - // teammate_spawned is not part of the exported Output type (cast through unknown - // for dead code elimination), so check via string comparison on the raw value - const isTeammateSpawn = - (result?.output?.status as string) === 'teammate_spawned' + // teammate_spawned is not part of the exported Output type (cast through unknown + // for dead code elimination), so check via string comparison on the raw value + const isTeammateSpawn = (result?.output?.status as string) === 'teammate_spawned'; - // For teammate spawns, show @name with type in parens and description as status - let agentType: string - let description: string | undefined - let color: keyof Theme | undefined - let descriptionColor: keyof Theme | undefined - let taskDescription: string | undefined - if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { - agentType = `@${parsedInput.data.name}` - const subagentType = parsedInput.data.subagent_type - description = isCustomSubagentType(subagentType) - ? subagentType - : undefined - taskDescription = parsedInput.data.description - // Use the custom agent definition's color on the type, not the name - descriptionColor = isCustomSubagentType(subagentType) - ? getAgentColor(subagentType) - : undefined - } else { - agentType = parsedInput.success - ? userFacingName(parsedInput.data) - : 'Agent' - description = parsedInput.success - ? parsedInput.data.description - : undefined - color = parsedInput.success - ? userFacingNameBackgroundColor(parsedInput.data) - : undefined - taskDescription = undefined - } + // For teammate spawns, show @name with type in parens and description as status + let agentType: string; + let description: string | undefined; + let color: keyof Theme | undefined; + let descriptionColor: keyof Theme | undefined; + let taskDescription: string | undefined; + if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { + agentType = `@${parsedInput.data.name}`; + const subagentType = parsedInput.data.subagent_type; + description = isCustomSubagentType(subagentType) ? subagentType : undefined; + taskDescription = parsedInput.data.description; + // Use the custom agent definition's color on the type, not the name + descriptionColor = isCustomSubagentType(subagentType) ? getAgentColor(subagentType) : undefined; + } else { + agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; + description = parsedInput.success ? parsedInput.data.description : undefined; + color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; + taskDescription = undefined; + } - // Check if this was launched as a background agent OR backgrounded mid-execution - const launchedAsAsync = - parsedInput.success && - 'run_in_background' in parsedInput.data && - parsedInput.data.run_in_background === true - const outputStatus = (result?.output as { status?: string } | undefined) - ?.status - const backgroundedMidExecution = - outputStatus === 'async_launched' || outputStatus === 'remote_launched' - const isAsync = - launchedAsAsync || backgroundedMidExecution || isTeammateSpawn + // Check if this was launched as a background agent OR backgrounded mid-execution + const launchedAsAsync = + parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; + const outputStatus = (result?.output as { status?: string } | undefined)?.status; + const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; + const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; - const name = parsedInput.success ? parsedInput.data.name : undefined + const name = parsedInput.success ? parsedInput.data.name : undefined; - return { - id: param.id, - agentType, - description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, - isResolved, - isError, - isAsync, - color, - descriptionColor, - lastToolInfo, - taskDescription, - name, - } - }, - ) + return { + id: param.id, + agentType, + description, + toolUseCount: stats.toolUseCount, + tokens: stats.tokens, + isResolved, + isError, + isAsync, + color, + descriptionColor, + lastToolInfo, + taskDescription, + name, + }; + }); - const anyUnresolved = toolUses.some(t => !t.isResolved) - const anyError = toolUses.some(t => t.isError) - const allComplete = !anyUnresolved + const anyUnresolved = toolUses.some(t => !t.isResolved); + const anyError = toolUses.some(t => t.isError); + const allComplete = !anyUnresolved; // Check if all agents are the same type - const allSameType = - agentStats.length > 0 && - agentStats.every(stat => stat.agentType === agentStats[0]?.agentType) - const commonType = - allSameType && agentStats[0]?.agentType !== 'Agent' - ? agentStats[0]?.agentType - : null + const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); + const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; // Check if all resolved agents are async (background) - const allAsync = agentStats.every(stat => stat.isAsync) + const allAsync = agentStats.every(stat => stat.isAsync); return ( - + {allComplete ? ( allAsync ? ( @@ -951,14 +843,12 @@ export function renderGroupedAgentToolUse( ) : ( <> - {toolUses.length}{' '} - {commonType ? `${commonType} agents` : 'agents'} finished + {toolUses.length} {commonType ? `${commonType} agents` : 'agents'} finished ) ) : ( <> - Running {toolUses.length}{' '} - {commonType ? `${commonType} agents` : 'agents'}… + Running {toolUses.length} {commonType ? `${commonType} agents` : 'agents'}… )}{' '} @@ -985,154 +875,131 @@ export function renderGroupedAgentToolUse( /> ))} - ) + ); } export function userFacingName( input: | Partial<{ - description: string - prompt: string - subagent_type: string - name: string - team_name: string + description: string; + prompt: string; + subagent_type: string; + name: string; + team_name: string; }> | undefined, ): string { - if ( - input?.subagent_type && - input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType - ) { + if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { - return 'Agent' + return 'Agent'; } - return input.subagent_type + return input.subagent_type; } - return 'Agent' + return 'Agent'; } export function userFacingNameBackgroundColor( - input: - | Partial<{ description: string; prompt: string; subagent_type: string }> - | undefined, + input: Partial<{ description: string; prompt: string; subagent_type: string }> | undefined, ): keyof Theme | undefined { if (!input?.subagent_type) { - return undefined + return undefined; } // Get the color for this agent - return getAgentColor(input.subagent_type) + return getAgentColor(input.subagent_type); } -export function extractLastToolInfo( - progressMessages: ProgressMessage[], - tools: Tools, -): string | null { +export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) - const toolUseByID = new Map() + const toolUseByID = new Map(); for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { - continue + continue; } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam) + toolUseByID.set(c.id, c as ToolUseBlockParam); } } } } // Count trailing consecutive search/read operations from the end - let searchCount = 0 - let readCount = 0 + let searchCount = 0; + let readCount = 0; for (let i = progressMessages.length - 1; i >= 0; i--) { - const msg = progressMessages[i]! + const msg = progressMessages[i]!; if (!hasProgressMessage(msg.data)) { - continue + continue; } - const info = getSearchOrReadInfo(msg, tools, toolUseByID) + const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - searchCount++ + searchCount++; } else if (info.isRead) { - readCount++ + readCount++; } } } else { - break + break; } } if (searchCount + readCount >= 2) { - return getSearchReadSummaryText(searchCount, readCount, true) + return getSearchReadSummaryText(searchCount, readCount, true); } // Find the last tool_result message - const lastToolResult = progressMessages.findLast( - (msg): msg is ProgressMessage => { - if (!hasProgressMessage(msg.data)) { - return false - } - const message = msg.data.message - return ( - message.type === 'user' && - message.message.content.some((c: BetaContentBlock) => c.type === 'tool_result') - ) - }, - ) + const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { + if (!hasProgressMessage(msg.data)) { + return false; + } + const message = msg.data.message; + return message.type === 'user' && message.message.content.some((c: BetaContentBlock) => c.type === 'tool_result'); + }); if (lastToolResult?.data.message.type === 'user') { const toolResultBlock = lastToolResult.data.message.message.content.find( (c: BetaContentBlock) => c.type === 'tool_result', - ) + ); if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above - const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id) + const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name) + const tool = findToolByName(tools, toolUseBlock.name); if (!tool) { - return toolUseBlock.name // Fallback to raw name + return toolUseBlock.name; // Fallback to raw name } - const input = toolUseBlock.input as Record - const parsedInput = tool.inputSchema.safeParse(input) + const input = toolUseBlock.input as Record; + const parsedInput = tool.inputSchema.safeParse(input); // Get user-facing tool name - const userFacingToolName = tool.userFacingName( - parsedInput.success ? parsedInput.data : undefined, - ) + const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); // Try to get summary from the tool itself if (tool.getToolUseSummary) { - const summary = tool.getToolUseSummary( - parsedInput.success ? parsedInput.data : undefined, - ) + const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); if (summary) { - return `${userFacingToolName}: ${summary}` + return `${userFacingToolName}: ${summary}`; } } // Default: just show user-facing tool name - return userFacingToolName + return userFacingToolName; } } } - return null + return null; } -function isCustomSubagentType( - subagentType: string | undefined, -): subagentType is string { - return ( - !!subagentType && - subagentType !== GENERAL_PURPOSE_AGENT.agentType && - subagentType !== 'worker' - ) +function isCustomSubagentType(subagentType: string | undefined): subagentType is string { + return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; } diff --git a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentDisplay.test.ts b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentDisplay.test.ts index 66d7f1953..59c3e6a88 100644 --- a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentDisplay.test.ts +++ b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentDisplay.test.ts @@ -1,11 +1,11 @@ -import { mock, describe, expect, test } from "bun:test"; +import { mock, describe, expect, test } from 'bun:test' // Mock heavy deps -mock.module("src/utils/model/agent.js", () => ({ +mock.module('src/utils/model/agent.js', () => ({ getDefaultSubagentModel: () => undefined, -})); +})) -mock.module("src/utils/settings/constants.js", () => ({ +mock.module('src/utils/settings/constants.js', () => ({ getSourceDisplayName: (source: string) => source, getSourceDisplayNameLowercase: (source: string) => source, getSourceDisplayNameCapitalized: (source: string) => source, @@ -15,133 +15,131 @@ mock.module("src/utils/settings/constants.js", () => ({ parseSettingSourcesFlag: () => [], getEnabledSettingSources: () => [], isSettingSourceEnabled: () => true, - SETTING_SOURCES: ["localSettings", "userSettings", "projectSettings"], - SOURCES: ["localSettings", "userSettings", "projectSettings"], - CLAUDE_CODE_SETTINGS_SCHEMA_URL: "https://json.schemastore.org/claude-code-settings.json", -})); + SETTING_SOURCES: ['localSettings', 'userSettings', 'projectSettings'], + SOURCES: ['localSettings', 'userSettings', 'projectSettings'], + CLAUDE_CODE_SETTINGS_SCHEMA_URL: + 'https://json.schemastore.org/claude-code-settings.json', +})) -const { - resolveAgentOverrides, - compareAgentsByName, - AGENT_SOURCE_GROUPS, -} = await import("../agentDisplay"); +const { resolveAgentOverrides, compareAgentsByName, AGENT_SOURCE_GROUPS } = + await import('../agentDisplay') function makeAgent(agentType: string, source: string): any { - return { agentType, source, name: agentType }; + return { agentType, source, name: agentType } } -describe("resolveAgentOverrides", () => { - test("marks no overrides when all agents active", () => { - const agents = [makeAgent("builder", "userSettings")]; - const result = resolveAgentOverrides(agents, agents); - expect(result).toHaveLength(1); - expect(result[0].overriddenBy).toBeUndefined(); - }); +describe('resolveAgentOverrides', () => { + test('marks no overrides when all agents active', () => { + const agents = [makeAgent('builder', 'userSettings')] + const result = resolveAgentOverrides(agents, agents) + expect(result).toHaveLength(1) + expect(result[0].overriddenBy).toBeUndefined() + }) - test("marks inactive agent as overridden", () => { + test('marks inactive agent as overridden', () => { const allAgents = [ - makeAgent("builder", "projectSettings"), - makeAgent("builder", "userSettings"), - ]; - const activeAgents = [makeAgent("builder", "userSettings")]; - const result = resolveAgentOverrides(allAgents, activeAgents); - const projectAgent = result.find( - (a: any) => a.source === "projectSettings", - ); - expect(projectAgent?.overriddenBy).toBe("userSettings"); - }); + makeAgent('builder', 'projectSettings'), + makeAgent('builder', 'userSettings'), + ] + const activeAgents = [makeAgent('builder', 'userSettings')] + const result = resolveAgentOverrides(allAgents, activeAgents) + const projectAgent = result.find((a: any) => a.source === 'projectSettings') + expect(projectAgent?.overriddenBy).toBe('userSettings') + }) - test("overriddenBy shows the overriding agent source", () => { - const allAgents = [makeAgent("tester", "localSettings")]; - const activeAgents = [makeAgent("tester", "policySettings")]; - const result = resolveAgentOverrides(allAgents, activeAgents); - expect(result[0].overriddenBy).toBe("policySettings"); - }); + test('overriddenBy shows the overriding agent source', () => { + const allAgents = [makeAgent('tester', 'localSettings')] + const activeAgents = [makeAgent('tester', 'policySettings')] + const result = resolveAgentOverrides(allAgents, activeAgents) + expect(result[0].overriddenBy).toBe('policySettings') + }) - test("deduplicates agents by (agentType, source)", () => { + test('deduplicates agents by (agentType, source)', () => { const agents = [ - makeAgent("builder", "userSettings"), - makeAgent("builder", "userSettings"), // duplicate - ]; - const result = resolveAgentOverrides(agents, agents.slice(0, 1)); - expect(result).toHaveLength(1); - }); + makeAgent('builder', 'userSettings'), + makeAgent('builder', 'userSettings'), // duplicate + ] + const result = resolveAgentOverrides(agents, agents.slice(0, 1)) + expect(result).toHaveLength(1) + }) - test("preserves agent definition properties", () => { - const agents = [{ agentType: "a", source: "userSettings", name: "Agent A" }] as any[]; - const result = resolveAgentOverrides(agents, agents); - expect((result[0] as any).name).toBe("Agent A"); - expect(result[0].agentType).toBe("a"); - }); - - test("handles empty arrays", () => { - expect(resolveAgentOverrides([], [])).toEqual([]); - }); - - test("handles agent from git worktree (duplicate detection)", () => { + test('preserves agent definition properties', () => { const agents = [ - makeAgent("builder", "projectSettings"), - makeAgent("builder", "projectSettings"), - makeAgent("builder", "localSettings"), - ]; - const result = resolveAgentOverrides(agents, agents.slice(0, 1)); + { agentType: 'a', source: 'userSettings', name: 'Agent A' }, + ] as any[] + const result = resolveAgentOverrides(agents, agents) + expect((result[0] as any).name).toBe('Agent A') + expect(result[0].agentType).toBe('a') + }) + + test('handles empty arrays', () => { + expect(resolveAgentOverrides([], [])).toEqual([]) + }) + + test('handles agent from git worktree (duplicate detection)', () => { + const agents = [ + makeAgent('builder', 'projectSettings'), + makeAgent('builder', 'projectSettings'), + makeAgent('builder', 'localSettings'), + ] + const result = resolveAgentOverrides(agents, agents.slice(0, 1)) // Deduped: projectSettings appears once, localSettings once - expect(result).toHaveLength(2); - }); -}); + expect(result).toHaveLength(2) + }) +}) -describe("compareAgentsByName", () => { - test("sorts alphabetically ascending", () => { - const a = makeAgent("alpha", "userSettings"); - const b = makeAgent("beta", "userSettings"); - expect(compareAgentsByName(a, b)).toBeLessThan(0); - }); +describe('compareAgentsByName', () => { + test('sorts alphabetically ascending', () => { + const a = makeAgent('alpha', 'userSettings') + const b = makeAgent('beta', 'userSettings') + expect(compareAgentsByName(a, b)).toBeLessThan(0) + }) - test("returns negative when a.name < b.name", () => { - const a = makeAgent("a", "s"); - const b = makeAgent("b", "s"); - expect(compareAgentsByName(a, b)).toBeLessThan(0); - }); + test('returns negative when a.name < b.name', () => { + const a = makeAgent('a', 's') + const b = makeAgent('b', 's') + expect(compareAgentsByName(a, b)).toBeLessThan(0) + }) - test("returns positive when a.name > b.name", () => { - const a = makeAgent("z", "s"); - const b = makeAgent("a", "s"); - expect(compareAgentsByName(a, b)).toBeGreaterThan(0); - }); + test('returns positive when a.name > b.name', () => { + const a = makeAgent('z', 's') + const b = makeAgent('a', 's') + expect(compareAgentsByName(a, b)).toBeGreaterThan(0) + }) - test("returns 0 for same name", () => { - const a = makeAgent("same", "s"); - const b = makeAgent("same", "s"); - expect(compareAgentsByName(a, b)).toBe(0); - }); + test('returns 0 for same name', () => { + const a = makeAgent('same', 's') + const b = makeAgent('same', 's') + expect(compareAgentsByName(a, b)).toBe(0) + }) - test("is case-insensitive (sensitivity: base)", () => { - const a = makeAgent("Alpha", "s"); - const b = makeAgent("alpha", "s"); - expect(compareAgentsByName(a, b)).toBe(0); - }); -}); + test('is case-insensitive (sensitivity: base)', () => { + const a = makeAgent('Alpha', 's') + const b = makeAgent('alpha', 's') + expect(compareAgentsByName(a, b)).toBe(0) + }) +}) -describe("AGENT_SOURCE_GROUPS", () => { - test("contains expected source groups in order", () => { - expect(AGENT_SOURCE_GROUPS).toHaveLength(7); +describe('AGENT_SOURCE_GROUPS', () => { + test('contains expected source groups in order', () => { + expect(AGENT_SOURCE_GROUPS).toHaveLength(7) expect(AGENT_SOURCE_GROUPS[0]).toEqual({ - label: "User agents", - source: "userSettings", - }); + label: 'User agents', + source: 'userSettings', + }) expect(AGENT_SOURCE_GROUPS[6]).toEqual({ - label: "Built-in agents", - source: "built-in", - }); - }); + label: 'Built-in agents', + source: 'built-in', + }) + }) - test("has unique labels", () => { - const labels = AGENT_SOURCE_GROUPS.map((g) => g.label); - expect(new Set(labels).size).toBe(labels.length); - }); + test('has unique labels', () => { + const labels = AGENT_SOURCE_GROUPS.map(g => g.label) + expect(new Set(labels).size).toBe(labels.length) + }) - test("has unique sources", () => { - const sources = AGENT_SOURCE_GROUPS.map((g) => g.source); - expect(new Set(sources).size).toBe(sources.length); - }); -}); + test('has unique sources', () => { + const sources = AGENT_SOURCE_GROUPS.map(g => g.source) + expect(new Set(sources).size).toBe(sources.length) + }) +}) diff --git a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts index 90525baad..4928fc90f 100644 --- a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts +++ b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts @@ -1,69 +1,72 @@ -import { mock, describe, expect, test } from "bun:test"; -import { debugMock } from "../../../../../../tests/mocks/debug"; +import { mock, describe, expect, test } from 'bun:test' +import { debugMock } from '../../../../../../tests/mocks/debug' // ─── Mocks for agentToolUtils.ts dependencies ─── // Only mock modules that are truly unavailable or cause side effects. // Do NOT mock common/shared modules (zod/v4, bootstrap/state, etc.) to avoid // corrupting the module cache for other test files in the same Bun process. -const noop = () => {}; +const noop = () => {} -mock.module("bun:bundle", () => ({ feature: () => false })); +mock.module('bun:bundle', () => ({ feature: () => false })) -mock.module("src/constants/tools.js", () => ({ +mock.module('src/constants/tools.js', () => ({ ALL_AGENT_DISALLOWED_TOOLS: new Set(), ASYNC_AGENT_ALLOWED_TOOLS: new Set(), CUSTOM_AGENT_DISALLOWED_TOOLS: new Set(), IN_PROCESS_TEAMMATE_ALLOWED_TOOLS: new Set(), -})); +})) -mock.module("src/services/AgentSummary/agentSummary.js", () => ({ +mock.module('src/services/AgentSummary/agentSummary.js', () => ({ startAgentSummarization: noop, -})); +})) -mock.module("src/services/analytics/index.js", () => ({ +mock.module('src/services/analytics/index.js', () => ({ logEvent: noop, logEventAsync: async () => {}, stripProtoFields: (v: any) => v, attachAnalyticsSink: noop, _resetForTesting: noop, AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined, -})); +})) -mock.module("src/services/api/dumpPrompts.js", () => ({ +mock.module('src/services/api/dumpPrompts.js', () => ({ clearDumpState: noop, -})); +})) -mock.module("src/Tool.js", () => ({ +mock.module('src/Tool.js', () => ({ toolMatchesName: () => false, findToolByName: noop, -})); +})) // messages.ts is complex - provide stubs for all named exports -mock.module("src/utils/messages.ts", () => ({ +mock.module('src/utils/messages.ts', () => ({ extractTextContent: (content: any[]) => - content?.filter?.((b: any) => b.type === "text")?.map?.((b: any) => b.text)?.join("") ?? "", + content + ?.filter?.((b: any) => b.type === 'text') + ?.map?.((b: any) => b.text) + ?.join('') ?? '', getLastAssistantMessage: () => null, SYNTHETIC_MESSAGES: new Set(), - INTERRUPT_MESSAGE: "", - INTERRUPT_MESSAGE_FOR_TOOL_USE: "", - CANCEL_MESSAGE: "", - REJECT_MESSAGE: "", - REJECT_MESSAGE_WITH_REASON_PREFIX: "", - SUBAGENT_REJECT_MESSAGE: "", - SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: "", - PLAN_REJECTION_PREFIX: "", - DENIAL_WORKAROUND_GUIDANCE: "", - NO_RESPONSE_REQUESTED: "", - SYNTHETIC_TOOL_RESULT_PLACEHOLDER: "", - SYNTHETIC_MODEL: "", + INTERRUPT_MESSAGE: '', + INTERRUPT_MESSAGE_FOR_TOOL_USE: '', + CANCEL_MESSAGE: '', + REJECT_MESSAGE: '', + REJECT_MESSAGE_WITH_REASON_PREFIX: '', + SUBAGENT_REJECT_MESSAGE: '', + SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: '', + PLAN_REJECTION_PREFIX: '', + DENIAL_WORKAROUND_GUIDANCE: '', + NO_RESPONSE_REQUESTED: '', + SYNTHETIC_TOOL_RESULT_PLACEHOLDER: '', + SYNTHETIC_MODEL: '', AUTO_REJECT_MESSAGE: noop, DONT_ASK_REJECT_MESSAGE: noop, withMemoryCorrectionHint: (s: string) => s, - deriveShortMessageId: () => "", + deriveShortMessageId: () => '', isClassifierDenial: () => false, - buildYoloRejectionMessage: () => "", - buildClassifierUnavailableMessage: () => "", + buildYoloRejectionMessage: () => '', + buildClassifierUnavailableMessage: () => '', isEmptyMessageText: () => true, createAssistantMessage: noop, createAssistantAPIErrorMessage: noop, @@ -72,9 +75,9 @@ mock.module("src/utils/messages.ts", () => ({ createUserInterruptionMessage: noop, createSyntheticUserCaveatMessage: noop, formatCommandInputTags: noop, -})); +})) -mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ +mock.module('src/tasks/LocalAgentTask/LocalAgentTask.js', () => ({ completeAgentTask: noop, createActivityDescriptionResolver: () => ({}), createProgressTracker: () => ({}), @@ -86,11 +89,11 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ killAsyncAgent: noop, updateAgentProgress: noop, updateProgressFromMessage: noop, -})); +})) -mock.module("src/utils/debug.ts", debugMock); +mock.module('src/utils/debug.ts', debugMock) -mock.module("src/utils/errors.js", () => ({ +mock.module('src/utils/errors.js', () => ({ ClaudeError: class extends Error {}, MalformedCommandError: class extends Error {}, AbortError: class extends Error {}, @@ -100,142 +103,137 @@ mock.module("src/utils/errors.js", () => ({ TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: class extends Error {}, isAbortError: () => false, hasExactErrorMessage: () => false, - toError: (e: any) => e instanceof Error ? e : new Error(String(e)), + toError: (e: any) => (e instanceof Error ? e : new Error(String(e))), errorMessage: (e: any) => String(e), getErrnoCode: () => undefined, isENOENT: () => false, getErrnoPath: () => undefined, - shortErrorStack: () => "", + shortErrorStack: () => '', isFsInaccessible: () => false, - classifyAxiosError: () => ({ category: "unknown" }), -})); + classifyAxiosError: () => ({ category: 'unknown' }), +})) -mock.module("src/utils/forkedAgent.js", () => ({})); +mock.module('src/utils/forkedAgent.js', () => ({})) -mock.module("src/utils/permissions/yoloClassifier.js", () => ({ - buildTranscriptForClassifier: () => "", +mock.module('src/utils/permissions/yoloClassifier.js', () => ({ + buildTranscriptForClassifier: () => '', classifyYoloAction: () => null, -})); +})) -mock.module("src/utils/task/sdkProgress.js", () => ({ +mock.module('src/utils/task/sdkProgress.js', () => ({ emitTaskProgress: noop, -})); +})) -mock.module("src/utils/tokens.js", () => ({ +mock.module('src/utils/tokens.js', () => ({ getTokenCountFromUsage: () => 0, -})); +})) -mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({ - EXIT_PLAN_MODE_V2_TOOL_NAME: "exit_plan_mode", -})); +mock.module('src/tools/ExitPlanModeTool/constants.js', () => ({ + EXIT_PLAN_MODE_V2_TOOL_NAME: 'exit_plan_mode', +})) -mock.module("src/tools/AgentTool/constants.js", () => ({ - AGENT_TOOL_NAME: "agent", - LEGACY_AGENT_TOOL_NAME: "task", -})); +mock.module('src/tools/AgentTool/constants.js', () => ({ + AGENT_TOOL_NAME: 'agent', + LEGACY_AGENT_TOOL_NAME: 'task', +})) -mock.module("src/tools/AgentTool/loadAgentsDir.js", () => ({})); +mock.module('src/tools/AgentTool/loadAgentsDir.js', () => ({})) -mock.module("src/state/AppState.js", () => ({})); +mock.module('src/state/AppState.js', () => ({})) -mock.module("src/types/ids.js", () => ({ +mock.module('src/types/ids.js', () => ({ asAgentId: (id: string) => id, -})); +})) // Break circular dep -mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({ +mock.module('src/tools/AgentTool/AgentTool.tsx', () => ({ AgentTool: {}, inputSchema: {}, outputSchema: {}, default: {}, -})); +})) -const { - countToolUses, - getLastToolUseName, -} = await import("../agentToolUtils"); +const { countToolUses, getLastToolUseName } = await import('../agentToolUtils') function makeAssistantMessage(content: any[]): any { - return { type: "assistant", message: { content } }; + return { type: 'assistant', message: { content } } } function makeUserMessage(text: string): any { - return { type: "user", message: { content: text } }; + return { type: 'user', message: { content: text } } } -describe("countToolUses", () => { - test("counts tool_use blocks in messages", () => { +describe('countToolUses', () => { + test('counts tool_use blocks in messages', () => { const messages = [ makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "text", text: "hello" }, + { type: 'tool_use', name: 'Read' }, + { type: 'text', text: 'hello' }, ]), - ]; - expect(countToolUses(messages)).toBe(1); - }); + ] + expect(countToolUses(messages)).toBe(1) + }) - test("returns 0 for messages without tool_use", () => { + test('returns 0 for messages without tool_use', () => { + const messages = [makeAssistantMessage([{ type: 'text', text: 'hello' }])] + expect(countToolUses(messages)).toBe(0) + }) + + test('returns 0 for empty array', () => { + expect(countToolUses([])).toBe(0) + }) + + test('counts multiple tool_use blocks across messages', () => { const messages = [ - makeAssistantMessage([{ type: "text", text: "hello" }]), - ]; - expect(countToolUses(messages)).toBe(0); - }); + makeAssistantMessage([{ type: 'tool_use', name: 'Read' }]), + makeUserMessage('ok'), + makeAssistantMessage([{ type: 'tool_use', name: 'Write' }]), + ] + expect(countToolUses(messages)).toBe(2) + }) - test("returns 0 for empty array", () => { - expect(countToolUses([])).toBe(0); - }); - - test("counts multiple tool_use blocks across messages", () => { - const messages = [ - makeAssistantMessage([{ type: "tool_use", name: "Read" }]), - makeUserMessage("ok"), - makeAssistantMessage([{ type: "tool_use", name: "Write" }]), - ]; - expect(countToolUses(messages)).toBe(2); - }); - - test("counts tool_use in single message with multiple blocks", () => { + test('counts tool_use in single message with multiple blocks', () => { const messages = [ makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "tool_use", name: "Grep" }, - { type: "tool_use", name: "Write" }, + { type: 'tool_use', name: 'Read' }, + { type: 'tool_use', name: 'Grep' }, + { type: 'tool_use', name: 'Write' }, ]), - ]; - expect(countToolUses(messages)).toBe(3); - }); -}); + ] + expect(countToolUses(messages)).toBe(3) + }) +}) -describe("getLastToolUseName", () => { - test("returns last tool name from assistant message", () => { +describe('getLastToolUseName', () => { + test('returns last tool name from assistant message', () => { const msg = makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "tool_use", name: "Write" }, - ]); - expect(getLastToolUseName(msg)).toBe("Write"); - }); + { type: 'tool_use', name: 'Read' }, + { type: 'tool_use', name: 'Write' }, + ]) + expect(getLastToolUseName(msg)).toBe('Write') + }) - test("returns undefined for message without tool_use", () => { - const msg = makeAssistantMessage([{ type: "text", text: "hello" }]); - expect(getLastToolUseName(msg)).toBeUndefined(); - }); + test('returns undefined for message without tool_use', () => { + const msg = makeAssistantMessage([{ type: 'text', text: 'hello' }]) + expect(getLastToolUseName(msg)).toBeUndefined() + }) - test("returns the last tool when multiple tool_uses present", () => { + test('returns the last tool when multiple tool_uses present', () => { const msg = makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "tool_use", name: "Grep" }, - { type: "tool_use", name: "Edit" }, - ]); - expect(getLastToolUseName(msg)).toBe("Edit"); - }); + { type: 'tool_use', name: 'Read' }, + { type: 'tool_use', name: 'Grep' }, + { type: 'tool_use', name: 'Edit' }, + ]) + expect(getLastToolUseName(msg)).toBe('Edit') + }) - test("returns undefined for non-assistant message", () => { - const msg = makeUserMessage("hello"); - expect(getLastToolUseName(msg)).toBeUndefined(); - }); + test('returns undefined for non-assistant message', () => { + const msg = makeUserMessage('hello') + expect(getLastToolUseName(msg)).toBeUndefined() + }) - test("handles message with null content", () => { - const msg = { type: "assistant", message: { content: null } } as any; - expect(getLastToolUseName(msg)).toBeUndefined(); - }); -}); + test('handles message with null content', () => { + const msg = { type: 'assistant', message: { content: null } } as any + expect(getLastToolUseName(msg)).toBeUndefined() + }) +}) diff --git a/packages/builtin-tools/src/tools/AgentTool/__tests__/filterIncompleteToolCalls.test.ts b/packages/builtin-tools/src/tools/AgentTool/__tests__/filterIncompleteToolCalls.test.ts index 5429c6ce8..9e52d8f17 100644 --- a/packages/builtin-tools/src/tools/AgentTool/__tests__/filterIncompleteToolCalls.test.ts +++ b/packages/builtin-tools/src/tools/AgentTool/__tests__/filterIncompleteToolCalls.test.ts @@ -67,7 +67,9 @@ describe('filterIncompleteToolCalls', () => { uuid: 'u1', message: { role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }], + content: [ + { type: 'tool_result', tool_use_id: 'done', content: 'ok' }, + ], }, }, ] as unknown as Message[] @@ -100,7 +102,9 @@ describe('filterIncompleteToolCalls', () => { uuid: 'u1', message: { role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'done', content: 'ok' }], + content: [ + { type: 'tool_result', tool_use_id: 'done', content: 'ok' }, + ], }, }, ] as unknown as Message[] diff --git a/packages/builtin-tools/src/tools/AgentTool/agentMemory.ts b/packages/builtin-tools/src/tools/AgentTool/agentMemory.ts index d1b16f1e2..722203c12 100644 --- a/packages/builtin-tools/src/tools/AgentTool/agentMemory.ts +++ b/packages/builtin-tools/src/tools/AgentTool/agentMemory.ts @@ -1,9 +1,6 @@ import { join, normalize, sep } from 'path' import { getProjectRoot } from 'src/bootstrap/state.js' -import { - buildMemoryPrompt, - ensureMemoryDirExists, -} from 'src/memdir/memdir.js' +import { buildMemoryPrompt, ensureMemoryDirExists } from 'src/memdir/memdir.js' import { getMemoryBaseDir } from 'src/memdir/paths.js' import { getCwd } from 'src/utils/cwd.js' import { findCanonicalGitRoot } from 'src/utils/git.js' diff --git a/packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts b/packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts index e8cf493f8..ec510be92 100644 --- a/packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts +++ b/packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts @@ -302,14 +302,16 @@ export function finalizeAgentTool( // Extract text content from the agent's response. If the final assistant // message is a pure tool_use block (loop exited mid-turn), fall back to // the most recent assistant message that has text content. - let content = (lastAssistantMessage.message?.content as ContentItem[] ?? []).filter( - _ => _.type === 'text', - ) + let content = ( + (lastAssistantMessage.message?.content as ContentItem[]) ?? [] + ).filter(_ => _.type === 'text') if (content.length === 0) { for (let i = agentMessages.length - 1; i >= 0; i--) { const m = agentMessages[i]! if (m.type !== 'assistant') continue - const textBlocks = (m.message?.content as ContentItem[] ?? []).filter(_ => _.type === 'text') + const textBlocks = ((m.message?.content as ContentItem[]) ?? []).filter( + _ => _.type === 'text', + ) if (textBlocks.length > 0) { content = textBlocks break @@ -317,7 +319,11 @@ export function finalizeAgentTool( } } - const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message?.usage as Parameters[0]) + const totalTokens = getTokenCountFromUsage( + lastAssistantMessage.message?.usage as Parameters< + typeof getTokenCountFromUsage + >[0], + ) const totalToolUseCount = countToolUses(agentMessages) logEvent('tengu_agent_tool_completed', { @@ -363,7 +369,9 @@ export function finalizeAgentTool( */ export function getLastToolUseName(message: MessageType): string | undefined { if (message.type !== 'assistant') return undefined - const block = (message.message?.content as ContentItem[] ?? []).findLast(b => b.type === 'tool_use') + const block = ((message.message?.content as ContentItem[]) ?? []).findLast( + b => b.type === 'tool_use', + ) return block?.type === 'tool_use' ? block.name : undefined } @@ -492,7 +500,10 @@ export function extractPartialResult( for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]! if (m.type !== 'assistant') continue - const text = extractTextContent(m.message?.content as ContentItem[] ?? [], '\n') + const text = extractTextContent( + (m.message?.content as ContentItem[]) ?? [], + '\n', + ) if (text) { return text } diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts index 2da8eb7a9..738010863 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type BASH_TOOL_NAME = any; +export type BASH_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts index 11f9fd01d..8204b84f9 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type EXIT_PLAN_MODE_TOOL_NAME = any; +export type EXIT_PLAN_MODE_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts index b455c0655..f851a8bcc 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FILE_EDIT_TOOL_NAME = any; +export type FILE_EDIT_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts index fac6439fc..e8c6709b3 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FILE_READ_TOOL_NAME = any; +export type FILE_READ_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts index e69299d74..45cc15c49 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FILE_WRITE_TOOL_NAME = any; +export type FILE_WRITE_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts index 060caf29c..5ff2b16bb 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type GLOB_TOOL_NAME = any; +export type GLOB_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts index 08b8a8d29..4645d4c52 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type GREP_TOOL_NAME = any; +export type GREP_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts index 6c6c94bad..3c1d7a0d2 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type NOTEBOOK_EDIT_TOOL_NAME = any; +export type NOTEBOOK_EDIT_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts index efd60265b..ae67746ae 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SEND_MESSAGE_TOOL_NAME = any; +export type SEND_MESSAGE_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts index 63b342a25..83e9643c5 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type WEB_FETCH_TOOL_NAME = any; +export type WEB_FETCH_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts index 38871a0ba..3d3f02b32 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type WEB_SEARCH_TOOL_NAME = any; +export type WEB_SEARCH_TOOL_NAME = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/auth.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/auth.ts index 909e31047..b3e5dee07 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/auth.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isUsing3PServices = any; +export type isUsing3PServices = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts index c0160dbf9..0568d4b57 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type hasEmbeddedSearchTools = any; +export type hasEmbeddedSearchTools = any diff --git a/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/settings/settings.ts b/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/settings/settings.ts index 4b9b819d5..b4678951c 100644 --- a/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/settings/settings.ts +++ b/packages/builtin-tools/src/tools/AgentTool/built-in/src/utils/settings/settings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getSettings_DEPRECATED = any; +export type getSettings_DEPRECATED = any diff --git a/packages/builtin-tools/src/tools/AgentTool/forkSubagent.ts b/packages/builtin-tools/src/tools/AgentTool/forkSubagent.ts index 4ab95e66a..7242cd12b 100644 --- a/packages/builtin-tools/src/tools/AgentTool/forkSubagent.ts +++ b/packages/builtin-tools/src/tools/AgentTool/forkSubagent.ts @@ -115,14 +115,20 @@ export function buildForkedMessages( uuid: randomUUID(), message: { ...assistantMessage.message, - content: [...(Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : [])], + content: [ + ...(Array.isArray(assistantMessage.message.content) + ? assistantMessage.message.content + : []), + ], }, } // Collect all tool_use blocks from the assistant message - const toolUseBlocks = (Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : []).filter( - (block): block is BetaToolUseBlock => block.type === 'tool_use', - ) + const toolUseBlocks = ( + Array.isArray(assistantMessage.message.content) + ? assistantMessage.message.content + : [] + ).filter((block): block is BetaToolUseBlock => block.type === 'tool_use') if (toolUseBlocks.length === 0) { logForDebugging( diff --git a/packages/builtin-tools/src/tools/AgentTool/src/Tool.ts b/packages/builtin-tools/src/tools/AgentTool/src/Tool.ts index 7e33e7efc..cec74692f 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/Tool.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/Tool.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type buildTool = any; -export type ToolDef = any; -export type toolMatchesName = any; +export type buildTool = any +export type ToolDef = any +export type toolMatchesName = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts b/packages/builtin-tools/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts index d68e6f6e0..e1ede3353 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ConfigurableShortcutHint = any; +export type ConfigurableShortcutHint = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/components/CtrlOToExpand.ts b/packages/builtin-tools/src/tools/AgentTool/src/components/CtrlOToExpand.ts index b8e3b0a62..05c1118ac 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/components/CtrlOToExpand.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/components/CtrlOToExpand.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type CtrlOToExpand = any; -export type SubAgentProvider = any; +export type CtrlOToExpand = any +export type SubAgentProvider = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/Byline.ts b/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/Byline.ts index ed8c71384..5cc7c977b 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/Byline.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/Byline.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Byline = any; +export type Byline = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts b/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts index ab506bb31..73c2d3482 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type KeyboardShortcutHint = any; +export type KeyboardShortcutHint = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/types/message.ts b/packages/builtin-tools/src/tools/AgentTool/src/types/message.ts index 4b0a33f37..3d11fb316 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/types/message.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/types/message.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type Message = any; -export type NormalizedUserMessage = any; +export type Message = any +export type NormalizedUserMessage = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/utils/debug.ts b/packages/builtin-tools/src/tools/AgentTool/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/utils/debug.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/utils/debug.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDebugging = any; +export type logForDebugging = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/utils/promptCategory.ts b/packages/builtin-tools/src/tools/AgentTool/src/utils/promptCategory.ts index 207db7233..fb3e57898 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/utils/promptCategory.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/utils/promptCategory.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getQuerySourceForAgent = any; +export type getQuerySourceForAgent = any diff --git a/packages/builtin-tools/src/tools/AgentTool/src/utils/settings/constants.ts b/packages/builtin-tools/src/tools/AgentTool/src/utils/settings/constants.ts index b82138d6a..24eb36c76 100644 --- a/packages/builtin-tools/src/tools/AgentTool/src/utils/settings/constants.ts +++ b/packages/builtin-tools/src/tools/AgentTool/src/utils/settings/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SettingSource = any; +export type SettingSource = any diff --git a/packages/builtin-tools/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx b/packages/builtin-tools/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx index c2c964d97..41058428c 100644 --- a/packages/builtin-tools/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +++ b/packages/builtin-tools/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx @@ -1,24 +1,21 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { - getAllowedChannels, - getQuestionPreviewFormat, -} from 'src/bootstrap/state.js' -import { MessageResponse } from 'src/components/MessageResponse.js' -import { BLACK_CIRCLE } from 'src/constants/figures.js' -import { getModeColor } from 'src/utils/permissions/PermissionMode.js' -import { z } from 'zod/v4' -import { Box, Text } from '@anthropic/ink' -import type { Tool } from 'src/Tool.js' -import { buildTool, type ToolDef } from 'src/Tool.js' -import { lazySchema } from 'src/utils/lazySchema.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js'; +import { MessageResponse } from 'src/components/MessageResponse.js'; +import { BLACK_CIRCLE } from 'src/constants/figures.js'; +import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; +import { z } from 'zod/v4'; +import { Box, Text } from '@anthropic/ink'; +import type { Tool } from 'src/Tool.js'; +import { buildTool, type ToolDef } from 'src/Tool.js'; +import { lazySchema } from 'src/utils/lazySchema.js'; import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT, -} from './prompt.js' +} from './prompt.js'; const questionOptionSchema = lazySchema(() => z.object({ @@ -39,7 +36,7 @@ const questionOptionSchema = lazySchema(() => 'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.', ), }), -) +); const questionSchema = lazySchema(() => z.object({ @@ -67,55 +64,44 @@ const questionSchema = lazySchema(() => 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', ), }), -) +); const annotationsSchema = lazySchema(() => { const annotationSchema = z.object({ preview: z .string() .optional() - .describe( - 'The preview content of the selected option, if the question used previews.', - ), - notes: z - .string() - .optional() - .describe('Free-text notes the user added to their selection.'), - }) + .describe('The preview content of the selected option, if the question used previews.'), + notes: z.string().optional().describe('Free-text notes the user added to their selection.'), + }); return z .record(z.string(), annotationSchema) .optional() .describe( 'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.', - ) -}) + ); +}); const UNIQUENESS_REFINE = { - check: (data: { - questions: { question: string; options: { label: string }[] }[] - }) => { - const questions = data.questions.map(q => q.question) + check: (data: { questions: { question: string; options: { label: string }[] }[] }) => { + const questions = data.questions.map(q => q.question); if (questions.length !== new Set(questions).size) { - return false + return false; } for (const question of data.questions) { - const labels = question.options.map(opt => opt.label) + const labels = question.options.map(opt => opt.label); if (labels.length !== new Set(labels).size) { - return false + return false; } } - return true + return true; }, - message: - 'Question texts must be unique, option labels must be unique within each question', -} as const + message: 'Question texts must be unique, option labels must be unique within each question', +} as const; const commonFields = lazySchema(() => ({ - answers: z - .record(z.string(), z.string()) - .optional() - .describe('User answers collected by the permission component'), + answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'), annotations: annotationsSchema(), metadata: z .object({ @@ -127,32 +113,24 @@ const commonFields = lazySchema(() => ({ ), }) .optional() - .describe( - 'Optional metadata for tracking and analytics purposes. Not displayed to user.', - ), -})) + .describe('Optional metadata for tracking and analytics purposes. Not displayed to user.'), +})); const inputSchema = lazySchema(() => z .strictObject({ - questions: z - .array(questionSchema()) - .min(1) - .max(4) - .describe('Questions to ask the user (1-4 questions)'), + questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'), ...commonFields(), }) .refine(UNIQUENESS_REFINE.check, { message: UNIQUENESS_REFINE.message, }), -) -type InputSchema = ReturnType +); +type InputSchema = ReturnType; const outputSchema = lazySchema(() => z.object({ - questions: z - .array(questionSchema()) - .describe('The questions that were asked'), + questions: z.array(questionSchema()).describe('The questions that were asked'), answers: z .record(z.string(), z.string()) .describe( @@ -160,23 +138,19 @@ const outputSchema = lazySchema(() => ), annotations: annotationsSchema(), }), -) -type OutputSchema = ReturnType +); +type OutputSchema = ReturnType; // SDK schemas are identical to internal schemas now that `preview` and // `annotations` are public (configurable via `toolConfig.askUserQuestion`). -export const _sdkInputSchema = inputSchema -export const _sdkOutputSchema = outputSchema +export const _sdkInputSchema = inputSchema; +export const _sdkOutputSchema = outputSchema; -export type Question = z.infer> -export type QuestionOption = z.infer> -export type Output = z.infer +export type Question = z.infer>; +export type QuestionOption = z.infer>; +export type Output = z.infer; -function AskUserQuestionResultMessage({ - answers, -}: { - answers: Output['answers'] -}): React.ReactNode { +function AskUserQuestionResultMessage({ answers }: { answers: Output['answers'] }): React.ReactNode { return ( @@ -193,7 +167,7 @@ function AskUserQuestionResultMessage({ - ) + ); } export const AskUserQuestionTool: Tool = buildTool({ @@ -202,25 +176,25 @@ export const AskUserQuestionTool: Tool = buildTool({ maxResultSizeChars: 100_000, shouldDefer: true, async description() { - return DESCRIPTION + return DESCRIPTION; }, async prompt() { - const format = getQuestionPreviewFormat() + const format = getQuestionPreviewFormat(); if (format === undefined) { // SDK consumer that hasn't opted into a preview format — omit preview // guidance (they may not render the field at all). - return ASK_USER_QUESTION_TOOL_PROMPT + return ASK_USER_QUESTION_TOOL_PROMPT; } - return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format] + return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]; }, get inputSchema(): InputSchema { - return inputSchema() + return inputSchema(); }, get outputSchema(): OutputSchema { - return outputSchema() + return outputSchema(); }, userFacingName() { - return '' + return ''; }, isEnabled() { // When --channels is active the user is likely on Telegram/Discord, not @@ -228,59 +202,56 @@ export const AskUserQuestionTool: Tool = buildTool({ // the keyboard. Channel permission relay already skips // requiresUserInteraction() tools (interactiveHandler.ts) so there's // no alternate approval path. - if ( - (feature('KAIROS') || feature('KAIROS_CHANNELS')) && - getAllowedChannels().length > 0 - ) { - return false + if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) { + return false; } - return true + return true; }, isConcurrencySafe() { - return true + return true; }, isReadOnly() { - return true + return true; }, toAutoClassifierInput(input) { - return input.questions.map(q => q.question).join(' | ') + return input.questions.map(q => q.question).join(' | '); }, requiresUserInteraction() { - return true + return true; }, async validateInput({ questions }) { if (getQuestionPreviewFormat() !== 'html') { - return { result: true } + return { result: true }; } for (const q of questions) { for (const opt of q.options) { - const err = validateHtmlPreview(opt.preview) + const err = validateHtmlPreview(opt.preview); if (err) { return { result: false, message: `Option "${opt.label}" in question "${q.question}": ${err}`, errorCode: 1, - } + }; } } } - return { result: true } + return { result: true }; }, async checkPermissions(input) { return { behavior: 'ask' as const, message: 'Answer questions?', updatedInput: input, - } + }; }, renderToolUseMessage() { - return null + return null; }, renderToolUseProgressMessage() { - return null + return null; }, renderToolResultMessage({ answers }, _toolUseID) { - return + return ; }, renderToolUseRejectedMessage() { return ( @@ -288,55 +259,55 @@ export const AskUserQuestionTool: Tool = buildTool({ {BLACK_CIRCLE}  User declined to answer questions - ) + ); }, renderToolUseErrorMessage() { - return null + return null; }, async call({ questions, answers = {}, annotations }, _context) { return { data: { questions, answers, ...(annotations && { annotations }) }, - } + }; }, mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) { const answersText = Object.entries(answers) .map(([questionText, answer]) => { - const annotation = annotations?.[questionText] - const parts = [`"${questionText}"="${answer}"`] + const annotation = annotations?.[questionText]; + const parts = [`"${questionText}"="${answer}"`]; if (annotation?.preview) { - parts.push(`selected preview:\n${annotation.preview}`) + parts.push(`selected preview:\n${annotation.preview}`); } if (annotation?.notes) { - parts.push(`user notes: ${annotation.notes}`) + parts.push(`user notes: ${annotation.notes}`); } - return parts.join(' ') + return parts.join(' '); }) - .join(', ') + .join(', '); return { type: 'tool_result', content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`, tool_use_id: toolUseID, - } + }; }, -} satisfies ToolDef) +} satisfies ToolDef); // Lightweight HTML fragment check. Not a parser — HTML5 parsers are // error-recovering by spec and accept anything. We're checking model intent // (did it emit HTML?) and catching the specific things we told it not to do. function validateHtmlPreview(preview: string | undefined): string | null { - if (preview === undefined) return null + if (preview === undefined) return null; if (/<\s*(html|body|!doctype)\b/i.test(preview)) { - return 'preview must be an HTML fragment, not a full document (no , , or )' + return 'preview must be an HTML fragment, not a full document (no , , or )'; } // SDK consumers typically set this via innerHTML — disallow executable/style // tags so a preview can't run code or restyle the host page. Inline event // handlers (onclick etc.) are still possible; consumers should sanitize. if (/<\s*(script|style)\b/i.test(preview)) { - return 'preview must not contain